fix(v0.4): mirror size-clamp bug + session manager page

The mirror looked broken because a backgrounded tab on one device still pinned
the shared PTY to its (default 80x24) size — so the device actively viewing got
a cramped terminal.

Fix: only an ACTIVELY-VIEWING client votes on PTY size.
- attachWs no longer seeds a default size vote (a join may be a hidden mirror)
- new 'blur' client message + clearClientDims(): a tab going hidden withdraws its
  size vote (still mirrors output); show() re-casts it
- PTY size = min cols/rows across clients that have actually reported dims
- session/protocol tests cover hidden-mirror-doesn't-clamp + blur

Session manager (separate page, for the 'too many sessions' problem):
- GET stays; add DELETE /live-sessions/:id and DELETE /live-sessions[?detached=1]
- manager.killById(); LiveSessionInfo gains cols/rows
- public/manage.html + manage.ts: list/open/kill sessions, kill-all / kill-detached,
  auto-refresh; 🗂 toolbar button; bundled as a 2nd esbuild entry

Verified: two concurrent clients mirror output + shared input; manage page
lists/kills (3→2→0). 225 tests green, tsc clean.
This commit is contained in:
Yaojia Wang
2026-06-19 11:04:38 +02:00
parent 22210fadbc
commit 021a514b2d
13 changed files with 467 additions and 44 deletions

View File

@@ -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",

View File

@@ -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).

14
public/manage.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Session Manager — Web Terminal</title>
<meta name="theme-color" content="#0e0f13">
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="manage-root"></div>
<script type="module" src="./build/manage.js"></script>
</body>
</html>

144
public/manage.ts Normal file
View File

@@ -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=<id>), 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<LiveSession[]> {
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<K extends keyof HTMLElementTagNameMap>(
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<void> {
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<void> {
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<void> {
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)

View File

@@ -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;
}

View File

@@ -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. */

View File

@@ -24,7 +24,7 @@ export const SESSION_ID_RE =
// ─── Type whitelist ───────────────────────────────────────────────────────────
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize', 'approve', 'reject'])
const ALLOWED_TYPES = new Set<string>(['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' } }

View File

@@ -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<void> } {
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<void> } {
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'))

View File

@@ -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 };
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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 ───────────────────────────────

View File

@@ -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);