From 0b3e086334c0a5a27e7e8d54645969fc83a68c08 Mon Sep 17 00:00:00 2001 From: timkjr Date: Sat, 19 Sep 2026 20:18:36 -0500 Subject: [PATCH] =?UTF-8?q?fix(split-pane):=20address=20Ark0N's=20fourth?= =?UTF-8?q?=20pass=20=E2=80=94=20PTY-less=20picker=20exclusion,=20hollow?= =?UTF-8?q?=20chord=20test,=20remaining=20key=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildSplitPickerSessions() now excludes any session with pid === null (exited CLI, tripped PTY-exit breaker, a restore that never re-attached). Pane B has no equivalent of selectSession()'s auto re-attach POST, so a split opened onto one had nothing reading its tmux pane: no terminal events ever arrived and Session.write() silently dropped every keystroke with no ack either way, while the socket itself reported healthy. - Fixed the hollow chord regression test: the synthetic keydowns carried no keyCode, which is what xterm's evaluateKeyboardEvent switches on to produce a data frame at all, so the assertion held regardless of whether the gate fired. Adding real keyCodes surfaced a second, real bug in the Alt+B case: the event bubbles to app.js's own document-level shortcut dispatcher, which really toggles the sidebar and resets the layout attribute the gate reads before Pane B's own (later, non-capture) handler ever sees it — fixed by driving the app's real settings cache instead of only the DOM attribute. - Ported the two remaining primary-pane gates with real consequences: Ctrl+Z (SIGTSTP) is swallowed for every non-shell session, matching terminal-ui.js's reasoning (an Ink/TUI agent loop stops dead with no visible output otherwise), and Shift/Ctrl+Enter now POSTs to /api/sessions/:id/send-key for THIS pane's own session instead of letting xterm send a bare \r, which used to submit an incomplete prompt instead of inserting a newline. Smart-copy Ctrl+C is re-implemented against Pane B's own terminal (copying app.copyTerminalSelection() would have copied Pane A's selection instead). - Updated docs/architecture-invariants.md and docs/split-pane-sessions-plan.md to match, and added CLAUDE.md's missing .split-picker-menu z-index entry. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- docs/split-pane-sessions-plan.md | 2 +- src/web/public/constants.js | 8 +++ src/web/public/terminal-split.js | 80 ++++++++++++++++++++++-- test/split-pane-helpers.test.ts | 21 +++++++ test/split-pane-terminal.browser.test.ts | 79 +++++++++++++++++++++-- 7 files changed, 180 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa8f3f8f..be44e668 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -356,7 +356,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s". -**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7). +**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7). **Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a4b2e076..c152b6ef 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -404,7 +404,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se ### Split-pane sessions -**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden on phones in mobile.css regardless of the setting, and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same three app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+V stays on xterm's own default paste, since Pane B has no image-paste trap to route it to. Pane B's input frames deliberately carry no `cid`/`seq` (`ws-routes.ts` supports that), matching the no-overlay/no-IME "deliberately plainer" list above, since it has no exactly-once delivery layer to key them against. Design: `docs/split-pane-sessions-plan.md`. +**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden on phones in mobile.css regardless of the setting, and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, tmux `send-keys -H` for a real 0x0a) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection. Ctrl+V stays on xterm's own default paste, since Pane B has no image-paste trap to route it to; Ctrl+Shift+C (the explicit copy chord) is left un-ported as lower value. ⚠️ `buildSplitPickerSessions()` also excludes any session with `pid === null` (an exited CLI, a crash-looped session whose breaker tripped, a restore that never re-attached): Pane B has no equivalent of `selectSession()`'s auto re-attach POST, so a pane opened onto one has nothing reading its tmux pane — no `terminal` events ever arrive, and `Session.write()` silently drops every keystroke with no ack either way, so the loss is invisible behind a socket that reports healthy. Pane B's input frames deliberately carry no `cid`/`seq` (`ws-routes.ts` supports that), matching the no-overlay/no-IME "deliberately plainer" list above, since it has no exactly-once delivery layer to key them against. Design: `docs/split-pane-sessions-plan.md`. ### Gesture control: the setting diff --git a/docs/split-pane-sessions-plan.md b/docs/split-pane-sessions-plan.md index b6e77f08..ffd6f76f 100644 --- a/docs/split-pane-sessions-plan.md +++ b/docs/split-pane-sessions-plan.md @@ -1,6 +1,6 @@ # Split-Pane Sessions — Design Spec -**Status**: Draft, pending review +**Status**: Implemented (v1) **Author**: Claude (session with Tim), 2026-09-15 **Scope**: v1 only. v2 items are named and explicitly deferred, not designed. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index ba2deec1..17b4b6a7 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1547,6 +1547,14 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds if (detachedIds?.has?.(id)) continue; const session = sessions.get(id); if (!session) continue; + // A session with no PTY attached (exited CLI, a crash-looped session + // whose breaker tripped, a restore that failed to re-attach) has nothing + // reading its tmux pane. SplitTerminalPane never does selectSession()'s + // re-attach POST, so its socket would open onto a pane nothing feeds: + // no terminal events, and Session.write() silently drops every keystroke + // with no ack either way (Pane B sends no `seq`), so the loss is + // invisible — the healthy socket never trips the disconnect banner. + if (session.pid === null) continue; result.push({ id, label: session.name || 'Session' }); } return result; diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js index 4cfa6b61..cf5e24fc 100644 --- a/src/web/public/terminal-split.js +++ b/src/web/public/terminal-split.js @@ -93,11 +93,16 @@ // — never stopPropagation()s — reaches xterm here too and writes its raw // byte/escape sequence into THIS session's PTY on top of whatever the app // action already did to Pane A (COD-153; mirrors the primary pane's own - // gates at terminal-ui.js's attachCustomKeyEventHandler). Routed through - // the same registry-aware predicates so a rebind or a disable restores - // plain terminal behavior here too. Ctrl+V is deliberately left on - // xterm's own default (plain-text paste): Pane B has no image-paste trap - // to route it to, so intercepting it here would only break paste. + // gates at terminal-ui.js's attachCustomKeyEventHandler: command palette, + // Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter + // newline, and smart-copy Ctrl+C). Routed through the same registry-aware + // predicates so a rebind or a disable restores plain terminal behavior + // here too. Ctrl+V is deliberately left on xterm's own default + // (plain-text paste): Pane B has no image-paste trap to route it to, so + // intercepting it here would only break paste. Ctrl+Shift+C (the + // explicit, never-falls-through copy chord) is also left un-ported — + // lower value than the plain Ctrl+C case above, since Pane B is rarely + // the pane a user is actively selecting text in. this.terminal.attachCustomKeyEventHandler((ev) => { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true; if ( @@ -114,6 +119,71 @@ if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) { return false; } + // Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own + // swallow — in a plain shell session this is the user's own + // job-control tool and must reach the PTY, but in every other mode + // (claude/omp/pi/codex/...) it silently stops an unattended agent + // loop dead. Pane B has its own PTY/session and must not send a + // suspend into a non-shell one just because the primary pane's own + // gate lives elsewhere. + if ( + ev.type === 'keydown' && + ev.key.toLowerCase() === 'z' && + ev.ctrlKey && + !ev.altKey && + !ev.metaKey && + !ev.shiftKey && + this.sessionMode !== 'shell' + ) { + return false; + } + // Shift+Enter / Ctrl+Enter: insert a newline instead of submitting. + // Mirrors terminal-ui.js's own handling — xterm sends plain \r for + // every Enter variant, so an Ink app (Claude Code) can't tell a + // newline from a submit. Without this gate, Pane B's onData would + // send that bare \r straight over the WS and submit an incomplete + // prompt instead of adding a line to it. Targets THIS pane's own + // session (this.sessionId), never the primary pane's + // activeSessionId, and has no local-echo overlay of its own to flush + // first (Pane B is deliberately plainer — see the fileoverview). + if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') { + fetch(`/api/sessions/${this.sessionId}/send-key`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }), + }).catch(() => { + /* Best-effort, matching this pane's tolerance elsewhere. */ + }); + return false; + } + // Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a + // selection, Ctrl+C copies THIS pane's own selection instead of + // sending ^C; with none, it must fall through unchanged or the + // interrupt key is lost. Re-implemented against this.terminal rather + // than reusing app.copyTerminalSelection(), which reads app.terminal + // — Pane A's — and would copy the wrong pane's selection. + if ( + ev.type === 'keydown' && + global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev) && + this.terminal?.hasSelection?.() + ) { + const raw = this.terminal.getSelection(); + const isColumnSelection = this.terminal._core?._selectionService?._activeSelectionMode === 3; + const selection = isColumnSelection ? raw : (global.CodemanCopySelection?.clean?.(raw) ?? raw); + if (selection.trim()) { + ev.preventDefault(); + void global.app._copyText?.(selection).then((ok) => { + this.terminal?.clearSelection?.(); + global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error'); + }); + return false; + } + // Nothing worth copying — clear for feedback (a padding-only + // selection cleans to '' and this press still falls through to the + // PTY as 0x03, matching the primary pane's own rule). + this.terminal.clearSelection?.(); + global.app.showToast?.('Nothing to copy', 'warning'); + } return true; }); diff --git a/test/split-pane-helpers.test.ts b/test/split-pane-helpers.test.ts index 22c27150..da6b8e6b 100644 --- a/test/split-pane-helpers.test.ts +++ b/test/split-pane-helpers.test.ts @@ -64,4 +64,25 @@ describe('CodemanSplitPane.buildSplitPickerSessions', () => { const result = buildSplitPickerSessions(sessions, ['a'], 'a'); expect(result).toEqual([]); }); + + it('excludes a session with no PTY attached (pid === null)', () => { + const { buildSplitPickerSessions } = loadSplitPaneHelper(); + const sessions = new Map([ + ['a', { name: 'w1-codeman' }], + ['b', { name: 'w2-exited', pid: null }], + ['c', { name: 'w3-alive', pid: 12345 }], + ]); + const result = buildSplitPickerSessions(sessions, ['a', 'b', 'c'], 'a'); + expect(result).toEqual([{ id: 'c', label: 'w3-alive' }]); + }); + + it('excludes a detached session even when it also has no PTY', () => { + const { buildSplitPickerSessions } = loadSplitPaneHelper(); + const sessions = new Map([ + ['a', { name: 'w1-codeman' }], + ['b', { name: 'w2-detached', pid: null }], + ]); + const result = buildSplitPickerSessions(sessions, ['a', 'b'], 'a', new Set(['b'])); + expect(result).toEqual([]); + }); }); diff --git a/test/split-pane-terminal.browser.test.ts b/test/split-pane-terminal.browser.test.ts index 4c168943..4fe7c07a 100644 --- a/test/split-pane-terminal.browser.test.ts +++ b/test/split-pane-terminal.browser.test.ts @@ -185,7 +185,7 @@ describe('SplitTerminalPane in a real browser', () => { return id; }); - const sentFrames = await page.evaluate(async (id) => { + const result = await page.evaluate(async (id) => { const mount = document.createElement('div'); mount.style.width = '400px'; mount.style.height = '300px'; @@ -215,16 +215,83 @@ describe('SplitTerminalPane in a real browser', () => { const fire = (init: KeyboardEventInit) => { textarea.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })); }; - fire({ key: 'k', code: 'KeyK', ctrlKey: true }); // command palette - fire({ key: '1', code: 'Digit1', altKey: true }); // Alt+1 tab switch - fire({ key: 'b', code: 'KeyB', altKey: true }); // Alt+B sidebar toggle + // keyCode is what xterm's evaluateKeyboardEvent switches on to decide + // whether to produce a data frame at all — at keyCode 0 (unset) it can + // never emit bytes, so the assertion below held regardless of whether + // the custom key handler's gate actually fired. Real values (K=75, + // 1=49, B=66) are what a real keypress carries. + const app = window.app as any; + fire({ key: 'k', code: 'KeyK', keyCode: 75, ctrlKey: true }); // command palette + fire({ key: '1', code: 'Digit1', keyCode: 49, altKey: true }); // Alt+1 tab switch + + // Ctrl+Z (SIGTSTP): this pane's own sessionMode is undefined (no `mode` + // opt passed to the constructor above), so `this.sessionMode !== 'shell'` + // holds and the gate must block it, mirroring a non-shell (agent) mode. + fire({ key: 'z', code: 'KeyZ', keyCode: 90, ctrlKey: true }); + + // Shift+Enter: must never reach the PTY as a bare \r (that would submit + // an incomplete prompt instead of inserting a newline) — it goes out as + // a POST to /api/sessions/:id/send-key instead. + const sendKeyCalls: unknown[] = []; + const realFetch = window.fetch.bind(window); + window.fetch = ((...args: Parameters) => { + const url = String(args[0]); + if (url.includes('/send-key')) { + sendKeyCalls.push(args[1] ? JSON.parse((args[1] as RequestInit).body as string) : null); + } + return realFetch(...args); + }) as typeof fetch; + fire({ key: 'Enter', code: 'Enter', keyCode: 13, shiftKey: true }); + window.fetch = realFetch; + + // Smart-copy Ctrl+C: with a real selection in THIS pane's own terminal, + // Ctrl+C must copy it (never send 0x03) and must copy Pane B's + // selection, not Pane A's. app._copyText is stubbed rather than relying + // on a real clipboard, which headless Chromium may refuse permission + // for. + pane.terminal.write('SPLITPANE_COPY_MARKER'); + await new Promise((r) => setTimeout(r, 100)); + pane.terminal.selectAll(); + let copiedText: string | null = null; + const realCopyText = app._copyText; + app._copyText = async (text: string) => { + copiedText = text; + return true; + }; + fire({ key: 'c', code: 'KeyC', keyCode: 67, ctrlKey: true }); + await new Promise((r) => setTimeout(r, 50)); + app._copyText = realCopyText; + + // Alt+B only reaches shouldToggleSessionSidebarFromShortcut's gate when + // the sidebar layout is actually active (app.js:4325) — under the + // default header-strip layout the app doesn't treat Alt+B as its own + // shortcut either, so Pane A forwards the same `ESC b` to its own PTY. + // Assert the gate where it is meant to hold: sidebar layout active. + // + // Setting only the `data-session-list` attribute is not enough: this + // event bubbles (matching how a real keypress reaches xterm), so it + // also reaches app.js's OWN document-level capture-phase shortcut + // dispatcher, which matches the same Alt+B binding and calls the real + // toggleSessionSidebar() — that reads the persisted settings (still + // 'header'), re-runs applySessionListLayout(), and resets the + // attribute back to 'header' before xterm's own (later, non-capture) + // key handler ever sees it. Persisting the setting through the app's + // own settings cache keeps the attribute stable across that bubble. + const prevSettings = { ...app.loadAppSettingsFromStorage() }; + app._cachedAppSettings = { ...prevSettings, sessionListLayout: 'sidebar' }; + app.applySessionListLayout(); + fire({ key: 'b', code: 'KeyB', keyCode: 66, altKey: true }); // Alt+B sidebar toggle + app._cachedAppSettings = prevSettings; + app.applySessionListLayout(); pane.destroy(); document.body.removeChild(mount); - return sent; + return { sent, sendKeyCalls, copiedText }; }, sessionId); - expect(sentFrames.every((f) => JSON.parse(f).t !== 'i')).toBe(true); + expect(result.sent.every((f) => JSON.parse(f).t !== 'i')).toBe(true); + expect(result.sendKeyCalls).toEqual([{ key: 'S-Enter' }]); + expect(result.copiedText).toContain('SPLITPANE_COPY_MARKER'); await page.evaluate(async (id) => { await fetch(`/api/sessions/${id}`, { method: 'DELETE' });