diff --git a/package.json b/package.json
index b90c750..1a58727 100644
--- a/package.json
+++ b/package.json
@@ -12,8 +12,8 @@
"start": "tsx src/server.ts",
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
- "build:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap",
- "dev:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap --watch",
+ "build:web": "esbuild public/main.ts public/manage.ts --bundle --format=esm --outdir=public/build --sourcemap",
+ "dev:web": "esbuild public/main.ts public/manage.ts --bundle --format=esm --outdir=public/build --sourcemap --watch",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json",
"test": "vitest run",
"test:watch": "vitest",
diff --git a/public/main.ts b/public/main.ts
index 60c75f0..215f0a5 100644
--- a/public/main.ts
+++ b/public/main.ts
@@ -67,6 +67,18 @@ mountHistory(toolbar, {
})
mountShortcuts(toolbar)
mountShareSession(toolbar, () => app.activeSessionId())
+
+// Session manager (standalone page) โ manage/kill the host's running sessions.
+const manageBtn = document.createElement('button')
+manageBtn.className = 'toolbtn'
+manageBtn.textContent = '๐'
+manageBtn.title = 'Manage sessions (open / kill)'
+manageBtn.setAttribute('aria-label', 'Manage sessions')
+manageBtn.addEventListener('click', () => {
+ location.href = '/manage.html'
+})
+toolbar.appendChild(manageBtn)
+
mountQrConnect(toolbar)
// PWA: register the service worker (installable + offline shell, M4).
diff --git a/public/manage.html b/public/manage.html
new file mode 100644
index 0000000..a1edba6
--- /dev/null
+++ b/public/manage.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Session Manager โ Web Terminal
+
+
+
+
+
+
+
+
diff --git a/public/manage.ts b/public/manage.ts
new file mode 100644
index 0000000..50d3f3b
--- /dev/null
+++ b/public/manage.ts
@@ -0,0 +1,144 @@
+/**
+ * public/manage.ts โ standalone Session Manager page (/manage.html).
+ *
+ * Lists the host's running server sessions (GET /live-sessions) and lets you
+ * open one (?join=), kill one (DELETE /live-sessions/:id), or bulk-kill all
+ * / only detached ones (DELETE /live-sessions[?detached=1]). Auto-refreshes.
+ * Independent of the main app, so it works even when many sessions are open.
+ */
+
+interface LiveSession {
+ id: string
+ createdAt: number
+ clientCount: number
+ status: 'working' | 'waiting' | 'idle' | 'unknown'
+ exited: boolean
+ cwd: string | null
+ cols: number
+ rows: number
+}
+
+const REFRESH_MS = 3000
+
+function relTime(ms: number): string {
+ const s = Math.max(0, (Date.now() - ms) / 1000)
+ if (s < 60) return `${Math.floor(s)}s`
+ if (s < 3600) return `${Math.floor(s / 60)}m`
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
+ return `${Math.floor(s / 86400)}d`
+}
+
+function statusGlyph(s: LiveSession['status']): string {
+ return s === 'working' ? 'โ working' : s === 'waiting' ? 'โณ waiting' : s === 'idle' ? 'โ idle' : 'ยท'
+}
+
+async function fetchSessions(): Promise {
+ try {
+ const res = await fetch('/live-sessions')
+ const data: unknown = await res.json()
+ return Array.isArray(data) ? (data as LiveSession[]) : []
+ } catch {
+ return []
+ }
+}
+
+const root = document.getElementById('manage-root')
+if (!root) throw new Error('#manage-root not found')
+
+let busy = false
+
+function el(
+ tag: K,
+ cls?: string,
+ text?: string,
+): HTMLElementTagNameMap[K] {
+ const node = document.createElement(tag)
+ if (cls) node.className = cls
+ if (text !== undefined) node.textContent = text
+ return node
+}
+
+async function killOne(id: string): Promise {
+ if (busy) return
+ busy = true
+ try {
+ await fetch(`/live-sessions/${id}`, { method: 'DELETE' })
+ } catch {
+ /* best-effort */
+ }
+ busy = false
+ void render()
+}
+
+async function killBulk(detachedOnly: boolean): Promise {
+ if (busy) return
+ const label = detachedOnly ? 'all DETACHED sessions (no device watching)' : 'ALL sessions'
+ if (!confirm(`Kill ${label}? Running shells/Claude will be terminated.`)) return
+ busy = true
+ try {
+ await fetch(`/live-sessions${detachedOnly ? '?detached=1' : ''}`, { method: 'DELETE' })
+ } catch {
+ /* best-effort */
+ }
+ busy = false
+ void render()
+}
+
+function row(s: LiveSession): HTMLElement {
+ const r = el('div', 'mg-row')
+
+ const main = el('div', 'mg-main')
+ const top = el('div', 'mg-top')
+ const name = el('span', 'mg-name', s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8))
+ const watchers = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `๐ ${s.clientCount}`)
+ const status = el('span', `mg-status mg-${s.status}`, statusGlyph(s.status))
+ top.append(name, watchers, status)
+ const meta = el('div', 'mg-meta', `${s.cwd ?? 'unknown dir'} ยท ${s.cols}ร${s.rows} ยท ${relTime(s.createdAt)} old ยท ${s.id.slice(0, 8)}`)
+ main.append(top, meta)
+
+ const actions = el('div', 'mg-actions')
+ const open = el('a', 'mg-open', 'Open โ') as HTMLAnchorElement
+ open.href = `/?join=${s.id}`
+ const kill = el('button', 'mg-kill', 'Kill โ')
+ kill.addEventListener('click', () => void killOne(s.id))
+ actions.append(open, kill)
+
+ r.append(main, actions)
+ return r
+}
+
+async function render(): Promise {
+ const sessions = await fetchSessions()
+ root!.replaceChildren()
+
+ const header = el('div', 'mg-header')
+ header.append(el('div', 'mg-title', 'Session Manager'))
+ const sub = el('div', 'mg-sub', `${sessions.length} session(s) running on the host`)
+ header.append(sub)
+
+ const bar = el('div', 'mg-bar')
+ const back = el('a', 'mg-btn', 'โ Back to terminal') as HTMLAnchorElement
+ back.href = '/'
+ const refresh = el('button', 'mg-btn', 'โป Refresh')
+ refresh.addEventListener('click', () => void render())
+ const killDetached = el('button', 'mg-btn warn', 'Kill detached')
+ killDetached.addEventListener('click', () => void killBulk(true))
+ const killAll = el('button', 'mg-btn danger', 'Kill all')
+ killAll.addEventListener('click', () => void killBulk(false))
+ bar.append(back, refresh, killDetached, killAll)
+ header.append(bar)
+ root!.append(header)
+
+ if (sessions.length === 0) {
+ root!.append(el('div', 'mg-empty', 'No sessions running. Open the terminal to start one.'))
+ return
+ }
+ const list = el('div', 'mg-list')
+ for (const s of sessions) list.append(row(s))
+ root!.append(list)
+}
+
+void render()
+setInterval(() => {
+ if (!busy) void render()
+}, REFRESH_MS)
diff --git a/public/style.css b/public/style.css
index 3e084d1..b953ddb 100644
--- a/public/style.css
+++ b/public/style.css
@@ -659,3 +659,144 @@ body {
background: var(--red);
color: #2a0808;
}
+
+/* โโ Session Manager page (manage.html) โโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+#manage-root {
+ max-width: 760px;
+ margin: 0 auto;
+ padding: 24px 16px 60px;
+}
+.mg-header {
+ margin-bottom: 18px;
+}
+.mg-title {
+ font-size: 22px;
+ font-weight: 700;
+ color: #fff;
+}
+.mg-sub {
+ color: var(--text-dim);
+ font-size: 13px;
+ margin-top: 2px;
+}
+.mg-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 14px;
+}
+.mg-btn {
+ background: var(--surface-2);
+ border: 1px solid var(--border-strong);
+ color: var(--text);
+ border-radius: 8px;
+ padding: 8px 14px;
+ cursor: pointer;
+ font: inherit;
+ font-size: 13px;
+ text-decoration: none;
+ transition: background 0.1s ease;
+}
+.mg-btn:hover {
+ background: var(--surface-3);
+}
+.mg-btn.warn {
+ border-color: rgba(245, 177, 76, 0.5);
+ color: var(--amber);
+}
+.mg-btn.danger {
+ border-color: rgba(255, 107, 107, 0.5);
+ color: var(--red);
+}
+.mg-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.mg-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+}
+.mg-main {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+.mg-top {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.mg-name {
+ color: #fff;
+ font-weight: 600;
+}
+.mg-watch {
+ font-size: 11px;
+ color: var(--text-faint);
+ background: var(--surface-3);
+ border-radius: 5px;
+ padding: 1px 6px;
+}
+.mg-watch.live {
+ color: var(--green);
+ background: rgba(70, 208, 127, 0.14);
+}
+.mg-status {
+ font-size: 11px;
+ color: var(--text-dim);
+}
+.mg-status.mg-waiting {
+ color: var(--amber);
+ font-weight: 600;
+}
+.mg-status.mg-working {
+ color: var(--accent);
+}
+.mg-meta {
+ color: var(--text-faint);
+ font-size: 12px;
+ margin-top: 3px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: Menlo, Consolas, monospace;
+}
+.mg-actions {
+ flex: none;
+ display: flex;
+ gap: 6px;
+}
+.mg-open {
+ background: var(--accent);
+ color: #fff;
+ border-radius: 8px;
+ padding: 7px 13px;
+ text-decoration: none;
+ font-size: 13px;
+}
+.mg-open:hover {
+ background: var(--accent-2);
+}
+.mg-kill {
+ background: transparent;
+ border: 1px solid rgba(255, 107, 107, 0.4);
+ color: var(--red);
+ border-radius: 8px;
+ padding: 7px 13px;
+ cursor: pointer;
+ font: inherit;
+ font-size: 13px;
+}
+.mg-kill:hover {
+ background: rgba(255, 107, 107, 0.14);
+}
+.mg-empty {
+ color: var(--text-dim);
+ padding: 30px 0;
+ text-align: center;
+}
diff --git a/public/terminal-session.ts b/public/terminal-session.ts
index 35b36b4..99638f0 100644
--- a/public/terminal-session.ts
+++ b/public/terminal-session.ts
@@ -351,6 +351,14 @@ export class TerminalSession {
hide(): void {
this.el.style.display = 'none'
+ // v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the
+ // shared PTY to this device's size. Output still streams (background mirror).
+ if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) {
+ this.ws.send(buildMessage({ type: 'blur' }))
+ }
+ // Force show() to re-cast our dims even if unchanged.
+ this.lastCols = -1
+ this.lastRows = -1
}
/** Tear down: closing the WS detaches โ the server PTY keeps running. */
diff --git a/src/protocol.ts b/src/protocol.ts
index 917369c..59ef83a 100644
--- a/src/protocol.ts
+++ b/src/protocol.ts
@@ -24,7 +24,7 @@ export const SESSION_ID_RE =
// โโโ Type whitelist โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-const ALLOWED_TYPES = new Set(['attach', 'input', 'resize', 'approve', 'reject'])
+const ALLOWED_TYPES = new Set(['attach', 'input', 'resize', 'blur', 'approve', 'reject'])
// โโโ parseClientMessage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -73,6 +73,11 @@ export function parseClientMessage(raw: string): ParseResult {
return validateInput(obj)
}
+ // blur (v0.4) carries no payload โ withdraw this client's size vote.
+ if (type === 'blur') {
+ return { ok: true, message: { type: 'blur' } }
+ }
+
// approve/reject (H3) carry no payload.
if (type === 'approve') {
return { ok: true, message: { type: 'approve' } }
diff --git a/src/server.ts b/src/server.ts
index 077e28e..fab3ace 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -39,7 +39,7 @@ import { isOriginAllowed } from './http/origin.js'
import { parseHookEvent } from './http/hook.js'
import { listSessions } from './http/history.js'
import { createSessionManager } from './session/manager.js'
-import { detachWs, writeInput, setClientDims } from './session/session.js'
+import { detachWs, writeInput, setClientDims, clearClientDims } from './session/session.js'
import type { Config } from './types.js'
import { WS_OPEN } from './types.js'
@@ -117,6 +117,23 @@ export function startServer(cfg: Config): { close(): Promise } {
res.json(manager.list())
})
+ // Kill ALL sessions, or only detached ones (?detached=1) โ manage page bulk action.
+ app.delete('/live-sessions', (req, res) => {
+ const onlyDetached = req.query['detached'] === '1'
+ let killed = 0
+ for (const s of manager.list()) {
+ if (onlyDetached && s.clientCount > 0) continue
+ if (manager.killById(s.id)) killed += 1
+ }
+ res.json({ killed })
+ })
+
+ // Kill one session by id โ manage page per-row action.
+ app.delete('/live-sessions/:id', (req, res) => {
+ const ok = manager.killById(req.params.id)
+ res.status(ok ? 204 : 404).end()
+ })
+
// โโ Claude Code hook side-channel (H2) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Hooks running inside a spawned shell POST status here (loopback only โ the
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.
@@ -268,8 +285,11 @@ export function startServer(cfg: Config): { close(): Promise } {
if (msg.type === 'input') {
writeInput(session, msg.data)
} else if (msg.type === 'resize') {
- // Per-client dims; the PTY tracks the min across all sharing devices.
+ // Per-client dims; the PTY tracks the min across actively-viewing devices.
setClientDims(session, ws, msg.cols, msg.rows)
+ } else if (msg.type === 'blur') {
+ // Tab hidden on this device โ withdraw its size vote (still mirrors output).
+ clearClientDims(session, ws)
} else if (msg.type === 'approve') {
// H3: resolve the held PermissionRequest with allow.
resolvePending(boundSessionId, permDecision('allow'))
diff --git a/src/session/manager.ts b/src/session/manager.ts
index a630176..544cce7 100644
--- a/src/session/manager.ts
+++ b/src/session/manager.ts
@@ -92,7 +92,7 @@ export function createSessionManager(cfg: Config): SessionManager {
// M4: createSession may throw (spawn failure). Do NOT catch here.
// M6: cwd (if given) is the spawn directory for "new tab here".
const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd);
- attachWs(session, ws, dims);
+ attachWs(session, ws);
sessions = new Map(sessions).set(session.meta.id, session);
return session;
}
@@ -101,7 +101,7 @@ export function createSessionManager(cfg: Config): SessionManager {
// โโ Case 2: hit a live session โ JOIN it (multi-device sharing) โโโโโโโโโโโ
if (existing !== undefined && existing.exitedAt === null) {
- attachWs(existing, ws, dims);
+ attachWs(existing, ws);
return existing;
}
@@ -122,7 +122,7 @@ export function createSessionManager(cfg: Config): SessionManager {
// to the still-running shell instead of spawning a fresh one.
if (cfg.useTmux && hasSession(tmuxName(sessionId))) {
const revived = createSession(cfg, dims, now, onSessionExit, sessionId);
- attachWs(revived, ws, dims);
+ attachWs(revived, ws);
sessions = new Map(sessions).set(revived.meta.id, revived);
return revived;
}
@@ -130,7 +130,7 @@ export function createSessionManager(cfg: Config): SessionManager {
// โโ Case 4: session not found โ create a new one โโโโโโโโโโโโโโโโโโโโโโโโโ
// M4: createSession may throw. Do NOT catch here.
const session = createSession(cfg, dims, now, onSessionExit);
- attachWs(session, ws, dims);
+ attachWs(session, ws);
sessions = new Map(sessions).set(session.meta.id, session);
return session;
}
@@ -149,10 +149,29 @@ export function createSessionManager(cfg: Config): SessionManager {
status: s.claudeStatus,
exited: s.exitedAt !== null,
cwd: s.cwd,
+ cols: s.pty.cols,
+ rows: s.pty.rows,
}))
.sort((a, b) => b.createdAt - a.createdAt);
}
+ /**
+ * Kill a session by id (manage page): close every attached client, kill the
+ * PTY (and tmux session, H1), and drop it from the table. Returns whether a
+ * session was found.
+ */
+ function killById(id: string): boolean {
+ const session = sessions.get(id);
+ if (session === undefined) return false;
+ for (const ws of session.clients) {
+ if (ws.readyState === WS_OPEN) ws.close();
+ }
+ kill(session);
+ sessions = new Map(sessions);
+ sessions.delete(id);
+ return true;
+ }
+
/**
* H2: a Claude Code hook reported activity for `sessionId`. Record it and push
* a `status` frame to the attached ws (no-op if the session is gone/detached).
@@ -223,5 +242,5 @@ export function createSessionManager(cfg: Config): SessionManager {
sessions = new Map();
}
- return { handleAttach, get, list, handleHookEvent, reapIdle, shutdown };
+ return { handleAttach, get, list, killById, handleHookEvent, reapIdle, shutdown };
}
diff --git a/src/session/session.ts b/src/session/session.ts
index 48cb4c2..98e5b7c 100644
--- a/src/session/session.ts
+++ b/src/session/session.ts
@@ -150,16 +150,18 @@ export function createSession(
}
/**
- * Add `ws` as a client (multi-device sharing): register its dims, clear the
- * detached stamp, re-derive the shared PTY size, then replay the scrollback to
- * THIS client only so it sees the current screen. Other clients are untouched.
+ * Add `ws` as a client (multi-device sharing): clear the detached stamp, then
+ * replay the scrollback to THIS client only so it sees the current screen.
* No kicking โ devices share the session (invariant #5 relaxed for v0.4).
+ *
+ * The client does NOT get a size vote here: only a client that is actively
+ * viewing the session sends a `resize` (the frontend skips hidden panes), so a
+ * background mirror never clamps the shared PTY. The vote is added on the first
+ * `setClientDims` and removed on `clearClientDims`/`detachWs`.
*/
-export function attachWs(session: Session, ws: WebSocketLike, dims: Dims): void {
+export function attachWs(session: Session, ws: WebSocketLike): void {
session.clients.add(ws);
- session.clientDims.set(ws, dims);
session.detachedAt = null;
- applyMinDims(session);
// Replay the buffered scrollback so the joining client sees the last screen.
sendIfOpen(ws, { type: 'output', data: session.buffer.snapshot() });
@@ -201,6 +203,15 @@ export function setClientDims(
applyMinDims(session);
}
+/**
+ * Withdraw a client's size vote without detaching it (its tab was hidden). The
+ * client still receives output (it's a background mirror); it just stops
+ * constraining the shared PTY size. Re-votes on its next `setClientDims`.
+ */
+export function clearClientDims(session: Session, ws: WebSocketLike): void {
+ if (session.clientDims.delete(ws)) applyMinDims(session);
+}
+
/**
* Kill the session (idle reclaim). Under tmux this ends the actual shell via
* `tmux kill-session` (H1); killing only the client pty would leave the tmux
diff --git a/src/types.ts b/src/types.ts
index 32a7520..2046d88 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -45,6 +45,9 @@ export type ClientMessage =
| { type: 'attach'; sessionId: string | null; cwd?: string }
| { type: 'input'; data: string }
| { type: 'resize'; cols: number; rows: number }
+ // v0.4: this client stopped actively viewing (tab hidden) โ withdraw its size
+ // vote so a background mirror doesn't clamp the shared PTY (min-dims).
+ | { type: 'blur' }
| { type: 'approve' }
| { type: 'reject' };
@@ -167,10 +170,11 @@ export interface Session {
// impl anchors [src/session/session.ts]:
// createSession(cfg: Config, dims: Dims, now: number, onExit: (s: Session) => void): Session
// // spawn failure THROWS, not swallowed (M4)
-// attachWs(session: Session, ws: WebSocketLike, dims: Dims): void // adds a client, replays buffer (no kick)
+// attachWs(session: Session, ws: WebSocketLike): void // adds a client, replays buffer (no size vote until it resizes)
// detachWs(session: Session, ws: WebSocketLike, now: number): void // removes one client; never kills PTY
// writeInput(session: Session, data: string): void // no-op after exit (L4)
-// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = min over clients (L4 no-op after exit)
+// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = min over active viewers (L4 no-op after exit)
+// clearClientDims(session: Session, ws: WebSocketLike): void // withdraw this client's size vote (tab hidden / blur)
// kill(session: Session): void
/* โโโโโโโโโโโโโโโโโโโโโโโโ manager (ยง3.5) โโโโโโโโโโโโโโโโโโโโโโโ */
@@ -183,6 +187,8 @@ export interface LiveSessionInfo {
status: ClaudeStatus;
exited: boolean;
cwd: string | null;
+ cols: number; // current PTY size (for the manage page)
+ rows: number;
}
export interface SessionManager {
@@ -196,6 +202,9 @@ export interface SessionManager {
get(id: string): Session | undefined;
/** Live sessions for multi-device discovery (newest first). */
list(): LiveSessionInfo[];
+ /** Kill a session by id (manage page): close its clients, kill the PTY, drop it.
+ * Returns true if a session was found and killed. */
+ killById(id: string): boolean;
/** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */
handleHookEvent(
sessionId: string,
diff --git a/test/protocol.test.ts b/test/protocol.test.ts
index d9c5392..452e70f 100644
--- a/test/protocol.test.ts
+++ b/test/protocol.test.ts
@@ -116,6 +116,12 @@ describe('parseClientMessage โ valid messages', () => {
expect(r.ok).toBe(true)
if (r.ok) expect(r.message).toEqual({ type: 'reject' })
})
+
+ it('parses blur (v0.4, no payload โ withdraw size vote)', () => {
+ const b = parseClientMessage(JSON.stringify({ type: 'blur' }))
+ expect(b.ok).toBe(true)
+ if (b.ok) expect(b.message).toEqual({ type: 'blur' })
+ })
})
// โโโ parseClientMessage โ invalid / error paths โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
diff --git a/test/session.test.ts b/test/session.test.ts
index 435c321..d207744 100644
--- a/test/session.test.ts
+++ b/test/session.test.ts
@@ -33,9 +33,8 @@ vi.mock('node-pty', () => ({
}));
// Imported AFTER vi.mock so the module under test binds to the mocked spawn.
-const { createSession, attachWs, detachWs, writeInput, setClientDims, kill } = await import(
- '../src/session/session.js'
-);
+const { createSession, attachWs, detachWs, writeInput, setClientDims, clearClientDims, kill } =
+ await import('../src/session/session.js');
// โโ helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const CFG: Config = {
@@ -133,7 +132,7 @@ describe('onData', () => {
it('broadcasts output to the attached client as an `output` message', () => {
const s = newSession();
const ws = createMockWs();
- attachWs(s, ws, DIMS);
+ attachWs(s, ws);
ws.sent.length = 0; // drop the replay frame from attach
(s.pty as MockIPty).emitData('abc');
@@ -145,8 +144,8 @@ describe('onData', () => {
const s = newSession();
const a = createMockWs();
const b = createMockWs();
- attachWs(s, a, DIMS);
- attachWs(s, b, DIMS);
+ attachWs(s, a);
+ attachWs(s, b);
a.sent.length = 0;
b.sent.length = 0;
@@ -165,7 +164,7 @@ describe('onData', () => {
it('does NOT send to a client whose readyState is not OPEN (M5)', () => {
const s = newSession();
const ws = createMockWs(WS_OPEN);
- attachWs(s, ws, DIMS);
+ attachWs(s, ws);
ws.sent.length = 0;
ws.readyState = 3; // CLOSED
@@ -182,7 +181,7 @@ describe('attachWs', () => {
(s.pty as MockIPty).emitData('prior output');
const ws = createMockWs();
- attachWs(s, ws, DIMS);
+ attachWs(s, ws);
expect(s.clients.has(ws)).toBe(true);
expect(received(ws)).toEqual([{ type: 'output', data: '\x1b[0mprior output' }]);
@@ -191,10 +190,10 @@ describe('attachWs', () => {
it('a second attach JOINS (does not kick) โ both clients stay connected', () => {
const s = newSession();
const a = createMockWs();
- attachWs(s, a, DIMS);
+ attachWs(s, a);
const b = createMockWs();
- attachWs(s, b, DIMS);
+ attachWs(s, b);
// Both present; the first was NOT closed.
expect(s.clients.has(a)).toBe(true);
@@ -209,21 +208,54 @@ describe('attachWs', () => {
expect(received(b)).toEqual([{ type: 'output', data: 'live' }]);
});
- it('resizes the PTY to the MIN dims across all clients (tmux-style)', () => {
+ it('resizes the PTY to the MIN dims across active viewers (tmux-style)', () => {
const s = newSession(); // spawn dims 80x24
const wide = createMockWs();
const narrow = createMockWs();
- attachWs(s, wide, { cols: 200, rows: 50 });
- // one client at 200x50 โ PTY grows to 200x50
+ // Attach alone does NOT vote on size (a join could be a hidden mirror).
+ attachWs(s, wide);
+ expect(s.pty.cols).toBe(80);
+
+ // A client only constrains the PTY once it reports dims (it's viewing).
+ setClientDims(s, wide, 200, 50);
expect(s.pty.cols).toBe(200);
expect(s.pty.rows).toBe(50);
- attachWs(s, narrow, { cols: 90, rows: 30 });
+ attachWs(s, narrow);
+ setClientDims(s, narrow, 90, 30);
// min(200,90)=90, min(50,30)=30
expect(s.pty.cols).toBe(90);
expect(s.pty.rows).toBe(30);
});
+
+ it('a hidden mirror (no dims reported) does NOT clamp the shared PTY', () => {
+ const s = newSession();
+ const viewer = createMockWs();
+ const hidden = createMockWs();
+
+ attachWs(s, viewer);
+ setClientDims(s, viewer, 200, 50);
+ attachWs(s, hidden); // joins but never reports dims (background tab)
+
+ // The hidden mirror must not drag the PTY down to the 80x24 default.
+ expect(s.pty.cols).toBe(200);
+ expect(s.pty.rows).toBe(50);
+ });
+
+ it('clearClientDims withdraws a vote (tab hidden) and lets the PTY grow', () => {
+ const s = newSession();
+ const wide = createMockWs();
+ const narrow = createMockWs();
+ attachWs(s, wide);
+ setClientDims(s, wide, 200, 50);
+ attachWs(s, narrow);
+ setClientDims(s, narrow, 90, 30); // clamps to 90x30
+
+ clearClientDims(s, narrow); // narrow's tab hidden โ withdraw vote
+ expect(s.pty.cols).toBe(200);
+ expect(s.pty.rows).toBe(50);
+ });
});
// โโ detachWs (remove one client) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -232,8 +264,8 @@ describe('detachWs', () => {
const s = newSession();
const a = createMockWs();
const b = createMockWs();
- attachWs(s, a, DIMS);
- attachWs(s, b, DIMS);
+ attachWs(s, a);
+ attachWs(s, b);
detachWs(s, a, 5_000);
// one client remains โ still "attached", no detach stamp
@@ -252,8 +284,10 @@ describe('detachWs', () => {
const s = newSession();
const wide = createMockWs();
const narrow = createMockWs();
- attachWs(s, wide, { cols: 200, rows: 50 });
- attachWs(s, narrow, { cols: 90, rows: 30 }); // clamps to 90x30
+ attachWs(s, wide);
+ setClientDims(s, wide, 200, 50);
+ attachWs(s, narrow);
+ setClientDims(s, narrow, 90, 30); // clamps to 90x30
detachWs(s, narrow, 5_000);
// only the wide client remains โ PTY expands back to 200x50
@@ -264,7 +298,7 @@ describe('detachWs', () => {
it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => {
const s = newSession();
const ws = createMockWs();
- attachWs(s, ws, DIMS);
+ attachWs(s, ws);
detachWs(s, ws, 5_000);
(s.pty as MockIPty).emitData('background work');
@@ -283,8 +317,8 @@ describe('onExit', () => {
const a = createMockWs();
const b = createMockWs();
- attachWs(s, a, DIMS);
- attachWs(s, b, DIMS);
+ attachWs(s, a);
+ attachWs(s, b);
a.sent.length = 0;
b.sent.length = 0;
@@ -303,7 +337,7 @@ describe('onExit', () => {
const s = createSession(CFG, DIMS, 1_000, onExit);
const ws = createMockWs();
- attachWs(s, ws, DIMS);
+ attachWs(s, ws);
detachWs(s, ws, 5_000);
ws.sent.length = 0;
@@ -319,7 +353,7 @@ describe('onExit', () => {
nextPty = createMockPty();
const s = createSession(CFG, DIMS, 1_000, () => {});
const ws = createMockWs(WS_OPEN);
- attachWs(s, ws, DIMS);
+ attachWs(s, ws);
ws.sent.length = 0;
ws.readyState = 3; // CLOSED
@@ -340,7 +374,7 @@ describe('writeInput / setClientDims', () => {
it('setClientDims forwards to pty.resize while alive', () => {
const s = newSession();
const ws = createMockWs();
- attachWs(s, ws, DIMS);
+ attachWs(s, ws);
setClientDims(s, ws, 100, 40);
expect((s.pty as MockIPty).resizes).toContainEqual({ cols: 100, rows: 40 });
});
@@ -348,7 +382,7 @@ describe('writeInput / setClientDims', () => {
it('setClientDims is idempotent: same min cols/rows are skipped', () => {
const s = newSession();
const ws = createMockWs();
- attachWs(s, ws, DIMS); // 80x24, equals spawn dims โ no resize yet
+ attachWs(s, ws); // 80x24, equals spawn dims โ no resize yet
expect((s.pty as MockIPty).resizes).toHaveLength(0);
setClientDims(s, ws, 90, 24); // changed โ applied
@@ -367,7 +401,7 @@ describe('writeInput / setClientDims', () => {
it('ignores setClientDims once the PTY has exited (L4)', () => {
const s = newSession();
const ws = createMockWs();
- attachWs(s, ws, DIMS);
+ attachWs(s, ws);
(s.pty as MockIPty).emitExit(0);
setClientDims(s, ws, 200, 50);