From 155372f7a8efed65abb58a5d173cfd89a6b83812 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 07:18:47 +0200 Subject: [PATCH 1/6] refactor(terminal): let the wheel paging and click-report gates answer for another pane The primary pane's hollow-buffer paging (#555) and its desktop click report read this.terminal and this.activeSessionId throughout, so a second pane (a grid tile, the split's Pane B) could only get them by copying the gates and their CLI rules. They now take an optional trailing target instead, the pattern registerFilePathLinkProvider, copyTerminalSelection and _handleImagePaste already use for tiles: - _shouldForwardWheelToApp(ev, { terminal, sessionId }) - _localScrollbackIsHollow({ terminal, sessionId, localRows }), where localRows stands in for baseY so a tile can discount rows it pushed above the screen itself - _handleDesktopTerminalClick(ev, { terminal, sessionId, linkHovered }), _sendSyntheticSgrTap(x, y, target), _shouldReportMouseToCli(sessionId), _terminalViewportAtBottom(terminal) and _clientPointToCell(x, y, terminal) Every field left out means the primary pane's, and every existing caller passes none, so the primary pane behaves exactly as before and its grep-pinned call sites are unchanged. The mode list for hollow buffers and the claude >= 2.1.187 forwarding gate stay in terminal-ui.js alone. The stateless math moves into two pure exports on CodemanTerminalInput, wheelDeltaLines and pageKeysForTravel, which _wheelScrollLinesFloat and _maybePageCliTranscript now delegate to. Comments on both sides name the tile's twins (the page-key pager and the 40 ms coalescer). Tests: the exports agree with the primary pane's methods and bytes, the gates read the target's session, buffer, rows and tracking mode rather than the active ones, and a targeted click uses the target's geometry, selection, scroll position and link hover. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/terminal-ui.js | 149 ++++++++++++++++++--------- test/terminal-scroll-routing.test.ts | 84 ++++++++++++++- test/terminal-touch-tap.test.ts | 81 +++++++++++++++ 3 files changed, 263 insertions(+), 51 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index f8f2a51b..403bcc44 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -92,6 +92,36 @@ // Bound on page keys emitted from one gesture batch, mirroring the SGR tick // cap: a fling must not build a backlog that keeps paging after it stops. const PAGE_KEY_MAX_PER_BATCH = 3; + + // Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The + // body of the primary pane's _wheelScrollLinesFloat (see its comment for the + // Shift-axis trap and the deltaMode units), pure so a TerminalTile pages with + // the same math against its own row count. + function wheelDeltaLines(ev, rows) { + const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; + if (!delta) return 0; + return ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel) + ? delta + : ev.deltaMode === 2 // DOM_DELTA_PAGE + ? delta * (rows || 24) + : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) + } + + // Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds + // `lines` to the sub-page travel already `pending`, and returns the travel + // left over plus the keys to send ('' below one page). The arithmetic of the + // primary pane's _maybePageCliTranscript, pure so a TerminalTile (which keeps + // its own pending travel) pages identically. + function pageKeysForTravel(pending, lines, rows) { + const perPage = Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION)); + const total = (pending || 0) + lines; + const pages = Math.trunc(total / perPage); + const keys = pages + ? (pages < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN).repeat(Math.min(Math.abs(pages), PAGE_KEY_MAX_PER_BATCH)) + : ''; + return { pending: total - pages * perPage, keys }; + } + const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4; // Composer navigation keys as xterm.js encodes user keystrokes: plain and // modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3 @@ -229,6 +259,8 @@ KEY_PAGE_DOWN, PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, + wheelDeltaLines, + pageKeysForTravel, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_TAP_SLOP, @@ -5395,42 +5427,51 @@ Object.assign(CodemanApp.prototype, { * Fails toward silence: an unknown or stale flag reports nothing rather than * injecting bytes. After a server restart the flag is false until the CLI * re-emits its DECSET, which closing and reopening a dialog does. + * + * `sessionId` defaults to the primary pane's session; a TerminalTile passes + * its own (through _handleDesktopTerminalClick's target), never the active one. */ - _shouldReportMouseToCli() { - return this.sessions?.get(this.activeSessionId)?.cliMouseTracking === true; + _shouldReportMouseToCli(sessionId = this.activeSessionId) { + return this.sessions?.get(sessionId)?.cliMouseTracking === true; }, // True when xterm's viewport shows the live PTY screen (not scrolled up into // local scrollback). SGR coordinates are only meaningful then: the TUI's // screen is the bottom `rows` of the buffer, so a report computed from a // scrolled-up viewport would hit-test a completely different row. - _terminalViewportAtBottom() { - const buf = this.terminal?.buffer?.active; + // `terminal` defaults to the primary pane's (a TerminalTile passes its own). + _terminalViewportAtBottom(terminal = this.terminal) { + const buf = terminal?.buffer?.active; return !buf || buf.viewportY >= buf.baseY; }, // Map a viewport point to a 1-based terminal cell the same way xterm maps a // click: offset inside .xterm-screen divided by the rendered cell size, // clamped to the grid. Returns null when the terminal isn't measurable yet. - _clientPointToCell(clientX, clientY) { - if (!this.terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null; - const screen = this.terminal.element?.querySelector('.xterm-screen'); - const cell = this.terminal._core?._renderService?.dimensions?.css?.cell; + // `terminal` defaults to the primary pane's (a TerminalTile passes its own). + _clientPointToCell(clientX, clientY, terminal = this.terminal) { + if (!terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null; + const screen = terminal.element?.querySelector('.xterm-screen'); + const cell = terminal._core?._renderService?.dimensions?.css?.cell; if (!screen || !cell?.width || !cell?.height) return null; const rect = screen.getBoundingClientRect(); - const col = Math.max(1, Math.min(this.terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1)); - const row = Math.max(1, Math.min(this.terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1)); + const col = Math.max(1, Math.min(terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1)); + const row = Math.max(1, Math.min(terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1)); return { col, row }; }, // Encode a tap as an SGR mouse report (press + release at button 0) and send it - // to the PTY directly, bypassing xterm's mouse encoder. - _sendSyntheticSgrTap(clientX, clientY) { - if (!this.activeSessionId) return; - if (!this._terminalViewportAtBottom()) return; // scrollback click → misfire, do nothing - const pos = this._clientPointToCell(clientX, clientY); + // to the PTY directly, bypassing xterm's mouse encoder. `target` ({ terminal, + // sessionId }) aims it at a TerminalTile instead of the primary pane; either + // field left out means the primary pane's. + _sendSyntheticSgrTap(clientX, clientY, target = {}) { + const sessionId = target.sessionId || this.activeSessionId; + const terminal = target.terminal || this.terminal; + if (!sessionId) return; + if (!this._terminalViewportAtBottom(terminal)) return; // scrollback click → misfire, do nothing + const pos = this._clientPointToCell(clientX, clientY, terminal); if (!pos) return; - this._sendInputAsync(this.activeSessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`); + this._sendInputAsync(sessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`); }, // True when a parsed CLI version string ('2.1.187' — banner-parsed on the @@ -5486,18 +5527,17 @@ Object.assign(CodemanApp.prototype, { /** Unrounded variant for the smooth local-scroll path, which accumulates * sub-line fractions across events instead of forcing every tiny trackpad - * delta to a whole ±1 line. Same unit handling and Shift-axis trap. */ + * delta to a whole ±1 line. Same unit handling and Shift-axis trap. The + * math is the pure CodemanTerminalInput.wheelDeltaLines (top of this file), + * which a TerminalTile calls with its own row count. */ _wheelScrollLinesFloat(ev) { - const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; - if (!delta) return 0; - return ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel) - ? delta - : ev.deltaMode === 2 // DOM_DELTA_PAGE - ? delta * (this.terminal?.rows || 24) - : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) + return window.CodemanTerminalInput.wheelDeltaLines(ev, this.terminal?.rows); }, - _shouldForwardWheelToApp(ev) { + // `target` ({ terminal, sessionId }) asks the question for a TerminalTile: + // its own terminal's tracking mode and its own session, never the active one. + // Either field left out means the primary pane's. + _shouldForwardWheelToApp(ev, target = {}) { if (ev.shiftKey) return false; // Opt-out (App Settings → Input → "Wheel scrolls local history"): pin the // plain wheel to xterm's own scrollback like pre-#144, for users who prefer @@ -5514,9 +5554,9 @@ Object.assign(CodemanApp.prototype, { // falls through to _maybePageCliTranscript, so the gesture still pages the // CLI's transcript and the setting keeps meaning exactly what it says. if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false; - const mode = this.terminal?.modes?.mouseTrackingMode; + const mode = (target.terminal || this.terminal)?.modes?.mouseTrackingMode; if (mode && mode !== 'none') return false; - const session = this.sessions?.get(this.activeSessionId); + const session = this.sessions?.get(target.sessionId || this.activeSessionId); const sessionMode = session?.mode || 'claude'; if (sessionMode !== 'claude') return false; if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; @@ -5571,6 +5611,10 @@ Object.assign(CodemanApp.prototype, { * tmux send-keys server-side, so per-event writes would spawn a process storm * on a single flick; the queue is bounded so a wild scroll can't build a * backlog that keeps scrolling after the finger stops. + * + * A TerminalTile keeps its own narrow twin (TerminalTile._queueScrollBytes, + * terminal-tile.js: same 40ms window, same 512-byte bound) because this queue + * flushes to the active session only; keep the two in step. */ _queueScrollBytes(data) { if (!data || !this.activeSessionId) return; @@ -5601,13 +5645,20 @@ Object.assign(CodemanApp.prototype, { * Every other mode is deliberately absent: shell/pi own real terminal * scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour * is unverified (docs/scrollback-fix-plan.md). + * + * `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which + * calls this with its own session and terminal, so the mode list above stays + * here alone. `localRows` replaces `baseY` as the history row count: a tile + * discounts the stale rows its own load order leaves above the screen + * (TerminalTile._localRows). Every field left out means the primary pane's. */ - _localScrollbackIsHollow() { - const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; + _localScrollbackIsHollow(target = {}) { + const mode = this.sessions?.get(target.sessionId || this.activeSessionId)?.mode || 'claude'; if (mode !== 'claude' && mode !== 'opencode') return false; - const buf = this.terminal?.buffer?.active; + const buf = (target.terminal || this.terminal)?.buffer?.active; if (!buf || buf.type === 'alternate') return false; - return (buf.baseY || 0) === 0; + const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY; + return (rows || 0) === 0; }, /** @@ -5631,6 +5682,10 @@ Object.assign(CodemanApp.prototype, { * * @returns true when the gesture was consumed here (the caller must not also * scroll locally). + * + * Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile + * through the same gates and the same pageKeysForTravel arithmetic; keep the + * two in step. */ _maybePageCliTranscript(ev, lines) { if (!lines || ev?.shiftKey || !this.activeSessionId) return false; @@ -5640,15 +5695,9 @@ Object.assign(CodemanApp.prototype, { this._pageKeySession = this.activeSessionId; this._pageKeyPending = 0; } - const tuning = window.CodemanTerminalInput; - const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION)); - const pending = (this._pageKeyPending || 0) + lines; - const pages = Math.trunc(pending / perPage); - this._pageKeyPending = pending - pages * perPage; - if (pages) { - const key = pages < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN; - this._queueScrollBytes(key.repeat(Math.min(Math.abs(pages), tuning.PAGE_KEY_MAX_PER_BATCH))); - } + const step = window.CodemanTerminalInput.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows); + this._pageKeyPending = step.pending; + if (step.keys) this._queueScrollBytes(step.keys); this._logScrollRouting('page-keys'); return true; }, @@ -5703,18 +5752,24 @@ Object.assign(CodemanApp.prototype, { // synthetic SGR press could e.g. dismiss a claude permission dialog), // clicks outside the cell grid, and sessions where xterm's own encoder is // live (it reported the click itself — a second report would double-move). - _handleDesktopTerminalClick(ev) { - if (!this.terminal || !ev?.isTrusted) return; + // + // `target` ({ terminal, sessionId, linkHovered }) runs the same skips for a + // TerminalTile's click: its own terminal, its own session's tracking flag and + // its own link hover (the primary pane's _linkHovered belongs to its terminal + // alone). Every field left out means the primary pane's. + _handleDesktopTerminalClick(ev, target = {}) { + const terminal = target.terminal || this.terminal; + if (!terminal || !ev?.isTrusted) return; if (ev.button !== 0 || ev.detail !== 1) return; if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return; - const mode = this.terminal.modes?.mouseTrackingMode; + const mode = terminal.modes?.mouseTrackingMode; if (mode && mode !== 'none') return; - if (!this._shouldReportMouseToCli()) return; - if (this.terminal.hasSelection?.()) return; - if (this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider) + if (!this._shouldReportMouseToCli(target.sessionId)) return; + if (terminal.hasSelection?.()) return; + if (target.linkHovered ?? this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider) if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return; if (!ev.target?.closest?.('.xterm-screen')) return; - this._sendSyntheticSgrTap(ev.clientX, ev.clientY); + this._sendSyntheticSgrTap(ev.clientX, ev.clientY, target); }, /** diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 060b6668..8550b7b4 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -23,8 +23,10 @@ import { describe, expect, it, vi } from 'vitest'; function loadTerminalUiHarness() { const CodemanApp = function CodemanApp(this: any) {}; const logs: string[] = []; + // terminal-ui.js hangs CodemanTerminalInput off window; tests read it there. + const windowRef: Record = {}; const context = vm.createContext({ - window: {}, + window: windowRef, CodemanApp, console: { warn: vi.fn(), log: (msg: string) => logs.push(msg) }, _crashDiag: { log: vi.fn() }, @@ -43,12 +45,12 @@ function loadTerminalUiHarness() { const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); vm.runInContext(code, context, { filename: 'terminal-ui.js' }); - return { app: new (CodemanApp as any)(), logs }; + return { app: new (CodemanApp as any)(), logs, windowRef }; } /** A session whose local buffer holds exactly one screen (baseY 0) — a hollow pane. */ function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) { - const { app, logs } = loadTerminalUiHarness(); + const { app, logs, windowRef } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; app.sessions = new Map([ @@ -68,7 +70,7 @@ function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: numbe modes: { mouseTrackingMode: 'none' }, buffer: { active: { type: 'normal', viewportY: 0, baseY: 0, length: 36 } }, }; - return { app, sent, logs }; + return { app, sent, logs, windowRef }; } describe('full-history re-pull downgrade guard (issue #205 round 2)', () => { @@ -255,6 +257,80 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); }); +describe('the paging gates asked for another pane (a TerminalTile)', () => { + it('exports the paging math, and the primary pane runs on it', () => { + const { app, sent, windowRef } = hollowApp(); + const { wheelDeltaLines, pageKeysForTravel } = windowRef.CodemanTerminalInput; + + expect(wheelDeltaLines({ deltaY: -50, deltaMode: 0 }, 36)).toBe(-2); // pixels, 25 a line + expect(wheelDeltaLines({ deltaY: 3, deltaMode: 1 }, 36)).toBe(3); // lines (Firefox) + expect(wheelDeltaLines({ deltaY: 1, deltaMode: 2 }, 36)).toBe(36); // pages: the given rows + expect(wheelDeltaLines({ deltaY: 0, deltaX: -75, shiftKey: true, deltaMode: 0 }, 36)).toBe(-3); // Shift axis + expect(pageKeysForTravel(0, -10, 36)).toEqual({ pending: -10, keys: '' }); + expect(pageKeysForTravel(-10, -8, 36)).toEqual({ pending: 0, keys: '\x1b[5~' }); + expect(pageKeysForTravel(0, -1000, 36).keys).toBe('\x1b[5~'.repeat(3)); + expect(pageKeysForTravel(0, 40, 36)).toEqual({ pending: 4, keys: '\x1b[6~'.repeat(2) }); + + // The primary pane's own methods agree with them. + const ev = { deltaY: -250, deltaMode: 2 }; + expect(app._wheelScrollLinesFloat(ev)).toBe(wheelDeltaLines(ev, 36)); + let pending = 0; + let expected = ''; + for (const lines of [-10, -10, 30, -1000, 7]) { + const step = pageKeysForTravel(pending, lines, 36); + pending = step.pending; + expected += step.keys; + app._maybePageCliTranscript({ shiftKey: false }, lines); + } + app._flushWheelSgrQueue(); + expect(app._pageKeyPending).toBe(pending); + expect(expected).not.toBe(''); + expect(sent).toEqual([{ id: 'sess-1', data: expected }]); + }); + + it("_localScrollbackIsHollow reads the target's session, buffer and rows, never the active ones", () => { + const { app } = hollowApp({ mode: 'shell' }); // the ACTIVE session is a shell + app.sessions.set('tile-1', { mode: 'opencode' }); + const tileBuffer = { type: 'normal', viewportY: 16, baseY: 16 }; + const tileTerminal = { rows: 24, buffer: { active: tileBuffer } }; + + expect(app._localScrollbackIsHollow()).toBe(false); // the primary's own answer + // 16 rows above the tile's screen, all of them its own overflow: hollow. + expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(true); + // Real history in the tile: not hollow, whatever the primary holds. + expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 3 })).toBe(false); + // No localRows: the tile's own baseY decides. + expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal })).toBe(false); + tileBuffer.type = 'alternate'; + expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false); + tileBuffer.type = 'normal'; + app.sessions.set('tile-1', { mode: 'codex' }); + expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false); + }); + + it("_shouldForwardWheelToApp reads the target's session and the target terminal's tracking mode", () => { + const { app } = hollowApp({ mode: 'opencode' }); // the ACTIVE session would never forward + app.sessions.set('tile-1', { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true }); + const tileTerminal = { rows: 24, modes: { mouseTrackingMode: 'none' } }; + + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe( + true + ); + // The tile's own xterm encoder owns the wheel while its tracking is on. + tileTerminal.modes.mouseTrackingMode = 'any'; + expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe( + false + ); + // And the primary's tracking mode does not leak into the tile's answer. + tileTerminal.modes.mouseTrackingMode = 'none'; + app.terminal.modes.mouseTrackingMode = 'any'; + expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe( + true + ); + }); +}); + describe('scroll routing diagnostic (issue #205 round 2)', () => { it('prints the decision and its inputs once per session, and again when it changes', () => { const { app, logs } = hollowApp({ cliVersion: '2.1.100' }); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 6974c39e..28bbd254 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -577,6 +577,87 @@ describe('terminal touch tap mouse guard', () => { expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']); }); + it('desktop click: a target aims the report at another pane (a TerminalTile)', () => { + // The tile's own terminal decides the geometry, the selection and the + // scroll position, and its own session decides the tracking flag; the + // primary pane's terminal and active session are not consulted. + const { app } = loadTerminalUiHarness(); + const sent: Array<{ id: string; data: string }> = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([ + ['sess-1', { mode: 'claude', cliMouseTracking: false }], + ['s2', { mode: 'opencode', cliMouseTracking: true }], + ]); + app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); + app._linkHovered = true; // the PRIMARY pane's hover: must not block the tile + app.terminal = { + cols: 80, + rows: 24, + modes: { mouseTrackingMode: 'none' }, + hasSelection: () => true, // the PRIMARY pane's selection: must not block the tile + buffer: { active: { viewportY: 0, baseY: 50 } }, // primary scrolled up: must not block either + element: { querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }) }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + let otherSelected = false; + const other = { + cols: 40, + rows: 12, + modes: { mouseTrackingMode: 'none' }, + hasSelection: () => otherSelected, + buffer: { active: { viewportY: 5, baseY: 5 } }, + element: { querySelector: () => ({ getBoundingClientRect: () => ({ left: 10, top: 20 }) }) }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + const click = { + isTrusted: true, + button: 0, + detail: 1, + clientX: 171, + clientY: 101, + target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) }, + }; + + app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: false }); + expect(sent).toEqual([{ id: 's2', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]); + + // The tile's own selection and its own link hover do block it. + otherSelected = true; + app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: false }); + otherSelected = false; + app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: true }); + expect(sent).toHaveLength(1); + + // With no target the primary pane answers for itself, exactly as before. + expect(app._shouldReportMouseToCli()).toBe(false); + expect(app._shouldReportMouseToCli('s2')).toBe(true); + app._handleDesktopTerminalClick(click); + expect(sent).toHaveLength(1); + }); + + it("tap: a target uses that pane's geometry and scroll position", () => { + const { app } = loadTerminalUiHarness(); + const sent: Array<{ id: string; data: string }> = []; + app.activeSessionId = 'sess-1'; + app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); + app.terminal = null; // the primary pane need not even exist + const other = { + cols: 40, + rows: 12, + buffer: { active: { viewportY: 0, baseY: 5 } }, // scrolled up + element: { querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }) }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + + app._sendSyntheticSgrTap(50, 9999, { terminal: other, sessionId: 's2' }); + expect(sent).toEqual([]); + + other.buffer.active.viewportY = 5; // back at the bottom + app._sendSyntheticSgrTap(50, 9999, { terminal: other, sessionId: 's2' }); + // Row clamped to the TARGET's 12 rows, not the primary's. + expect(sent).toEqual([{ id: 's2', data: '\x1b[<0;7;12M\x1b[<0;7;12m' }]); + }); + it('tap: does nothing while the viewport is scrolled up into local scrollback', () => { const { app } = loadTerminalUiHarness(); const sent: string[] = []; From 4fe843a94e5eaef06443e6904f04872c088c7a96 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 07:19:04 +0200 Subject: [PATCH 2/6] fix(tiles): page a hollow tile's CLI transcript and report its clicks (#555 parity) #555 made the primary pane page opencode's transcript with PageUp/PageDown from the wheel, because opencode draws in place on the alternate screen and leaves the browser's buffer with no scrollback. A TerminalTile (a grid tile, the split's Pane B) left every wheel to xterm, so in an opencode tile the wheel scrolled nothing, or only stale rows. The tile now runs the primary pane's own gates aimed at itself (its terminal, its session, never the active one): xterm's tracking mode, the Claude forwarding gate, then the hollow-buffer test. A wheel that passes them is consumed in the capture phase and turned into PageUp/PageDown through the shared pageKeysForTravel math, coalesced per tile (40 ms, 512 bytes, the twin of the primary pane's queue) and sent ephemeral on the tile's own socket. Every other wheel stays with xterm as before, the shell history pull included. The file names no CLI: the mode rules stay in terminal-ui.js, and terminal-tile.js joins the frontend no-id-branching guard. A plain port of the primary's baseY === 0 test would almost never fire in a grid. A tile's first capture is taken at the PTY's previous size (usually the taller primary pane's) and written into a shorter xterm, and its own row-shrinking fits (zoom-out, divider drags, tile count changes) push more rows above the screen. The tile counts those rows as its own overflow: all of them after a load whose capture held a single screen (the server's captureRows), plus whatever a local fit or a PTY geometry report pushes up, reset by a clear and clamped to baseY. The paging gate gets baseY minus that count. Output that scrolls real lines still counts as history, so the tile stops paging there. #555's other half, stripping opencode's mouse DECSETs so a drag selects text, is server-side and already reached tile sockets. It also left the tile's xterm unable to encode opencode's clicks, so the tile now installs the primary pane's desktop click report (bubble phase, gated on the session's cliMouseTracking, the tile's own link hover and selection). Both listeners, the flush timer and the page-key state are torn down in destroy(). Still out of scope, as the fileoverview now says: touch paging (tiles have no touch path) and SGR wheel forwarding to Claude's fullscreen renderer (tile-grid-plan follow-up 4), so a fullscreen Claude tile keeps leaving the wheel to xterm. Tests: test/terminal-tile-scroll.test.ts drives a real tile in the vm harness (session targeting, every no-page case, accumulation, the cap, coalescing, byte parity with the primary pane, the overflow discount through a load, a fit, a geometry report and a clear, the click report and destroy); the discount cases fail with it removed. The fake xterm gains opt-in row emulation. test/terminal-tile-scroll.browser.test.ts checks the same model against a real xterm with trusted wheel events (browser suite, not the gate). Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- config/test-suites.ts | 1 + docs/architecture-invariants.md | 4 +- docs/split-pane-sessions-plan.md | 5 +- docs/tile-grid-plan.md | 7 +- docs/wiki/The-Dashboard.md | 3 +- src/web/public/terminal-tile.js | 183 ++++++- test/frontend-cli-no-id-branching.test.ts | 29 +- test/mocks/terminal-tile-fakes.ts | 44 +- test/terminal-tile-scroll.browser.test.ts | 176 +++++++ test/terminal-tile-scroll.test.ts | 573 ++++++++++++++++++++++ test/terminal-tile-unit.test.ts | 25 +- 12 files changed, 1033 insertions(+), 19 deletions(-) create mode 100644 test/terminal-tile-scroll.browser.test.ts create mode 100644 test/terminal-tile-scroll.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 438ac05e..4ab21163 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,7 +286,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) +**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) pages the wheel and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/config/test-suites.ts b/config/test-suites.ts index 24662c30..6283d361 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -33,6 +33,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/capture-geometry-retry.browser.test.ts', 'test/codex-predictive-echo.test.ts', // also needs a real codex binary 'test/split-pane-terminal.browser.test.ts', + 'test/terminal-tile-scroll.browser.test.ts', 'test/shift-enter-keypress.browser.test.ts', 'test/key-tester.browser.test.ts', 'test/webhook-settings.browser.test.ts', diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 240b18a3..87e6a360 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,7 +225,7 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. -**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`. +**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. **Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing. @@ -801,7 +801,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ### 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 an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **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 below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), 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 `TerminalTile` 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 the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. 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'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) 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/Ctrl+Shift+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+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell straight to `return true`, silently ceding the keystroke to the BROWSER's own handling (e.g. Chrome's Inspect-Element binding) with no feedback and no copy attempt — Pane A always intercepts it. Ctrl+V goes through the primary pane's paste trap aimed at Pane B (`_handleImagePaste({ terminal, sessionId })`), so a pasted image uploads to Pane B's session. ⚠️ `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 keystrokes go through the exactly-once queue (`_sendInputAsync`) over its own socket, registered in the app's input-socket map (`_registerInputSocket` / `_inputSocketFor`), so they are seq-tagged, ACKed (`{t:'ia'}` is routed by the RECEIVING socket's session, since the frame names none), redelivered after a drop, and the ACK clears the idle alert. ⚠️ What xterm generates never enters that persisted queue: query replies are dropped (`shouldSuppressTerminalQueryResponse`, as the primary pane drops them) and focus/mouse reports go through `_sendInputEphemeral`, or a reload would replay them as typed text. ⚠️ A SHELL Pane B pulls scrollback itself when the wheel goes up at the top of its buffer (`_maybeLoadMoreHistory`/`_pullHistory`): tmux repaints a burst of output instead of scrolling it, so Pane B's own xterm holds about one screen of scrollback while tmux holds every line, and it loaded history exactly once at connect and never again. It is the same bounded pull as Pane A's (`?full=1&tail=TERMINAL_TAIL_SIZE`, no rewrite when the window holds no more rows than the pane already has or the pane is at its `scrollback + rows` cap, and a 60 s back-off instead of 4 s when that skipped window was truncated or the pane is full, since each ask costs the server a whole-history `capture-pane`), against Pane B's OWN terminal rather than `app.terminal`, so it cannot share `_maybeRefetchFullHistory`. The wheel listener is capture-phase because xterm `stopPropagation()`s the events it consumes; the alternate-screen skip (nano, vim, less) only matters for a direct-PTY shell, since under tmux the browser xterm never enters the alternate buffer; skipped too for a detached session (mirrors `_sendResize()`'s own check and app.js's `_maybeRefetchFullHistory`), since its own window already owns its PTY size and scrollback. Live frames arriving from the response onward, a `{t:'c'}` clear frame included, are held with their arrival time (`_liveQueue`, opened right after `await fetch(...)` beside `capturedAt`; a frame from before it is replaced by the capture or written unchanged, so the pane keeps painting during the round trip) and replayed in order only if they arrived after the capture (the response's arrival stands in for the capture instant, as in `_finishBufferLoad`, so a frame inside that one round trip can be lost or doubled); the request uses the primary pane's budget (`CodemanFetchDeadline.terminalFetchDeadlineMs({ full: true })`, 45 s, 10 s only if that helper is absent) and the body read gets 10 s once the headers land, because from then on the pull holds the pane's live output. The request phase holds no live output, but it does hold the single-flight flag, so a coalesced `{t:'r'}` refresh and a marker owed by a close (below) wait for the response, at worst for that whole budget (accepted: a Codeman restart resets an in-flight request along with the socket, so that pull fails at once and stamps the marker). ⚠️ The "disconnected" marker must be the LAST thing on screen. A replay's own `\x1bc` would otherwise wipe a marker written before the pull and paint a fresh, current-looking history while `onData` keeps silently dropping every keystroke on the dead socket (a Codeman restart drops the socket while the tmux session, and so the HTTP pull, still succeeds), so `_pullHistory()` re-stamps it after the live-frame flush. A close DURING any load (a pull, a refresh; `connect()` awaits the initial load before it creates the socket, so no close lands in that one) writes nothing: `_onSocketClosed()` sets `_markerOwed` while a load's work runs (`_loadRunning`; a load that only waits in the tile grid's queue writes the marker at once, and its refresh clears the screen only when its turn comes), since written there it would sit above the held frames the pull flushes after a skip, a downgrade, a failed fetch or the deadline, above a refresh's replay, or land mid-way through a chunked replay. Each load settles the marker in its OWN `finally` (`_stampMarkerIfOwed()`), after the queue flush, EXCEPT when a trailing refresh is pending: that refresh's `clear()` is synchronous while xterm parses a `write()` on a later tick, so a marker stamped just before it lands in the freshly cleared buffer ABOVE the refresh's replay (a second, stale copy; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously and cannot show it, so an async-parse fake there pins it). The marker stays owed instead, and the trailing refresh, which re-owes it on a closed socket anyway, writes the one copy below its own replay. Anything that wipes the terminal on a closed socket (a replay's `\x1bc`, a refresh's `clear()`) sets `_markerOwed` too, so the marker is rewritten whether or not the close landed during the load. Tracked via `_wsClosed`/`_markerOwed` rather than routed through `_onLiveOutput()`, since a close landing before the response is stamped before the cutoff and would be dropped with the rest of the pre-capture queue. There is no "Load full history" banner in Pane B, so a shell history past that 1 MiB window stays out of reach there. Non-shell Pane B is unchanged: it already loads `full=1`, and its history is out of scope for this pull (codex and Claude's inline renderer do grow tmux history; this just isn't how they recover it). ⚠️ App-level terminal actions find their pane through `_focusedPane()`, the terminal focused LAST (not `document.activeElement`, which the mic or a header button takes): Ctrl+L, Ctrl+Shift+R, voice and image paste act on Pane B while it holds the keyboard. Ctrl+W is not an app shortcut at all (Close Session has no default key), so it reaches whichever pane is focused as delete-word. ⚠️ Geometry: `TerminalTile.fit()` sends the size the xterm actually holds, unfloored (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, re-sends on every fresh socket, and adopts the PTY's column count from `{t:'zc'}`; the font, family and weight setters call `tile.fit()`, never a local refit alone (#464). Every capture a pane fetches carries a deadline covering the body (`CodemanFetchDeadline`), so a capture that never answers cannot hold its single-flight flag forever. Design: `docs/split-pane-sessions-plan.md`; the tile class: `docs/tile-grid-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 an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile; see terminal-tile.js's fileoverview) — 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 below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), 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 `TerminalTile` 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 the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. 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'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) 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/Ctrl+Shift+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+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell straight to `return true`, silently ceding the keystroke to the BROWSER's own handling (e.g. Chrome's Inspect-Element binding) with no feedback and no copy attempt — Pane A always intercepts it. Ctrl+V goes through the primary pane's paste trap aimed at Pane B (`_handleImagePaste({ terminal, sessionId })`), so a pasted image uploads to Pane B's session. ⚠️ `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 keystrokes go through the exactly-once queue (`_sendInputAsync`) over its own socket, registered in the app's input-socket map (`_registerInputSocket` / `_inputSocketFor`), so they are seq-tagged, ACKed (`{t:'ia'}` is routed by the RECEIVING socket's session, since the frame names none), redelivered after a drop, and the ACK clears the idle alert. ⚠️ What xterm generates never enters that persisted queue: query replies are dropped (`shouldSuppressTerminalQueryResponse`, as the primary pane drops them) and focus/mouse reports go through `_sendInputEphemeral`, or a reload would replay them as typed text. ⚠️ A SHELL Pane B pulls scrollback itself when the wheel goes up at the top of its buffer (`_maybeLoadMoreHistory`/`_pullHistory`): tmux repaints a burst of output instead of scrolling it, so Pane B's own xterm holds about one screen of scrollback while tmux holds every line, and it loaded history exactly once at connect and never again. It is the same bounded pull as Pane A's (`?full=1&tail=TERMINAL_TAIL_SIZE`, no rewrite when the window holds no more rows than the pane already has or the pane is at its `scrollback + rows` cap, and a 60 s back-off instead of 4 s when that skipped window was truncated or the pane is full, since each ask costs the server a whole-history `capture-pane`), against Pane B's OWN terminal rather than `app.terminal`, so it cannot share `_maybeRefetchFullHistory`. The wheel listener is capture-phase because xterm `stopPropagation()`s the events it consumes; the alternate-screen skip (nano, vim, less) only matters for a direct-PTY shell, since under tmux the browser xterm never enters the alternate buffer; skipped too for a detached session (mirrors `_sendResize()`'s own check and app.js's `_maybeRefetchFullHistory`), since its own window already owns its PTY size and scrollback. Live frames arriving from the response onward, a `{t:'c'}` clear frame included, are held with their arrival time (`_liveQueue`, opened right after `await fetch(...)` beside `capturedAt`; a frame from before it is replaced by the capture or written unchanged, so the pane keeps painting during the round trip) and replayed in order only if they arrived after the capture (the response's arrival stands in for the capture instant, as in `_finishBufferLoad`, so a frame inside that one round trip can be lost or doubled); the request uses the primary pane's budget (`CodemanFetchDeadline.terminalFetchDeadlineMs({ full: true })`, 45 s, 10 s only if that helper is absent) and the body read gets 10 s once the headers land, because from then on the pull holds the pane's live output. The request phase holds no live output, but it does hold the single-flight flag, so a coalesced `{t:'r'}` refresh and a marker owed by a close (below) wait for the response, at worst for that whole budget (accepted: a Codeman restart resets an in-flight request along with the socket, so that pull fails at once and stamps the marker). ⚠️ The "disconnected" marker must be the LAST thing on screen. A replay's own `\x1bc` would otherwise wipe a marker written before the pull and paint a fresh, current-looking history while `onData` keeps silently dropping every keystroke on the dead socket (a Codeman restart drops the socket while the tmux session, and so the HTTP pull, still succeeds), so `_pullHistory()` re-stamps it after the live-frame flush. A close DURING any load (a pull, a refresh; `connect()` awaits the initial load before it creates the socket, so no close lands in that one) writes nothing: `_onSocketClosed()` sets `_markerOwed` while a load's work runs (`_loadRunning`; a load that only waits in the tile grid's queue writes the marker at once, and its refresh clears the screen only when its turn comes), since written there it would sit above the held frames the pull flushes after a skip, a downgrade, a failed fetch or the deadline, above a refresh's replay, or land mid-way through a chunked replay. Each load settles the marker in its OWN `finally` (`_stampMarkerIfOwed()`), after the queue flush, EXCEPT when a trailing refresh is pending: that refresh's `clear()` is synchronous while xterm parses a `write()` on a later tick, so a marker stamped just before it lands in the freshly cleared buffer ABOVE the refresh's replay (a second, stale copy; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously and cannot show it, so an async-parse fake there pins it). The marker stays owed instead, and the trailing refresh, which re-owes it on a closed socket anyway, writes the one copy below its own replay. Anything that wipes the terminal on a closed socket (a replay's `\x1bc`, a refresh's `clear()`) sets `_markerOwed` too, so the marker is rewritten whether or not the close landed during the load. Tracked via `_wsClosed`/`_markerOwed` rather than routed through `_onLiveOutput()`, since a close landing before the response is stamped before the cutoff and would be dropped with the rest of the pre-capture queue. There is no "Load full history" banner in Pane B, so a shell history past that 1 MiB window stays out of reach there. Non-shell Pane B is unchanged: it already loads `full=1`, and its history is out of scope for this pull (codex and Claude's inline renderer do grow tmux history; this just isn't how they recover it). ⚠️ App-level terminal actions find their pane through `_focusedPane()`, the terminal focused LAST (not `document.activeElement`, which the mic or a header button takes): Ctrl+L, Ctrl+Shift+R, voice and image paste act on Pane B while it holds the keyboard. Ctrl+W is not an app shortcut at all (Close Session has no default key), so it reaches whichever pane is focused as delete-word. ⚠️ Geometry: `TerminalTile.fit()` sends the size the xterm actually holds, unfloored (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, re-sends on every fresh socket, and adopts the PTY's column count from `{t:'zc'}`; the font, family and weight setters call `tile.fit()`, never a local refit alone (#464). Every capture a pane fetches carries a deadline covering the body (`CodemanFetchDeadline`), so a capture that never answers cannot hold its single-flight flag forever. Design: `docs/split-pane-sessions-plan.md`; the tile class: `docs/tile-grid-plan.md`. ### Tile grid diff --git a/docs/split-pane-sessions-plan.md b/docs/split-pane-sessions-plan.md index 6d7416d2..6b3259e1 100644 --- a/docs/split-pane-sessions-plan.md +++ b/docs/split-pane-sessions-plan.md @@ -92,7 +92,10 @@ view needs a wide viewport). So: keyboard accessory bar. On a desktop, typing directly into an xterm instance with no overlay is exactly how Codeman behaved before the local- echo overlay existed for touch devices — normal, not degraded, for a - keyboard-and-mouse user. + keyboard-and-mouse user. (Since moved to `TerminalTile`, terminal-tile.js, + which has gained two pieces of the primary pane through its own gates aimed + at the tile: hollow-buffer wheel paging (#555) and the desktop click report + for a CLI with `cliMouseTracking` on. See that file's fileoverview.) If this asymmetry actually bothers you in daily use, promoting Pane B to full parity is a scoped v2 (extract the shared logic already once you have two diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index fde97b16..de6612f3 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -998,7 +998,12 @@ exits green. Use the browser runner for those files and read the file count. 3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}` on drain) for grids over slow links. 4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer, - a "Load full history" action inside a tile. + a "Load full history" action inside a tile. (Done since: a tile pages a + hollow buffer's CLI transcript with PageUp/PageDown, the primary pane's + #555 route, and hand-reports a plain click while its session has + `cliMouseTracking` on, both through the primary pane's gates aimed at the + tile. The SGR wheel forwarding itself is still open: a fullscreen Claude + tile leaves the wheel to xterm.) 5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles. 6. Named grid presets, possibly per owner on the server. 7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`, diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index cd9da9e2..0795cef0 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -219,7 +219,8 @@ Worth knowing: `~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal. Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is always local scrollback. OpenCode's wheel and swipes page its own conversation - (PageUp/PageDown). Other CLIs scroll locally. + (PageUp/PageDown); in a grid tile or the split view's second pane the wheel does + too. Other CLIs scroll locally. - **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not. `Ctrl+Shift+C` always copies. - **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 240df0fb..243d4d97 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -11,12 +11,27 @@ * * Deliberately plainer than the primary pane (this.terminal/this._ws in * terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile - * handlers, no keyboard accessory bar. Desktop-only by nature; see - * docs/split-pane-sessions-plan.md. + * handlers (a swipe on a touch screen pages nothing), no keyboard accessory + * bar, and no SGR wheel forwarding to Claude's fullscreen renderer + * (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see + * docs/split-pane-sessions-plan.md and docs/tile-grid-plan.md. + * + * What it does carry over from the primary pane, through the primary pane's + * own code aimed at THIS pane (its terminal, its session, never the active + * one): + * - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the + * alternate screen, Claude's repaint mode) leaves the xterm no scrollback, + * so the wheel pages the CLI's own transcript with PageUp/PageDown + * (_maybePageCliTranscript) through the primary pane's gates, plus an + * overflow-row discount for this pane's capture-before-resize load + * (_localRows). + * - The desktop click report: a plain left-click hand-encoded as SGR while + * the session's CLI has mouse tracking on (cliMouseTracking), for the modes + * whose mouse DECSETs the server strips (_installClickListener). * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) - * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight) + * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_handleDesktopTerminalClick) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js */ @@ -157,6 +172,19 @@ // flag, app._linkHovered, belongs to its terminal alone). this._linkHovered = false; this._onFocusIn = null; + // Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a + // whole page, carried to the next wheel event. + this._pageKeyPending = 0; + // Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes). + this._scrollBytes = ''; + this._scrollFlushTimer = null; + // Rows above the screen that this pane pushed there itself rather than + // received as history: a capture taken at the PTY's previous, taller size + // and row-shrinking fits (_overflowAfterLoad, _noteResizeRows). Not + // history, so the paging gate leaves them out (_localRows). + this._overflowRows = 0; + // The desktop click reporter (_installClickListener). + this._onClick = null; } async connect() { @@ -192,6 +220,7 @@ }); this._installWheelListener(); + this._installClickListener(); // Focusing this terminal makes it the pane the keyboard is in, so the // app-level shortcuts, voice and paste act on it (app._focusedPane). @@ -600,6 +629,7 @@ // Cleared at the load's turn, not when it was asked for: a grid tile // waiting in the queue keeps its last frame instead of sitting blank. this.terminal?.clear(); + this._overflowRows = 0; // The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue // a trailing refresh behind a pull that the socket's close then // interrupts), so a refresh on a closed socket owes it back once its @@ -635,6 +665,7 @@ () => this._destroyed, (cancel) => (this._cancelReplay = cancel) ); + if (!this._destroyed && this.terminal) this._overflowRows = this._overflowAfterLoad(payload); } } catch { /* Best-effort: live output still arrives once the socket connects. */ @@ -699,18 +730,140 @@ // one it is restoring. Queued, it lands in order with the frames around it. _onLiveClear() { if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true }); - else this.terminal?.clear(); + else this._clearTerminal(); + } + + // A clear leaves no rows above the screen, the pane's own overflow included. + _clearTerminal() { + this.terminal?.clear(); + this._overflowRows = 0; } // Capture phase, because xterm's own wheel handler stopPropagation()s every // event it consumes, so a bubbling listener here would never see the wheel - // while the pane still has scrollback to scroll. Passive: this only observes, - // xterm keeps doing the scrolling. + // while the pane still has scrollback to scroll. Not passive: the one route + // this pane takes over, paging a hollow buffer's CLI transcript + // (_maybePageCliTranscript), is consumed right here (preventDefault plus + // stopPropagation in the capture phase, the primary pane's technique), so + // xterm's viewport, a descendant, never sees it. Every other wheel is left + // to xterm, which keeps doing the scrolling, and only observed for the + // shell history pull. _installWheelListener() { this._onWheel = (ev) => { + if (this._maybePageCliTranscript(ev)) { + ev.preventDefault(); + ev.stopPropagation(); + return; + } if (ev.deltaY < 0) this._maybeLoadMoreHistory(); }; - this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true }); + this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: false }); + } + + // A plain left-click reported to the CLI, the primary pane's desktop click + // (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The + // server strips the mouse DECSETs of some modes (opencode's since #555, so a + // drag selects text), which leaves this xterm's own mouse encoder idle for + // them; without this a click in such a pane never reached the CLI. Only + // while this pane's session has tracking on (cliMouseTracking), through the + // same skips as the primary pane. Bubble phase, as there. The target is + // built per click, so the terminal and the link hover are read live. + _installClickListener() { + this._onClick = (ev) => { + if (this._destroyed || !this.terminal) return; + global.app?._handleDesktopTerminalClick?.(ev, { + terminal: this.terminal, + sessionId: this.sessionId, + linkHovered: this._linkHovered, + }); + }; + this.mountEl.addEventListener('click', this._onClick); + } + + // Hollow-buffer paging, the twin of the primary pane's + // _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that + // draws in place (opencode, on the alternate screen; Claude's repaint mode) + // leaves this xterm no scrollback, so a wheel scrolled nothing; instead the + // travel pages the CLI's own transcript with PageUp/PageDown. Every gate is + // the primary pane's own, asked for THIS pane (its terminal, its session, + // never the active one), so the CLI rules stay in terminal-ui.js and this + // file names no CLI. Returns true when the wheel was consumed here. + _maybePageCliTranscript(ev) { + if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false; + const app = global.app; + const input = global.CodemanTerminalInput; + if (!app || !input?.pageKeysForTravel || !input.wheelDeltaLines) return false; + // xterm's own encoder forwards the wheel while the CLI's tracking reaches + // it (a shell running htop), as in the primary pane. + const tracking = this.terminal.modes?.mouseTrackingMode; + if (tracking && tracking !== 'none') return false; + const target = { terminal: this.terminal, sessionId: this.sessionId }; + // The primary pane would forward this wheel to Claude's fullscreen + // renderer as SGR reports. Tiles do not do that yet (docs/tile-grid-plan.md + // follow-up 4), so the wheel stays with xterm, as before. + if (app._shouldForwardWheelToApp?.(ev, target)) return false; + if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false; + const lines = input.wheelDeltaLines(ev, this.terminal.rows); + if (!lines) return false; + const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows); + this._pageKeyPending = step.pending; + if (step.keys) this._queueScrollBytes(step.keys); + return true; + } + + // Coalesces the page keys into one send per 40 ms, bounded at 512 bytes so a + // fling cannot build a backlog that keeps paging after it stops. A narrow + // twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue + // (terminal-ui.js; keep the two in step), which flushes to the active + // session only. Sent ephemeral (no seq, never persisted) to THIS pane's + // session, over this pane's socket while it is open. + _queueScrollBytes(data) { + if (!data || this._destroyed) return; + if (this._scrollBytes.length > 512) return; + this._scrollBytes += data; + if (this._scrollFlushTimer) return; + this._scrollFlushTimer = setTimeout(() => { + this._scrollFlushTimer = null; + const bytes = this._scrollBytes; + this._scrollBytes = ''; + if (bytes && !this._destroyed) global.app?._sendInputEphemeral?.(this.sessionId, bytes); + }, 40); + } + + // History rows in this xterm, for the paging gate: baseY less the rows this + // pane pushed up itself. Clamped, because a clear (Ctrl+L, a `{t:'c'}` + // frame) or an ED3/RIS in the stream drops rows behind this count's back. + _localRows() { + const baseY = this.terminal?.buffer?.active?.baseY || 0; + this._overflowRows = Math.min(this._overflowRows, baseY); + return baseY - this._overflowRows; + } + + // After a local resize: rows a shrinking fit pushed above the screen count + // as overflow, and rows a growing one pulled back come off it. `before` is + // baseY just before the resize (xterm resizes synchronously). + _noteResizeRows(before) { + const after = this.terminal?.buffer?.active?.baseY || 0; + this._overflowRows = Math.max(0, Math.min(after, this._overflowRows + (after - before))); + } + + // The overflow a finished load leaves: everything above the screen when the + // capture held a single screen (the server says how tall in `captureRows`; + // the full-history path keeps every pane row and trims one newline), none + // when it carried history. The counterpart of the primary pane resizing the + // PTY before it captures (app.js selectSession's sendResize), which this + // pane does not do: its first capture is taken at the PTY's previous size + // (usually the primary pane's, taller) and written into a shorter xterm, + // whose extra rows land above the screen with nothing after them to clear + // them. Without a `captureRows` the raw baseY stands, as in the primary. + _overflowAfterLoad(payload) { + const captureRows = payload?.captureRows; + if (!Number.isFinite(captureRows)) return 0; + const text = payload.terminalBuffer || ''; + let lines = 1; + for (let i = text.indexOf('\n'); i !== -1 && lines <= captureRows; i = text.indexOf('\n', i + 1)) lines++; + if (lines > captureRows) return 0; + return this.terminal?.buffer?.active?.baseY || 0; } // Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of @@ -818,6 +971,7 @@ } this._historyPullUseless = false; term.write('\x1bc'); + this._overflowRows = 0; // the reset leaves nothing above the screen replayed = true; if (this._wsClosed) this._markerOwed = true; await writeChunked( @@ -849,7 +1003,7 @@ const cutoff = replayed ? capturedAt : 0; for (const entry of queued) { if (entry.at < cutoff) continue; - if (entry.clear) this.terminal?.clear(); + if (entry.clear) this._clearTerminal(); else this.terminal?.write(entry.data); } // Settled after the queue flush so the marker is the last thing on @@ -894,7 +1048,9 @@ // pane's own convention (throttledResize in terminal-ui.js). localFit() { if (!this.fitAddon) return; + const before = this.terminal?.buffer?.active?.baseY || 0; this.fitAddon.fit(); + this._noteResizeRows(before); } // Reflow to the container and tell the PTY, as one step: the xterm and the @@ -955,7 +1111,9 @@ { cols, rows } ); if (!verdict?.adopt) return; + const before = terminal.buffer?.active?.baseY || 0; terminal.resize(verdict.cols, terminal.rows); + this._noteResizeRows(before); // a column change reflows rows above the screen this._lastSentDims = { cols: verdict.cols, rows: terminal.rows }; } @@ -982,6 +1140,15 @@ this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true }); this._onWheel = null; } + if (this._onClick) { + this.mountEl?.removeEventListener('click', this._onClick); + this._onClick = null; + } + // Page keys still waiting for their flush go nowhere: the pane is gone. + clearTimeout(this._scrollFlushTimer); + this._scrollFlushTimer = null; + this._scrollBytes = ''; + this._pageKeyPending = 0; this._detachSocket(); if (this._onFocusIn) { this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn); diff --git a/test/frontend-cli-no-id-branching.test.ts b/test/frontend-cli-no-id-branching.test.ts index ba098bc7..e4f3aa30 100644 --- a/test/frontend-cli-no-id-branching.test.ts +++ b/test/frontend-cli-no-id-branching.test.ts @@ -3,7 +3,9 @@ * (`session-ui.js`, `mobile-overview.js`), plus the files that draw a session * header's harness logo and model (`constants.js`, `terminal-split.js`, * `tile-grid.js`: the logo's `run-mode-dot <cliId>` class is the id as DATA), - * mirroring + * and `terminal-tile.js`, whose wheel paging and click reports reach the + * primary pane's CLI rules through terminal-ui.js and must not grow a copy of + * them, mirroring * `test/cli-registry-no-id-branching.test.ts` for the backend registry. * * Deliberately scoped to ONLY these two files, not all of `src/web/public/`. @@ -24,7 +26,14 @@ import { fileURLToPath } from 'node:url'; import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url)); -const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js', 'constants.js', 'terminal-split.js', 'tile-grid.js']; +const SCANNED_FILES = [ + 'session-ui.js', + 'mobile-overview.js', + 'constants.js', + 'terminal-split.js', + 'tile-grid.js', + 'terminal-tile.js', +]; /** * Every currently-surviving branch, each with the COUNT of physical call @@ -100,6 +109,20 @@ const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = { reason: 'attach route: a shell session attaches through /shell, an agent through /interactive', }, + // terminal-tile.js: the pane's two shell-only mechanisms, both mirrors of the + // primary pane's own shell checks (terminal-ui.js / app.js). Its wheel + // paging and click reports name no CLI: they ask terminal-ui.js's gates. + "terminal-tile.js::mode !== 'shell'": { + count: 2, + reason: + 'Ctrl+Z reaches the PTY only in a shell (job control), and the scroll-to-top history pull is shell-only, ' + + 'both as in the primary pane', + }, + "terminal-tile.js::mode === 'shell'": { + count: 1, + reason: 'the load query: a shell loads the bounded tail= window instead of a full capture, as in the primary pane', + }, + // mobile-overview.js: shell is exempt from the isCliAvailable() gate the // same way the toolbar's #runModeMenu exempts it (shell needs no CLI). "mobile-overview.js::mode !== 'shell'": { @@ -188,7 +211,7 @@ function actualCounts(): Map<string, number> { } describe('no NEW CLI-id branching in the scanned frontend files', () => { - it('scans both files (sanity)', () => { + it('scans every listed file (sanity)', () => { // If this drops to zero the scanner or the file list drifted and every // assertion below would pass vacuously. const scannedBytes = SCANNED_FILES.reduce((n, f) => n + readFileSync(PUBLIC + f, 'utf-8').length, 0); diff --git a/test/mocks/terminal-tile-fakes.ts b/test/mocks/terminal-tile-fakes.ts index 63eb82b9..3bb4a349 100644 --- a/test/mocks/terminal-tile-fakes.ts +++ b/test/mocks/terminal-tile-fakes.ts @@ -61,15 +61,45 @@ export class FakeFit { /** An xterm that records writes, resizes and its handlers; `type()` feeds onData like a keystroke. */ export class FakeTerminal { static last: FakeTerminal | null = null; + /** + * Opt-in, set by a test BEFORE the tile connects: the buffer's rows follow + * what is written, as in xterm. Every `\n` adds a line, `baseY` is the lines + * beyond the screen, a clear leaves one line, and a resize recomputes it + * (a row-shrinking fit pushes rows above the screen, a growing one pulls them + * back). The viewport follows the bottom. Off, `baseY` stays where a test + * puts it. + */ + static emulateScroll = false; options: Record<string, unknown>; cols = 80; rows = 24; dataCb: ((data: string) => void) | null = null; - buffer = { active: { type: 'normal', viewportY: 0, length: 24 } }; + buffer = { active: { type: 'normal', viewportY: 0, baseY: 0, length: 24 } }; + /** xterm's own mouse-tracking mode (DECSET 1000 and friends); 'none' while no app asked for the mouse. */ + modes = { mouseTrackingMode: 'none' }; + /** Lines in the buffer while emulating (the cursor line counts). */ + lineCount = 1; + emulate = FakeTerminal.emulateScroll; + /** Called after an emulated resize, so a test can stand in for a reflow. */ + afterResize: ((cols: number, rows: number) => void) | null = null; + /** Where the screen sits and how big a cell renders, for the click-to-cell math. */ + screenRect = { left: 10, top: 20 }; + element = { + querySelector: (sel: string) => + sel === '.xterm-screen' ? { getBoundingClientRect: () => ({ ...this.screenRect }) } : null, + }; + _core = { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }; constructor(options: Record<string, unknown>) { this.options = { ...options }; FakeTerminal.last = this; } + /** Re-derives baseY (and a viewport following the bottom) from the emulated line count. */ + settleRows() { + const active = this.buffer.active; + active.baseY = Math.max(0, this.lineCount - this.rows); + active.viewportY = active.baseY; + active.length = Math.max(this.lineCount, this.rows); + } loadAddon(addon: FakeFit) { addon.term = this; } @@ -101,16 +131,28 @@ export class FakeTerminal { // An empty write puts nothing on screen; the replay queues one only to hear // (its callback) that everything before it has been parsed. if (data) this.writes.push(data); + if (data && this.emulate) { + this.lineCount += data.split('\n').length - 1; + this.settleRows(); + } if (!this.holdParse) cb?.(); } clear() { this.writes.push('<CLEAR>'); + if (this.emulate) { + this.lineCount = 1; + this.settleRows(); + } } resizes: Array<[number, number]> = []; resize(cols: number, rows: number) { this.resizes.push([cols, rows]); this.cols = cols; this.rows = rows; + if (this.emulate) { + this.settleRows(); + this.afterResize?.(cols, rows); + } } scrollToLine() {} scrollToTop() {} diff --git a/test/terminal-tile-scroll.browser.test.ts b/test/terminal-tile-scroll.browser.test.ts new file mode 100644 index 00000000..a37ae39b --- /dev/null +++ b/test/terminal-tile-scroll.browser.test.ts @@ -0,0 +1,176 @@ +/** + * @fileoverview Real Chromium + real xterm coverage for a TerminalTile's + * hollow-buffer paging (#555 parity), the parts a fake xterm cannot prove: + * + * - the capture-phase wheel listener on the tile's mount really keeps the + * paged wheel away from xterm (its viewport does not move into the stale + * rows above the screen), while a wheel the tile does not page still + * reaches xterm and scrolls it, and + * - real xterm puts a one-screen capture taken at a taller size above the + * screen, and a row-shrinking fit pushes more rows up, which is what the + * tile's overflow discount (`_localRows`) counts. Output that scrolls real + * lines is history, and the tile stops paging. + * + * The wheel is a real one (`page.mouse.wheel()`), and the last step proves it + * reaches xterm: a wheel the tile does not page scrolls xterm's viewport, so + * "xterm did not scroll" on a paged wheel means something. What keeps xterm + * still there is the tile's preventDefault (xterm 6's scrollable element skips + * a wheel whose default was prevented); its stopPropagation, the primary + * pane's other half, keeps xterm's own handlers from seeing the event at all. + * Dropping both makes this test fail. + * + * The tile's load and socket are stubbed in the page (fetch answers its one + * capture, WebSocket never opens), so nothing here needs a PTY; the page keys + * are read from a spy on `app._sendInputEphemeral`. The gates themselves are + * the real ones in terminal-ui.js, asked for the tile's own session. + * + * Port: ephemeral (`new WebServer(0, …)`, read back from `boundPort`). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const PROBE_ID = 'tile-scroll-probe'; + +type Snap = { rows: number; baseY: number; viewportY: number; localRows: number; sent: Array<[string, string]> }; + +describe('TerminalTile wheel paging in a real browser', () => { + let server: WebServer; + let browser: Browser; + let page: Page; + + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal && (window as any).TerminalTile, null, { + timeout: 30000, + }); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + }, 60000); + + /** The tile's state plus every page key sent so far. */ + const snap = () => + page.evaluate(() => { + const probe = (window as any).__tileProbe; + const term = probe.tile.terminal; + return { + rows: term.rows, + baseY: term.buffer.active.baseY, + viewportY: term.buffer.active.viewportY, + localRows: probe.tile._localRows(), + sent: probe.sent.slice(), + } as Snap; + }); + + /** A real wheel-up of a whole screen over the tile, then time for the 40 ms flush and a frame. */ + async function wheelUp(rows: number) { + await page.mouse.move(200, 60); + await page.mouse.wheel(0, -rows * 25); + await page.waitForTimeout(150); + } + + it('pages a hollow tile, keeps xterm still, survives a shrink, and stops once real lines scroll', async () => { + await page.evaluate(async (id) => { + const w = window as any; + const app = w.app; + const CAPTURE_ROWS = 40; + const capture = Array.from({ length: CAPTURE_ROWS }, (_, i) => `row ${i}`).join('\r\n'); + + // The tile's session, as the app knows it: opencode, which draws in place. + app.sessions.set(id, { id, mode: 'opencode' }); + const sent: Array<[string, string]> = []; + const realEphemeral = app._sendInputEphemeral; + app._sendInputEphemeral = (sessionId: string, data: string) => sent.push([sessionId, data]); + const realFetch = w.fetch; + w.fetch = async (url: string, init?: unknown) => + String(url).includes(`/api/sessions/${id}/terminal`) + ? new Response(JSON.stringify({ data: { terminalBuffer: capture, captureRows: CAPTURE_ROWS } })) + : realFetch(url, init); + const RealWebSocket = w.WebSocket; + w.WebSocket = class { + static OPEN = 1; + readyState = 0; + send() {} + close() {} + }; + + const mount = document.createElement('div'); + mount.style.cssText = 'position:fixed;left:0;top:0;width:640px;height:300px;z-index:99999;background:#000'; + document.body.appendChild(mount); + const tile = new w.TerminalTile(id, mount, { mode: 'opencode' }); + try { + await tile.connect(); + } finally { + w.fetch = realFetch; + w.WebSocket = RealWebSocket; + } + w.__tileProbe = { tile, mount, sent, realEphemeral }; + }, PROBE_ID); + + try { + // A 40-row capture in a shorter xterm: the extra rows sit above the + // screen, and the tile counts every one of them as its own overflow. + const afterLoad = await snap(); + expect(afterLoad.rows).toBeLessThan(40); + expect(afterLoad.baseY).toBe(40 - afterLoad.rows); + expect(afterLoad.viewportY).toBe(afterLoad.baseY); + expect(afterLoad.localRows).toBe(0); + + // The wheel is consumed and paged: xterm never saw it, so its viewport + // stayed at the bottom instead of scrolling into the stale rows. + await wheelUp(afterLoad.rows); + const afterWheel = await snap(); + expect(afterWheel.viewportY).toBe(afterLoad.baseY); + expect(afterWheel.sent.length).toBeGreaterThanOrEqual(1); + expect(afterWheel.sent.every(([id]) => id === PROBE_ID)).toBe(true); + expect(afterWheel.sent.map(([, data]) => data).join('')).toMatch(/^(?:\x1b\[5~)+$/); + + // A row-shrinking fit pushes more rows up; still not history, still paged. + await page.evaluate(() => { + const probe = (window as any).__tileProbe; + probe.mount.style.height = '200px'; + probe.tile.localFit(); + }); + const afterShrink = await snap(); + expect(afterShrink.rows).toBeLessThan(afterLoad.rows); + expect(afterShrink.baseY).toBeGreaterThan(afterLoad.baseY); + expect(afterShrink.localRows).toBe(0); + await wheelUp(afterShrink.rows); + const afterSecondWheel = await snap(); + expect(afterSecondWheel.sent.length).toBeGreaterThan(afterWheel.sent.length); + expect(afterSecondWheel.viewportY).toBe(afterShrink.baseY); + + // Output that scrolled real lines is history: the wheel goes back to + // xterm, which scrolls its own buffer, and no page key is sent. + await page.evaluate( + () => + new Promise((resolve) => + (window as any).__tileProbe.tile.terminal.write('real 1\r\nreal 2\r\nreal 3\r\n', resolve) + ) + ); + const afterOutput = await snap(); + expect(afterOutput.localRows).toBe(3); + await wheelUp(afterOutput.rows); + const afterThirdWheel = await snap(); + expect(afterThirdWheel.sent.length).toBe(afterSecondWheel.sent.length); + expect(afterThirdWheel.viewportY).toBeLessThan(afterOutput.baseY); + } finally { + await page.evaluate((id) => { + const w = window as any; + const probe = w.__tileProbe; + probe.tile.destroy(); + probe.mount.remove(); + w.app.sessions.delete(id); + w.app._sendInputEphemeral = probe.realEphemeral; + delete w.__tileProbe; + }, PROBE_ID); + } + }); +}); diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts new file mode 100644 index 00000000..4a4e55d5 --- /dev/null +++ b/test/terminal-tile-scroll.test.ts @@ -0,0 +1,573 @@ +/** + * @fileoverview A TerminalTile pages a hollow buffer's CLI transcript, and + * reports a plain click to a CLI whose mouse DECSETs the server strips, the way + * the primary pane does (#555), through the primary pane's own gates aimed at + * the TILE: its terminal, its session, never the active one. + * + * opencode draws in place on the alternate screen, so tmux keeps no history + * for it and the browser's buffer holds one screen. The primary pane turns the + * wheel into PageUp/PageDown there (`_maybePageCliTranscript`, terminal-ui.js); + * a tile left the wheel to xterm, which scrolled nothing. #555 also strips + * opencode's mouse DECSETs on the server, which reaches tile sockets too: a + * drag selects text again, but xterm's own encoder no longer reports a click, + * so the tile now hand-encodes it like the primary pane. + * + * A tile's buffer is rarely empty above the screen even so: its first capture + * is taken at the PTY's previous, taller size and written into a shorter + * xterm, and its own row-shrinking fits push more rows up. Those rows are not + * history, so the tile discounts them (`_localRows`); the overflow cases below + * fail without that discount. + * + * Real code under test: constants.js + app.js + terminal-ui.js + + * terminal-tile.js in one `vm` context, as in terminal-tile-input.test.ts. + * xterm, the fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts), + * with the fake xterm's row emulation switched on. + * + * Port: none (no server, no browser). + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js'; + +const fetchMock = vi.fn(); + +function loadContext() { + const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); + const windowStub: Record<string, unknown> = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + CodemanBase: { base: '' }, + }; + const context = vm.createContext({ + console: { ...console, log: vi.fn(), debug: vi.fn() }, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + // Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code + // running inside this context. + setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id), + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: FakeSocket, + Terminal: FakeTerminal, + FitAddon: { FitAddon: FakeFit }, + fetch: (...args: unknown[]) => fetchMock(...args), + location: { protocol: 'http:', host: 'codeman.test' }, + document: { addEventListener: vi.fn(), documentElement: { dataset: {} } }, + localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + window: windowStub, + MobileDetection: { + isTouchDevice: () => false, + isHandheldDevice: () => false, + getDeviceType: () => 'desktop', + }, + }); + vm.runInContext( + `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` + + 'globalThis.__CodemanApp = CodemanApp;', + context + ); + return { + CodemanApp: (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp, + windowStub, + }; +} + +const { CodemanApp, windowStub } = loadContext(); + +type Session = { mode: string; cliVersion?: string; cliMouseTracking?: boolean }; +type App = Record<string, unknown> & { + sessions: Map<string, Session>; + activeSessionId: string | null; + _linkHovered?: boolean; + loadAppSettingsFromStorage: () => Record<string, unknown>; +}; + +function makeApp(sessions: Record<string, Session>, activeSessionId: string | null = 'other'): App { + const app = Object.create(CodemanApp.prototype) as App; + app._clientId = 'c-test'; + app._wsTabNonce = 'nonce-1'; + app._seqCounters = new Map(); + app._pendingDeliveries = new Map(); + app._postDraining = new Set(); + app._extraInputSockets = new Map(); + app._persistReliableState = vi.fn(); + app._persistReliableNow = vi.fn(); + app._updateConnectionIndicator = vi.fn(); + app.markIdleAlertSeen = vi.fn(); + app.showToast = vi.fn(); + app.loadAppSettingsFromStorage = () => ({}); + app._ws = null; + app._wsSessionId = null; + app._estimateReplayRows = (text: string) => text.split('\n').length; + app.sessions = new Map(Object.entries(sessions)); + app.activeSessionId = activeSessionId; + return app; +} + +/** The tile's mount element: records its listeners by type, with their options. */ +function makeMount() { + const listeners: Record<string, Array<{ fn: (ev: unknown) => void; opts: unknown }>> = {}; + return { + listeners, + addEventListener: vi.fn((type: string, fn: (ev: unknown) => void, opts?: unknown) => { + (listeners[type] ||= []).push({ fn, opts }); + }), + removeEventListener: vi.fn(), + fire(type: string, ev: unknown) { + for (const { fn } of listeners[type] || []) fn(ev); + }, + }; +} + +type Tile = { + connect(): Promise<void>; + destroy(): void; + fit(opts?: { force?: boolean }): void; + ws: FakeSocket | null; + sessionId: string; + _linkHovered: boolean; + _scrollFlushTimer: unknown; + _onPtyGeometryReport(cols: number, rows: number): void; +}; +const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile; + +const liveTiles: Tile[] = []; + +const lines = (n: number) => Array.from({ length: n }, (_, i) => `row ${i}`).join('\r\n'); + +/** The capture a tile's first load receives. `captureRows` absent = the server could not say. */ +function serveCapture(terminalBuffer: string, captureRows?: number) { + fetchMock.mockImplementation(async () => ({ + ok: true, + status: 200, + json: async () => ({ data: { terminalBuffer, ...(captureRows === undefined ? {} : { captureRows }) } }), + })); +} + +async function connectTile(app: App, opts: { sessionId?: string; mode?: string } = {}) { + windowStub.app = app; + const mount = makeMount(); + const tile = new TerminalTile(opts.sessionId ?? 's-tile', mount, { mode: opts.mode ?? 'opencode' }); + liveTiles.push(tile); + await tile.connect(); + const ws = FakeSocket.instances.at(-1)!; + ws.open(); + return { tile, ws, term: FakeTerminal.last!, mount }; +} + +function wheel(deltaY: number, extra: Record<string, unknown> = {}) { + return { + deltaY, + deltaX: 0, + deltaMode: 0, + shiftKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...extra, + }; +} + +/** One wheel event worth `rowsOfTravel` lines (pixel mode: 25 px a line). */ +const wheelLines = (rowsOfTravel: number, extra: Record<string, unknown> = {}) => wheel(rowsOfTravel * 25, extra); + +const PAGE_UP = '\x1b[5~'; +const PAGE_DOWN = '\x1b[6~'; + +/** Frames the tile's socket sent once the 40 ms coalescer has flushed. */ +function flushed(ws: FakeSocket) { + vi.advanceTimersByTime(40); + return ws.inputFrames(); +} + +beforeEach(() => { + vi.useFakeTimers(); + FakeFit.proposed = { cols: 80, rows: 24 }; + FakeSocket.instances = []; + FakeTerminal.emulateScroll = true; + fetchMock.mockReset(); + serveCapture(lines(10), 24); +}); + +afterEach(() => { + for (const tile of liveTiles.splice(0)) tile.destroy(); + FakeTerminal.emulateScroll = false; + vi.useRealTimers(); +}); + +describe('a tile pages a hollow buffer through the primary pane gates', () => { + it('turns half a screen of wheel-up into one ephemeral PageUp on its own socket, and down into PageDown', async () => { + const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + expect(term.buffer.active.baseY).toBe(0); + + const up = wheelLines(-term.rows / 2); + mount.fire('wheel', up); + + expect(up.preventDefault).toHaveBeenCalled(); + expect(up.stopPropagation).toHaveBeenCalled(); + expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); // no seq: never persisted + + mount.fire('wheel', wheelLines(term.rows / 2)); + expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: PAGE_DOWN }); + }); + + it("reads the TILE's session, not the active one", async () => { + // Active session is a shell; the tile shows opencode: the tile still pages. + const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode' } }, 'other'); + const { ws, mount } = await connectTile(app); + + mount.fire('wheel', wheelLines(-12)); + + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + + it.each(['shell', 'codex', 'antigravity'])( + 'leaves a %s tile to xterm even while the active session is opencode', + async (mode) => { + const app = makeApp({ other: { mode: 'opencode' }, 's-tile': { mode } }, 'other'); + const { ws, mount } = await connectTile(app, { mode }); + + const ev = wheelLines(-12); + mount.fire('wheel', ev); + + expect(ev.preventDefault).not.toHaveBeenCalled(); + expect(ev.stopPropagation).not.toHaveBeenCalled(); + expect(flushed(ws)).toEqual([]); + } + ); + + it('still pulls history on a wheel-up at the top of a shell tile', async () => { + const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'shell' } }), { mode: 'shell' }); + expect(fetchMock).toHaveBeenCalledTimes(1); // the initial load + + mount.fire('wheel', wheelLines(-3)); + await vi.advanceTimersByTimeAsync(0); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[1][0])).toContain('full=1&tail='); + }); + + it('never pages Shift, a tracking xterm, the alternate buffer, real history or a horizontal swipe', async () => { + const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + const fireAndCheck = (ev: ReturnType<typeof wheel>) => { + mount.fire('wheel', ev); + expect(ev.preventDefault).not.toHaveBeenCalled(); + expect(ev.stopPropagation).not.toHaveBeenCalled(); + }; + + fireAndCheck(wheelLines(-12, { shiftKey: true })); // the explicit "local scrollback" gesture + + term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel + fireAndCheck(wheelLines(-12)); + term.modes.mouseTrackingMode = 'none'; + + term.buffer.active.type = 'alternate'; // xterm's alt-scroll owns it + fireAndCheck(wheelLines(-12)); + term.buffer.active.type = 'normal'; + + fireAndCheck(wheel(0, { deltaX: 120 })); // pure horizontal: nothing to page + + term.write(lines(40)); // real output scrolled real lines above the screen + expect(term.buffer.active.baseY).toBeGreaterThan(0); + fireAndCheck(wheelLines(-12)); + + expect(flushed(ws)).toEqual([]); + }); +}); + +describe("the forwarding gate behaves as in the primary pane, for the tile's session", () => { + it('leaves a fullscreen Claude tile to xterm (tiles do not forward SGR wheel yet)', async () => { + const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } }); + const { ws, mount } = await connectTile(app, { mode: 'claude' }); + + const ev = wheelLines(-12); + mount.fire('wheel', ev); + + expect(ev.preventDefault).not.toHaveBeenCalled(); + expect(flushed(ws)).toEqual([]); + }); + + it('pages that same tile under the "Wheel scrolls local history" opt-out (the footgun rescue)', async () => { + const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } }); + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); + const { ws, mount } = await connectTile(app, { mode: 'claude' }); + + mount.fire('wheel', wheelLines(-12)); + + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + + it('pages a Claude tile whose CLI version is unknown', async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude', cliMouseTracking: true } }), { + mode: 'claude', + }); + + mount.fire('wheel', wheelLines(-12)); + + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); +}); + +describe('page-key travel, cap and coalescing', () => { + beforeEach(() => { + FakeFit.proposed = { cols: 80, rows: 36 }; + }); + + it('accumulates sub-page travel: two wheels of 10 lines are one PageUp', async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + + mount.fire('wheel', wheelLines(-10)); + expect(flushed(ws)).toEqual([]); + mount.fire('wheel', wheelLines(-10)); + + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + + it('caps one wheel at three PageUps, in one frame', async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + + mount.fire('wheel', wheelLines(-1000)); + + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP.repeat(3) }]); + }); + + it('sends two pages queued within 40 ms as one frame', async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + + mount.fire('wheel', wheelLines(-18)); + vi.advanceTimersByTime(20); + mount.fire('wheel', wheelLines(-18)); + + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP.repeat(2) }]); + }); + + it('sends exactly the bytes the primary pane sends for the same wheel sequence', async () => { + const sequence = [-10, -10, -4, 30, -1000, 7, -18, 200].map((n) => wheelLines(n)); + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + for (const ev of sequence) mount.fire('wheel', ev); + const tileBytes = flushed(ws) + .map((f) => f.d) + .join(''); + + const primary = makeApp({ other: { mode: 'opencode' } }, 'other') as App & { + terminal: unknown; + _maybePageCliTranscript(ev: unknown, lines: number): boolean; + _wheelScrollLinesFloat(ev: unknown): number; + _flushWheelSgrQueue(): void; + }; + const sent: string[] = []; + primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data); + primary.terminal = { + rows: 36, + modes: { mouseTrackingMode: 'none' }, + buffer: { active: { type: 'normal', baseY: 0, viewportY: 0 } }, + }; + for (const ev of sequence) primary._maybePageCliTranscript(ev, primary._wheelScrollLinesFloat(ev)); + primary._flushWheelSgrQueue(); + + expect(tileBytes).not.toBe(''); + expect(tileBytes).toBe(sent.join('')); + }); +}); + +describe('rows the tile pushed above the screen itself are not history', () => { + it('pages after a one-screen capture taken at a taller size (the first load)', async () => { + serveCapture(lines(40), 40); // the PTY was 40 rows when it was captured + const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + expect(term.buffer.active.baseY).toBe(16); // 40 lines into a 24-row xterm + + mount.fire('wheel', wheelLines(-12)); + + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + + it('does not page a capture that carried real history', async () => { + serveCapture(lines(60), 24); + const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + expect(term.buffer.active.baseY).toBeGreaterThan(0); + + const ev = wheelLines(-12); + mount.fire('wheel', ev); + + expect(ev.preventDefault).not.toHaveBeenCalled(); + expect(flushed(ws)).toEqual([]); + }); + + it('falls back to the raw baseY when the server sent no captureRows', async () => { + serveCapture(lines(40)); + const tall = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + expect(tall.term.buffer.active.baseY).toBe(16); + tall.mount.fire('wheel', wheelLines(-12)); + expect(flushed(tall.ws)).toEqual([]); + + serveCapture(lines(10)); + const short = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + expect(short.term.buffer.active.baseY).toBe(0); + short.mount.fire('wheel', wheelLines(-12)); + expect(flushed(short.ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + + it('keeps paging after a row-shrinking fit pushes more rows up', async () => { + serveCapture(lines(40), 40); + const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + + FakeFit.proposed = { cols: 80, rows: 20 }; // a zoom-out or a divider drag + tile.fit(); + expect(term.buffer.active.baseY).toBe(20); + + mount.fire('wheel', wheelLines(-10)); + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + + it('stops paging once output scrolls real lines above the screen', async () => { + serveCapture(lines(40), 40); + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + + ws.receive({ t: 'o', d: 'a\r\nb\r\nc\r\n' }); // three real lines scrolled off + + const ev = wheelLines(-12); + mount.fire('wheel', ev); + expect(ev.preventDefault).not.toHaveBeenCalled(); + expect(flushed(ws)).toEqual([]); + }); + + it('forgets the overflow on a server clear, so later real history counts', async () => { + serveCapture(lines(40), 40); + const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + + ws.receive({ t: 'c' }); + expect(term.buffer.active.baseY).toBe(0); + ws.receive({ t: 'o', d: '\r\n'.repeat(term.rows + 1) }); // two real lines above the screen + expect(term.buffer.active.baseY).toBe(2); + + const ev = wheelLines(-12); + mount.fire('wheel', ev); + expect(ev.preventDefault).not.toHaveBeenCalled(); + expect(flushed(ws)).toEqual([]); + }); + + it('keeps paging when the PTY geometry report reflows the rows above the screen', async () => { + serveCapture(lines(40), 40); + const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + // A narrower width wraps the overflow rows onto more rows, as xterm's reflow does. + term.afterResize = () => { + term.lineCount += 3; + term.settleRows(); + }; + + tile._onPtyGeometryReport(60, 40); + expect(term.cols).toBe(60); + expect(term.buffer.active.baseY).toBe(19); + + mount.fire('wheel', wheelLines(-12)); + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); +}); + +describe('a tile reports a plain click to a CLI with mouse tracking on', () => { + const click = (overrides: Record<string, unknown> = {}) => ({ + isTrusted: true, + button: 0, + detail: 1, + // 10 + 8 * 20 + 1, 20 + 16 * 5 + 1 inside the tile's own screen: column 21, row 6. + clientX: 171, + clientY: 101, + target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) }, + ...overrides, + }); + const TAP = '\x1b[<0;21;6M\x1b[<0;21;6m'; + + it("sends one seq-tagged SGR press+release on the tile's socket, from the tile's own geometry", async () => { + const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode', cliMouseTracking: true } }); + const { ws, mount } = await connectTile(app); + + mount.fire('click', click()); + + expect(ws.inputFrames().map((f) => [f.d, typeof f.seq])).toEqual([[TAP, 'number']]); + }); + + it('sends nothing without the flag, over a selection, over its own hovered link, or scrolled up', async () => { + const app = makeApp({ 's-tile': { mode: 'opencode' } }); + const { tile, ws, term, mount } = await connectTile(app); + + mount.fire('click', click()); // the CLI has no tracking mode on + app.sessions.set('s-tile', { mode: 'opencode', cliMouseTracking: true }); + + term.selection = 'picked'; + mount.fire('click', click()); // a drag-selection just ended + term.selection = ''; + + tile._linkHovered = true; + mount.fire('click', click()); // the link provider opens this one + tile._linkHovered = false; + + term.buffer.active.baseY = 10; + term.buffer.active.viewportY = 0; + mount.fire('click', click()); // would hit-test a different row + term.buffer.active.viewportY = 10; + + expect(ws.inputFrames()).toEqual([]); + + mount.fire('click', click()); // nothing in the way any more + expect(ws.inputFrames().map((f) => f.d)).toEqual([TAP]); + }); + + it("is not blocked by the primary pane's own link hover", async () => { + const app = makeApp({ 's-tile': { mode: 'opencode', cliMouseTracking: true } }); + app._linkHovered = true; + const { ws, mount } = await connectTile(app); + + mount.fire('click', click()); + + expect(ws.inputFrames().map((f) => f.d)).toEqual([TAP]); + }); + + it("follows the tile session's flag, never the active session's", async () => { + const reported = await connectTile( + makeApp({ + other: { mode: 'claude', cliMouseTracking: false }, + 's-tile': { mode: 'opencode', cliMouseTracking: true }, + }) + ); + reported.mount.fire('click', click()); + expect(reported.ws.inputFrames().map((f) => f.d)).toEqual([TAP]); + + const silent = await connectTile( + makeApp({ + other: { mode: 'claude', cliMouseTracking: true }, + 's-tile': { mode: 'opencode', cliMouseTracking: false }, + }) + ); + silent.mount.fire('click', click()); + expect(silent.ws.inputFrames()).toEqual([]); + }); +}); + +describe('destroy()', () => { + it('drops queued page keys and detaches both listeners it registered', async () => { + const { tile, ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + mount.fire('wheel', wheelLines(-12)); // queued, not yet flushed + const wheelFn = mount.listeners.wheel[0].fn; + const clickFn = mount.listeners.click[0].fn; + + tile.destroy(); + vi.advanceTimersByTime(100); + + expect(ws.inputFrames()).toEqual([]); + expect(tile._scrollFlushTimer).toBeNull(); + expect(mount.removeEventListener).toHaveBeenCalledWith('wheel', wheelFn, { capture: true }); + expect(mount.removeEventListener).toHaveBeenCalledWith('click', clickFn); + }); + + it('registers the wheel listener non-passive in the capture phase and the click one in the bubble phase', async () => { + const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + + expect(mount.listeners.wheel.map((l) => l.opts)).toEqual([{ capture: true, passive: false }]); + expect(mount.listeners.click.map((l) => l.opts)).toEqual([undefined]); + }); +}); diff --git a/test/terminal-tile-unit.test.ts b/test/terminal-tile-unit.test.ts index 5606abf2..8eb28d48 100644 --- a/test/terminal-tile-unit.test.ts +++ b/test/terminal-tile-unit.test.ts @@ -65,6 +65,8 @@ type PaneUnderTest = { _onLiveOutput(data: string): void; _onLiveClear(): void; _installWheelListener(): void; + _installClickListener(): void; + _onClick: unknown; _writeDisconnectedMarker(): void; _onSocketClosed(): void; }; @@ -763,9 +765,13 @@ describe('TerminalTile scroll-to-top history pull', () => { // Capture phase: xterm's own wheel handler stopPropagation()s the events it // consumes, so a bubbling listener would never fire while the pane still has // scrollback to scroll, and the pull would work only from the exact top row. + // Not passive: the hollow-buffer paging route consumes its wheel right here + // (test/terminal-tile-scroll.test.ts). This stub window has neither the + // shared paging helpers nor the app's gates, so that route stays inert and + // every wheel below falls through to the pull, as before. const [type, listener, options] = mount.addEventListener.mock.calls[0]; expect(type).toBe('wheel'); - expect(options).toEqual({ capture: true, passive: true }); + expect(options).toEqual({ capture: true, passive: false }); listener({ deltaY: 120 }); // wheel down listener({ deltaY: 0 }); @@ -789,6 +795,22 @@ describe('TerminalTile scroll-to-top history pull', () => { expect(pane._onWheel).toBeNull(); }); + it('destroy() detaches exactly the click listener it registered', () => { + const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() }; + const pane = makePane('opencode', mount); + pane._installWheelListener(); + pane._installClickListener(); + const [type, registered, options] = mount.addEventListener.mock.calls[1]; + // Bubble phase, like the primary pane's click reporter (terminal-ui.js). + expect(type).toBe('click'); + expect(options).toBeUndefined(); + + pane.destroy(); + + expect(mount.removeEventListener).toHaveBeenCalledWith('click', registered); + expect(pane._onClick).toBeNull(); + }); + it('connect() installs the wheel listener (static guard)', () => { // connect() needs a whole xterm to run, so its wiring is pinned by source // rather than executed; the listener's behaviour is exercised above. @@ -800,6 +822,7 @@ describe('TerminalTile scroll-to-top history pull', () => { expect(end).toBeGreaterThan(start); const connect = SOURCE.slice(start, end); expect(connect).toContain('this._installWheelListener();'); + expect(connect).toContain('this._installClickListener();'); expect(connect).toContain('this._onLiveClear();'); expect(connect).not.toContain('this.terminal.clear();'); // The tests below drive the close through _onSocketClosed() directly; the From 312a8faa06265b80e3eb379a78d8136ef009ff75 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 08:20:02 +0200 Subject: [PATCH 3/6] fix(tiles): wire the Android soft-keyboard controller into every tile (#541 parity) #541 fixed Android autocorrect duplicating the typed line in the primary pane: xterm's keyCode-229 textarea diff is append-only, so an autocorrect on space (delete a word, insert the corrected one) sent the whole line again. The fix, an edit-based diff that sends one DEL per deleted code point and then the inserted text, lives in terminal-keycode229-recovery.js together with #441's next-keydown drain (a character committed in the same task as Enter goes out ahead of the \r) and the original orphaned-insertText recovery. Only the primary pane created that controller, so a grid tile or the split's Pane B still ran xterm's stock behaviour. Both are gated on width alone (1180 CSS px), which a wide Android tablet clears. TerminalTile now creates its own controller in connect(), after the xterm opens and before the first await, handed this tile's textarea, this tile's CompositionHelper and _onTerminalData as the send path, so recovered bytes go to the tile's own session through the exactly-once queue. As in the primary pane, handleKeyEvent runs first in the custom key handler, above the keyCode-229 early return, and notifyCanonicalData sits in the onData lambda, gated on the same two CodemanTerminalInput predicates, never in _onTerminalData, which the recovered bytes also take. destroy() tears the controller down before disposing the xterm, which restores xterm's own diff and removes the capture listeners. No mode or device gate, matching the primary. The module itself is unchanged apart from its header; terminal-ui.js gains only a comment naming the twin. Tests: test/terminal-tile-input.test.ts now loads the real module into its vm harness (with window timers, without which create() would silently throw and every test would run against no controller) and drives a fake CompositionHelper carrying xterm's own append-only diff. It covers install and restore on the tile's own helper and textarea, autocorrect sent as an edit (with a control reproducing the device-log duplicate), the last character and an autocorrect each followed by Enter in one task, a self-rescued 229 key delivered once, the onData gate ignoring query replies and focus reports, two refused inserts after one keydown both recovered, robustness when the controller throws, per-tile controllers, and a source pin keeping the call above the early return. Removing the create, the handleKeyEvent call, the notify, its gate, or the destroy each turns at least one of them red, as does moving the notify into _onTerminalData. The browser suite gains a TerminalTile block in test/terminal-keycode229-recovery.browser.test.ts (real xterm, trusted execCommand input, chunks asserted to address the tile's session, with a destroyed-controller control). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 4 +- docs/architecture-invariants.md | 2 +- docs/split-pane-sessions-plan.md | 11 +- docs/tile-grid-plan.md | 6 +- .../public/terminal-keycode229-recovery.js | 5 +- src/web/public/terminal-tile.js | 94 +++- src/web/public/terminal-ui.js | 3 + test/mocks/terminal-tile-fakes.ts | 31 +- ...rminal-keycode229-recovery.browser.test.ts | 192 +++++++- test/terminal-tile-input.test.ts | 419 +++++++++++++++++- 10 files changed, 745 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4ab21163..f142a8f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,7 +276,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) +**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) **Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE with the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) of `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order, the active one focused; trimmed or filled by `tileGridSetForCount`, a stored grid re-formed into its cells by `reformTileCells`), never a menu; right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot <cliId>` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) @@ -327,7 +327,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 87e6a360..233632b3 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -801,7 +801,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ### 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 an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile; see terminal-tile.js's fileoverview) — 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 below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), 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 `TerminalTile` 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 the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. 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'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) 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/Ctrl+Shift+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+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell straight to `return true`, silently ceding the keystroke to the BROWSER's own handling (e.g. Chrome's Inspect-Element binding) with no feedback and no copy attempt — Pane A always intercepts it. Ctrl+V goes through the primary pane's paste trap aimed at Pane B (`_handleImagePaste({ terminal, sessionId })`), so a pasted image uploads to Pane B's session. ⚠️ `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 keystrokes go through the exactly-once queue (`_sendInputAsync`) over its own socket, registered in the app's input-socket map (`_registerInputSocket` / `_inputSocketFor`), so they are seq-tagged, ACKed (`{t:'ia'}` is routed by the RECEIVING socket's session, since the frame names none), redelivered after a drop, and the ACK clears the idle alert. ⚠️ What xterm generates never enters that persisted queue: query replies are dropped (`shouldSuppressTerminalQueryResponse`, as the primary pane drops them) and focus/mouse reports go through `_sendInputEphemeral`, or a reload would replay them as typed text. ⚠️ A SHELL Pane B pulls scrollback itself when the wheel goes up at the top of its buffer (`_maybeLoadMoreHistory`/`_pullHistory`): tmux repaints a burst of output instead of scrolling it, so Pane B's own xterm holds about one screen of scrollback while tmux holds every line, and it loaded history exactly once at connect and never again. It is the same bounded pull as Pane A's (`?full=1&tail=TERMINAL_TAIL_SIZE`, no rewrite when the window holds no more rows than the pane already has or the pane is at its `scrollback + rows` cap, and a 60 s back-off instead of 4 s when that skipped window was truncated or the pane is full, since each ask costs the server a whole-history `capture-pane`), against Pane B's OWN terminal rather than `app.terminal`, so it cannot share `_maybeRefetchFullHistory`. The wheel listener is capture-phase because xterm `stopPropagation()`s the events it consumes; the alternate-screen skip (nano, vim, less) only matters for a direct-PTY shell, since under tmux the browser xterm never enters the alternate buffer; skipped too for a detached session (mirrors `_sendResize()`'s own check and app.js's `_maybeRefetchFullHistory`), since its own window already owns its PTY size and scrollback. Live frames arriving from the response onward, a `{t:'c'}` clear frame included, are held with their arrival time (`_liveQueue`, opened right after `await fetch(...)` beside `capturedAt`; a frame from before it is replaced by the capture or written unchanged, so the pane keeps painting during the round trip) and replayed in order only if they arrived after the capture (the response's arrival stands in for the capture instant, as in `_finishBufferLoad`, so a frame inside that one round trip can be lost or doubled); the request uses the primary pane's budget (`CodemanFetchDeadline.terminalFetchDeadlineMs({ full: true })`, 45 s, 10 s only if that helper is absent) and the body read gets 10 s once the headers land, because from then on the pull holds the pane's live output. The request phase holds no live output, but it does hold the single-flight flag, so a coalesced `{t:'r'}` refresh and a marker owed by a close (below) wait for the response, at worst for that whole budget (accepted: a Codeman restart resets an in-flight request along with the socket, so that pull fails at once and stamps the marker). ⚠️ The "disconnected" marker must be the LAST thing on screen. A replay's own `\x1bc` would otherwise wipe a marker written before the pull and paint a fresh, current-looking history while `onData` keeps silently dropping every keystroke on the dead socket (a Codeman restart drops the socket while the tmux session, and so the HTTP pull, still succeeds), so `_pullHistory()` re-stamps it after the live-frame flush. A close DURING any load (a pull, a refresh; `connect()` awaits the initial load before it creates the socket, so no close lands in that one) writes nothing: `_onSocketClosed()` sets `_markerOwed` while a load's work runs (`_loadRunning`; a load that only waits in the tile grid's queue writes the marker at once, and its refresh clears the screen only when its turn comes), since written there it would sit above the held frames the pull flushes after a skip, a downgrade, a failed fetch or the deadline, above a refresh's replay, or land mid-way through a chunked replay. Each load settles the marker in its OWN `finally` (`_stampMarkerIfOwed()`), after the queue flush, EXCEPT when a trailing refresh is pending: that refresh's `clear()` is synchronous while xterm parses a `write()` on a later tick, so a marker stamped just before it lands in the freshly cleared buffer ABOVE the refresh's replay (a second, stale copy; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously and cannot show it, so an async-parse fake there pins it). The marker stays owed instead, and the trailing refresh, which re-owes it on a closed socket anyway, writes the one copy below its own replay. Anything that wipes the terminal on a closed socket (a replay's `\x1bc`, a refresh's `clear()`) sets `_markerOwed` too, so the marker is rewritten whether or not the close landed during the load. Tracked via `_wsClosed`/`_markerOwed` rather than routed through `_onLiveOutput()`, since a close landing before the response is stamped before the cutoff and would be dropped with the rest of the pre-capture queue. There is no "Load full history" banner in Pane B, so a shell history past that 1 MiB window stays out of reach there. Non-shell Pane B is unchanged: it already loads `full=1`, and its history is out of scope for this pull (codex and Claude's inline renderer do grow tmux history; this just isn't how they recover it). ⚠️ App-level terminal actions find their pane through `_focusedPane()`, the terminal focused LAST (not `document.activeElement`, which the mic or a header button takes): Ctrl+L, Ctrl+Shift+R, voice and image paste act on Pane B while it holds the keyboard. Ctrl+W is not an app shortcut at all (Close Session has no default key), so it reaches whichever pane is focused as delete-word. ⚠️ Geometry: `TerminalTile.fit()` sends the size the xterm actually holds, unfloored (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, re-sends on every fresh socket, and adopts the PTY's column count from `{t:'zc'}`; the font, family and weight setters call `tile.fit()`, never a local refit alone (#464). Every capture a pane fetches carries a deadline covering the body (`CodemanFetchDeadline`), so a capture that never answers cannot hold its single-flight flag forever. Design: `docs/split-pane-sessions-plan.md`; the tile class: `docs/tile-grid-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 an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile, and its own keyCode-229 soft-keyboard controller from terminal-keycode229-recovery.js, the #441 next-keydown drain and #541's edit-based diff for Android autocorrect, since the 1180px width gate is reachable by a wide Android tablet; see terminal-tile.js's fileoverview) — 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 below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), 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 `TerminalTile` 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 the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. 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'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) 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/Ctrl+Shift+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+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell straight to `return true`, silently ceding the keystroke to the BROWSER's own handling (e.g. Chrome's Inspect-Element binding) with no feedback and no copy attempt — Pane A always intercepts it. Ctrl+V goes through the primary pane's paste trap aimed at Pane B (`_handleImagePaste({ terminal, sessionId })`), so a pasted image uploads to Pane B's session. ⚠️ `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 keystrokes go through the exactly-once queue (`_sendInputAsync`) over its own socket, registered in the app's input-socket map (`_registerInputSocket` / `_inputSocketFor`), so they are seq-tagged, ACKed (`{t:'ia'}` is routed by the RECEIVING socket's session, since the frame names none), redelivered after a drop, and the ACK clears the idle alert. ⚠️ What xterm generates never enters that persisted queue: query replies are dropped (`shouldSuppressTerminalQueryResponse`, as the primary pane drops them) and focus/mouse reports go through `_sendInputEphemeral`, or a reload would replay them as typed text. ⚠️ A SHELL Pane B pulls scrollback itself when the wheel goes up at the top of its buffer (`_maybeLoadMoreHistory`/`_pullHistory`): tmux repaints a burst of output instead of scrolling it, so Pane B's own xterm holds about one screen of scrollback while tmux holds every line, and it loaded history exactly once at connect and never again. It is the same bounded pull as Pane A's (`?full=1&tail=TERMINAL_TAIL_SIZE`, no rewrite when the window holds no more rows than the pane already has or the pane is at its `scrollback + rows` cap, and a 60 s back-off instead of 4 s when that skipped window was truncated or the pane is full, since each ask costs the server a whole-history `capture-pane`), against Pane B's OWN terminal rather than `app.terminal`, so it cannot share `_maybeRefetchFullHistory`. The wheel listener is capture-phase because xterm `stopPropagation()`s the events it consumes; the alternate-screen skip (nano, vim, less) only matters for a direct-PTY shell, since under tmux the browser xterm never enters the alternate buffer; skipped too for a detached session (mirrors `_sendResize()`'s own check and app.js's `_maybeRefetchFullHistory`), since its own window already owns its PTY size and scrollback. Live frames arriving from the response onward, a `{t:'c'}` clear frame included, are held with their arrival time (`_liveQueue`, opened right after `await fetch(...)` beside `capturedAt`; a frame from before it is replaced by the capture or written unchanged, so the pane keeps painting during the round trip) and replayed in order only if they arrived after the capture (the response's arrival stands in for the capture instant, as in `_finishBufferLoad`, so a frame inside that one round trip can be lost or doubled); the request uses the primary pane's budget (`CodemanFetchDeadline.terminalFetchDeadlineMs({ full: true })`, 45 s, 10 s only if that helper is absent) and the body read gets 10 s once the headers land, because from then on the pull holds the pane's live output. The request phase holds no live output, but it does hold the single-flight flag, so a coalesced `{t:'r'}` refresh and a marker owed by a close (below) wait for the response, at worst for that whole budget (accepted: a Codeman restart resets an in-flight request along with the socket, so that pull fails at once and stamps the marker). ⚠️ The "disconnected" marker must be the LAST thing on screen. A replay's own `\x1bc` would otherwise wipe a marker written before the pull and paint a fresh, current-looking history while `onData` keeps silently dropping every keystroke on the dead socket (a Codeman restart drops the socket while the tmux session, and so the HTTP pull, still succeeds), so `_pullHistory()` re-stamps it after the live-frame flush. A close DURING any load (a pull, a refresh; `connect()` awaits the initial load before it creates the socket, so no close lands in that one) writes nothing: `_onSocketClosed()` sets `_markerOwed` while a load's work runs (`_loadRunning`; a load that only waits in the tile grid's queue writes the marker at once, and its refresh clears the screen only when its turn comes), since written there it would sit above the held frames the pull flushes after a skip, a downgrade, a failed fetch or the deadline, above a refresh's replay, or land mid-way through a chunked replay. Each load settles the marker in its OWN `finally` (`_stampMarkerIfOwed()`), after the queue flush, EXCEPT when a trailing refresh is pending: that refresh's `clear()` is synchronous while xterm parses a `write()` on a later tick, so a marker stamped just before it lands in the freshly cleared buffer ABOVE the refresh's replay (a second, stale copy; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously and cannot show it, so an async-parse fake there pins it). The marker stays owed instead, and the trailing refresh, which re-owes it on a closed socket anyway, writes the one copy below its own replay. Anything that wipes the terminal on a closed socket (a replay's `\x1bc`, a refresh's `clear()`) sets `_markerOwed` too, so the marker is rewritten whether or not the close landed during the load. Tracked via `_wsClosed`/`_markerOwed` rather than routed through `_onLiveOutput()`, since a close landing before the response is stamped before the cutoff and would be dropped with the rest of the pre-capture queue. There is no "Load full history" banner in Pane B, so a shell history past that 1 MiB window stays out of reach there. Non-shell Pane B is unchanged: it already loads `full=1`, and its history is out of scope for this pull (codex and Claude's inline renderer do grow tmux history; this just isn't how they recover it). ⚠️ App-level terminal actions find their pane through `_focusedPane()`, the terminal focused LAST (not `document.activeElement`, which the mic or a header button takes): Ctrl+L, Ctrl+Shift+R, voice and image paste act on Pane B while it holds the keyboard. Ctrl+W is not an app shortcut at all (Close Session has no default key), so it reaches whichever pane is focused as delete-word. ⚠️ Geometry: `TerminalTile.fit()` sends the size the xterm actually holds, unfloored (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, re-sends on every fresh socket, and adopts the PTY's column count from `{t:'zc'}`; the font, family and weight setters call `tile.fit()`, never a local refit alone (#464). Every capture a pane fetches carries a deadline covering the body (`CodemanFetchDeadline`), so a capture that never answers cannot hold its single-flight flag forever. Design: `docs/split-pane-sessions-plan.md`; the tile class: `docs/tile-grid-plan.md`. ### Tile grid diff --git a/docs/split-pane-sessions-plan.md b/docs/split-pane-sessions-plan.md index 6b3259e1..19b1f379 100644 --- a/docs/split-pane-sessions-plan.md +++ b/docs/split-pane-sessions-plan.md @@ -93,9 +93,14 @@ view needs a wide viewport). So: instance with no overlay is exactly how Codeman behaved before the local- echo overlay existed for touch devices — normal, not degraded, for a keyboard-and-mouse user. (Since moved to `TerminalTile`, terminal-tile.js, - which has gained two pieces of the primary pane through its own gates aimed - at the tile: hollow-buffer wheel paging (#555) and the desktop click report - for a CLI with `cliMouseTracking` on. See that file's fileoverview.) + which has gained three pieces of the primary pane: hollow-buffer wheel + paging (#555) and the desktop click report for a CLI with + `cliMouseTracking` on, both through the primary pane's gates aimed at the + tile, and its own keyCode-229 soft-keyboard controller + (terminal-keycode229-recovery.js: the #441 next-keydown drain and #541's + edit-based diff, so an Android autocorrect is not sent twice). The 1180px + width gate is all that keeps a phone out, and a wide Android tablet clears + it. See that file's fileoverview.) If this asymmetry actually bothers you in daily use, promoting Pane B to full parity is a scoped v2 (extract the shared logic already once you have two diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index de6612f3..da30d020 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -224,7 +224,11 @@ no `+`, owner decision 9). mouse-wheel forwarding to Claude's fullscreen renderer, the "Load full history" banner. These exist for touch devices or rare cases; a desktop keyboard user types straight into xterm, which is how Codeman behaved before - those features existed. + those features existed. (One exception, since the 1180 px gate is width + only and a wide Android tablet clears it: every tile wires the main + terminal's keyCode-229 soft-keyboard controller, terminal-keycode229-recovery.js, + so an Android autocorrect is sent as an edit rather than a duplicated line, + #541, and a character committed with Enter is not lost, #441.) - Server-side persistence of grids (named presets per owner). - Pop-out windows (`/session/:id`, solo mode) showing a grid. diff --git a/src/web/public/terminal-keycode229-recovery.js b/src/web/public/terminal-keycode229-recovery.js index c6d36f0f..22e44cba 100644 --- a/src/web/public/terminal-keycode229-recovery.js +++ b/src/web/public/terminal-keycode229-recovery.js @@ -42,8 +42,9 @@ * also be a CAPTURE listener; see the measured table at the addEventListener * call below. * - * @dependency none (standalone IIFE; consumed by terminal-ui.js) - * @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller) + * @dependency none (standalone IIFE; consumed by terminal-ui.js for the primary pane and by + * terminal-tile.js for every grid tile and the split's Pane B, one controller per xterm) + * @loadorder 5.55 (before app.js/terminal-ui.js/terminal-tile.js, which create controllers) */ (function (global) { 'use strict'; diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 243d4d97..39d04ee0 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -10,7 +10,7 @@ * synchronous tmux call that blocks the server's event loop. * * Deliberately plainer than the primary pane (this.terminal/this._ws in - * terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile + * terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile * handlers (a swipe on a touch screen pages nothing), no keyboard accessory * bar, and no SGR wheel forwarding to Claude's fullscreen renderer * (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see @@ -28,10 +28,21 @@ * - The desktop click report: a plain left-click hand-encoded as SGR while * the session's CLI has mouse tracking on (cliMouseTracking), for the modes * whose mouse DECSETs the server strips (_installClickListener). + * - The soft-keyboard controller (terminal-keycode229-recovery.js), one per + * pane, on this pane's own textarea and composition helper and sending to + * this pane's session (_createKeyCode229Recovery): it forwards an + * `insertText` xterm refused, settles a pending textarea edit at the next + * keydown ahead of that key (#441: the last character an Android keyboard + * commits in the same task as Enter), and replaces xterm's append-only + * keyCode-229 diff with an edit-based one (#541: autocorrect on space + * duplicated the line). Not a desktop-only concern: the grid and the split + * are gated on width alone (SPLIT_PANE_MIN_WIDTH, 1180 CSS px), which a wide + * Android tablet, or a large foldable unfolded in landscape, reaches. * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) - * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_handleDesktopTerminalClick) + * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_handleDesktopTerminalClick) + * @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js */ @@ -185,6 +196,9 @@ this._overflowRows = 0; // The desktop click reporter (_installClickListener). this._onClick = null; + // The soft-keyboard controller (terminal-keycode229-recovery.js): created + // in connect() once the xterm is open, torn down in destroy(). + this._keyCode229Recovery = null; } async connect() { @@ -227,7 +241,26 @@ this._onFocusIn = () => global.app?._noteFocusedTile?.(this); this.terminal.textarea?.addEventListener('focus', this._onFocusIn); - this.terminal.onData((data) => this._onTerminalData(data)); + this._createKeyCode229Recovery(); + // The twin of terminal-ui.js's onData gate (initTerminal; keep the two in + // step). Canonical xterm data tells the controller this keystroke was + // delivered, but not a query reply or a focus/mouse report, which xterm + // emits on its own and which would otherwise stand a pending recovery + // down. The notify lives HERE and not in _onTerminalData(): the + // controller's own recovered bytes go through _onTerminalData() too, and + // must never count as xterm's, or a second pending character from the + // same keystroke window would stand down and be lost. + this.terminal.onData((data) => { + try { + const input = global.CodemanTerminalInput; + if (!input?.shouldSuppressTerminalQueryResponse?.(data) && !input?.isTerminalFocusOrMouseReport?.(data)) { + this._keyCode229Recovery?.notifyCanonicalData?.(); + } + } catch { + /* Bookkeeping must never block real input. */ + } + this._onTerminalData(data); + }); // xterm has no gates of its own, so every app-level chord that the // document capture-phase handler (app.js) only preventDefault()s (never @@ -242,6 +275,19 @@ // here too. Ctrl+V goes through the primary pane's paste trap // (image-input.js), aimed at this pane (below). this.terminal.attachCustomKeyEventHandler((ev) => { + // FIRST, above the IME early return below, as in terminal-ui.js: every + // keydown settles this pane's pending textarea edit and drains a + // pending recovery BEFORE xterm handles the key, so a character an + // Android keyboard committed in the same task as Enter is sent ahead + // of the \r. Below that return a keyCode-229 keydown would skip the + // settle, the drain and the snapshot, and the panes would differ. + // Read at call time, never captured, so the controller can be swapped + // (the tests count xterm's emissions through it). + try { + this._keyCode229Recovery?.handleKeyEvent?.(ev); + } catch { + /* The controller must never interfere with xterm's own handling. */ + } if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true; if ( ev.altKey && @@ -552,6 +598,44 @@ app?._sendInputAsync?.(this.sessionId, data); } + // The soft-keyboard controller, the twin of the primary pane's wiring in + // terminal-ui.js initTerminal() (keep the two in step); the behaviour lives + // once, in terminal-keycode229-recovery.js. Everything it is handed is THIS + // pane's: its textarea, its xterm's CompositionHelper (whose + // `_handleAnyTextareaChanges` it patches, per instance) and its send path. + // Recovered text goes straight to _onTerminalData(), never through xterm's + // onData, so it is not counted as xterm's own (see connect()'s onData). + // Created after terminal.open(): xterm's capture `input` listener on the + // textarea is registered there, and must run before the controller's. No + // device or mode gate, as in the primary pane: with a hardware keyboard it + // costs one assignment per keydown. A failure leaves xterm's own handling. + _createKeyCode229Recovery() { + this._destroyKeyCode229Recovery(); + if (!this.terminal) return; + try { + this._keyCode229Recovery = + global.CodemanKeyCode229Recovery?.create?.({ + textarea: this.terminal.textarea, + emitRecovered: (data) => this._onTerminalData(data), + getCompositionHelper: () => this.terminal?._core?._compositionHelper, + isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true, + }) ?? null; + } catch { + this._keyCode229Recovery = null; + } + } + + // Restores xterm's own textarea diff and removes the controller's capture + // listeners from the live textarea, so it runs before terminal.dispose(). + _destroyKeyCode229Recovery() { + try { + this._keyCode229Recovery?.destroy?.(); + } catch { + /* Optional; teardown must continue. */ + } + this._keyCode229Recovery = null; + } + // Joins the app's input-socket map for this session and flushes anything // already queued for it (typed while the socket was down, or left over from // a reload) over the fresh socket. Called from onopen. @@ -1154,6 +1238,10 @@ this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn); this._onFocusIn = null; } + // Before dispose(): puts xterm's own textarea diff back and takes the + // controller's listeners off the textarea; its pending timers are inert + // once it is destroyed. + this._destroyKeyCode229Recovery(); // A destroyed pane cannot hold the keyboard: shortcuts fall back to the // primary terminal (_focusedPane also skips a destroyed tile on its own). if (global.app?._focusedTile === this) global.app._noteFocusedTile?.(null); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 403bcc44..92d31442 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1838,6 +1838,9 @@ Object.assign(CodemanApp.prototype, { // registers its own listener with `capture: true`; on bubble xterm's // `cancel()` (stopPropagation) would swallow exactly the handled events — // see the measured table in terminal-keycode229-recovery.js. + // Twin: TerminalTile (terminal-tile.js _createKeyCode229Recovery and its + // connect() key handler and onData) wires its own controller the same way + // for every grid tile and the split's Pane B; keep the two in step. try { this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({ textarea: this.terminal.textarea, diff --git a/test/mocks/terminal-tile-fakes.ts b/test/mocks/terminal-tile-fakes.ts index 3bb4a349..f68428a0 100644 --- a/test/mocks/terminal-tile-fakes.ts +++ b/test/mocks/terminal-tile-fakes.ts @@ -70,6 +70,12 @@ export class FakeTerminal { * puts it. */ static emulateScroll = false; + /** + * Opt-in, set by a test BEFORE the tile connects (and reset after): extra + * fields merged into `_core`, e.g. xterm's `_compositionHelper` for the + * keyCode-229 controller, which reads it once when the tile creates it. + */ + static coreFactory: ((term: FakeTerminal) => Record<string, unknown>) | null = null; options: Record<string, unknown>; cols = 80; rows = 24; @@ -88,7 +94,10 @@ export class FakeTerminal { querySelector: (sel: string) => sel === '.xterm-screen' ? { getBoundingClientRect: () => ({ ...this.screenRect }) } : null, }; - _core = { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }; + _core: Record<string, unknown> = { + _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } }, + ...(FakeTerminal.coreFactory?.(this) ?? {}), + }; constructor(options: Record<string, unknown>) { this.options = { ...options }; FakeTerminal.last = this; @@ -109,12 +118,26 @@ export class FakeTerminal { } keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null; focusListeners: Array<() => void> = []; + /** Every other textarea listener, with the capture flag it was added with (the keyCode-229 controller's). */ + textareaListeners: Array<{ type: string; fn: (ev: Record<string, unknown>) => void; capture: unknown }> = []; textarea = { - addEventListener: (type: string, fn: () => void) => { - if (type === 'focus') this.focusListeners.push(fn); + /** The helper textarea's text, which xterm's keyCode-229 diff (and the controller's) reads. */ + value: '', + addEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => { + if (type === 'focus') this.focusListeners.push(fn as () => void); + else this.textareaListeners.push({ type, fn, capture }); }, - removeEventListener: (type: string, fn: () => void) => { + removeEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => { if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn); + else { + this.textareaListeners = this.textareaListeners.filter( + (l) => !(l.type === type && l.fn === fn && Boolean(l.capture) === Boolean(capture)) + ); + } + }, + /** Delivers `ev` to the textarea's listeners of `type`, in registration order. */ + fire: (type: string, ev: Record<string, unknown> = {}) => { + for (const l of this.textareaListeners.filter((x) => x.type === type)) l.fn({ type, ...ev }); }, }; focusTextarea() { diff --git a/test/terminal-keycode229-recovery.browser.test.ts b/test/terminal-keycode229-recovery.browser.test.ts index 04d1dc8f..c8e78dbe 100644 --- a/test/terminal-keycode229-recovery.browser.test.ts +++ b/test/terminal-keycode229-recovery.browser.test.ts @@ -12,7 +12,9 @@ * - a keystroke xterm DOES handle is delivered exactly once, not twice; * - a character committed in the SAME page task as Enter reaches the send * path ahead of the `\r`, which is the ordering the zero-delay timer - * alone cannot produce. + * alone cannot produce; + * - a TerminalTile (grid tile, split Pane B) wires its own controller, so + * the same shapes come out right there too, addressed to the tile's session. * * Browser-driven, so it is excluded from `npm run test:ci` like the other * Playwright suites. Run locally: @@ -393,4 +395,192 @@ describe('orphaned terminal input recovery wiring', () => { // Byte for byte what the phone sent in the device log. expect(line).toBe('testing the peompttesting the prompt rompt '); }); + + /** + * The same keyboard shapes through a TerminalTile, the pane a grid tile and the split view's + * Pane B are made of. It wires its OWN controller (terminal-tile.js _createKeyCode229Recovery): + * its own xterm, helper textarea and composition helper, sending to its own session through + * `app._sendInputAsync(tileSessionId, …)`, never the active one. Declared after the primary + * pane's control above, which only switched the PRIMARY controller off. + */ + describe("in a TerminalTile (a grid tile, the split view's Pane B)", () => { + const TILE_ID = 'cod541-tile-browser'; + + beforeAll(async () => { + await page.evaluate(async (id) => { + const w = window as any; + const mount = document.createElement('div'); + mount.id = 'tile229Mount'; + mount.style.cssText = 'position:fixed;left:0;top:0;width:480px;height:320px;z-index:9999;'; + document.body.appendChild(mount); + // A session that does not exist: its socket is refused (4004) and the load finds + // nothing, neither of which the controller needs. Input is stubbed per test below. + const tile = new w.TerminalTile(id, mount, { mode: 'shell' }); + w.__tile229 = tile; + await tile.connect(); + }, TILE_ID); + await page.waitForFunction(() => (window as any).__tile229?._keyCode229Recovery, null, { timeout: 30000 }); + }, 60000); + + afterAll(async () => { + await page.evaluate(() => { + const w = window as any; + w.__tile229?.destroy(); + delete w.__tile229; + document.getElementById('tile229Mount')?.remove(); + }); + }); + + type Scenario = 'autocorrect' | 'lastCharThenEnter' | 'autocorrectThenEnter' | 'orphanThenEnter' | 'selfRescued'; + + /** + * Runs one keyboard shape against the tile's own textarea (scoped to its mount: + * `document.querySelector` would find the PRIMARY pane's) and reports what reached the send + * path, which session each chunk was addressed to, and how often xterm itself spoke. + */ + async function inTile(scenario: Scenario) { + return page.evaluate( + async ({ scenario, tileId }) => { + const w = window as any; + const app = w.app; + const tile = w.__tile229; + const textarea = document.querySelector('#tile229Mount .xterm-helper-textarea') as HTMLTextAreaElement; + const originalSendInput = app._sendInputAsync; + const originalSessionId = app.activeSessionId; + const rec = tile._keyCode229Recovery; + const sent: Array<[string, string]> = []; + let xtermEmitted = 0; + const keydown = (init: KeyboardEventInit, keyCode: number) => { + const down = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, composed: true, ...init }); + Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } }); + textarea.dispatchEvent(down); + }; + const key229 = () => keydown({ key: 'Unidentified' }, 229); + const enter = () => keydown({ key: 'Enter', code: 'Enter' }, 13); + const tick = () => new Promise((resolve) => setTimeout(resolve, 20)); + const typeKeys = async (text: string) => { + for (const ch of text) { + key229(); + document.execCommand('insertText', false, ch); + await tick(); + } + }; + const autocorrectEdit = () => { + key229(); + textarea.setSelectionRange(textarea.value.length - 5, textarea.value.length); + document.execCommand('delete'); + key229(); + document.execCommand('insertText', false, 'rompt '); + }; + try { + app.activeSessionId = 'cod541-not-the-tile'; + app._sendInputAsync = (sessionId: string, chunk: string) => sent.push([sessionId, chunk]); + if (scenario === 'selfRescued') { + // Counts xterm's own canonical emissions: the tile's onData reads the property at + // call time, and the controller object itself is frozen. + tile._keyCode229Recovery = { + handleKeyEvent: (e: any) => rec.handleKeyEvent(e), + notifyCanonicalData: () => { + xtermEmitted += 1; + return rec.notifyCanonicalData(); + }, + destroy: () => rec.destroy(), + }; + } + textarea.value = ''; + textarea.focus(); + + if (scenario === 'autocorrect') { + await typeKeys('testing the peompt'); + autocorrectEdit(); // both edits in one task, before any timer runs + } else if (scenario === 'lastCharThenEnter') { + await typeKeys('hell'); + key229(); + document.execCommand('insertText', false, 'o'); + enter(); // same task + } else if (scenario === 'autocorrectThenEnter') { + await typeKeys('testing the peompt'); + autocorrectEdit(); + enter(); // same task + } else if (scenario === 'orphanThenEnter') { + // #441's batched shape: a keydown, the composed insertText xterm refuses, Enter. + keydown({ key: 'Unidentified' }, 65); + textarea.value = 'o'; + textarea.dispatchEvent( + new InputEvent('input', { data: 'o', inputType: 'insertText', bubbles: true, composed: true }) + ); + enter(); + } else { + key229(); + textarea.value = 'y'; + textarea.dispatchEvent( + new InputEvent('input', { data: 'y', inputType: 'insertText', bubbles: true, composed: true }) + ); + } + await new Promise((resolve) => setTimeout(resolve, 120)); + + const raw = sent.map(([, chunk]) => chunk).join(''); + const line: string[] = []; + for (const ch of raw) { + if (ch === '\x7f') line.pop(); + else line.push(ch); + } + return { + raw, + line: line.join(''), + textarea: textarea.value, + sessions: [...new Set(sent.map(([sessionId]) => sessionId))], + xtermEmitted, + tileId, + }; + } finally { + app._sendInputAsync = originalSendInput; + app.activeSessionId = originalSessionId; + tile._keyCode229Recovery = rec; + textarea.value = ''; + } + }, + { scenario, tileId: TILE_ID } + ); + } + + it('an autocorrect on space reaches the shell once, not duplicated', async () => { + const r = await inTile('autocorrect'); + expect(r.textarea).toBe('testing the prompt '); + expect(r.line).toBe('testing the prompt '); + expect(r.sessions).toEqual([TILE_ID]); + }); + + it('a 229 last character in the same task as Enter submits the whole line', async () => { + const r = await inTile('lastCharThenEnter'); + expect(r.raw).not.toContain('\x7f'); + expect(r.line).toBe('hello\r'); + expect(r.sessions).toEqual([TILE_ID]); + }); + + it('an autocorrect plus Enter in one task submits the corrected line', async () => { + const r = await inTile('autocorrectThenEnter'); + expect(r.line).toBe('testing the prompt \r'); + expect(r.sessions).toEqual([TILE_ID]); + }); + + it('a refused insertText committed in the same task as Enter goes out BEFORE the carriage return', async () => { + const r = await inTile('orphanThenEnter'); + expect(r.raw).toBe('o\r'); + expect(r.sessions).toEqual([TILE_ID]); + }); + + it('a 229 keystroke xterm diffed itself is delivered once', async () => { + const r = await inTile('selfRescued'); + expect(r.xtermEmitted).toBe(1); + expect(r.raw).toBe('y'); + }); + + it("control: with the tile's controller destroyed, xterm alone duplicates the line", async () => { + // Keep LAST in this block: it leaves the tile's controller off. + await page.evaluate(() => (window as any).__tile229._keyCode229Recovery.destroy()); + const r = await inTile('autocorrect'); + expect(r.line).toBe('testing the peompttesting the prompt rompt '); + }); + }); }); diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts index 3eddf5d9..a3873bf1 100644 --- a/test/terminal-tile-input.test.ts +++ b/test/terminal-tile-input.test.ts @@ -16,10 +16,16 @@ * the pane once (`onExit`); a late close from a REPLACED socket is ignored; and * destroy() cancels a pending reconnect. * - * Real code under test: constants.js + app.js (the queue) + terminal-ui.js (the - * shared input predicates) + terminal-tile.js, in one `vm` context. xterm, the - * fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts); - * `connect()` runs for real. + * The last blocks pin the soft-keyboard controller every tile wires + * (terminal-keycode229-recovery.js, the primary pane's #441/#541 fixes): an + * Android autocorrect is sent as an edit, not a duplicated line, a character + * committed in the same task as Enter goes out ahead of the \r, and the + * controller is bound to THIS tile's textarea, composition helper and session. + * + * Real code under test: constants.js + terminal-keycode229-recovery.js + + * app.js (the queue) + terminal-ui.js (the shared input predicates) + + * terminal-tile.js, in one `vm` context. xterm, the fit addon and WebSocket are + * fakes (test/mocks/terminal-tile-fakes.ts); `connect()` runs for real. */ import { readFileSync } from 'node:fs'; import { performance } from 'node:perf_hooks'; @@ -36,6 +42,12 @@ function loadContext() { addEventListener: vi.fn(), removeEventListener: vi.fn(), CodemanBase: { base: '' }, + // The keyCode-229 controller defaults its timers to window's. Late-bound, + // like the context's own, so vi.useFakeTimers() reaches it; without them + // its create() throws into the tile's catch and every controller test + // would run against no controller at all. + setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id), }; const context = vm.createContext({ console: { ...console, log: vi.fn(), debug: vi.fn() }, @@ -63,7 +75,8 @@ function loadContext() { }, }); vm.runInContext( - `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` + + `${read('constants.js')}\n${read('terminal-keycode229-recovery.js')}\n${read('app.js')}\n` + + `${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` + 'globalThis.__CodemanApp = CodemanApp;', context ); @@ -138,6 +151,7 @@ afterEach(() => { }); beforeEach(() => { + FakeTerminal.coreFactory = null; FakeFit.proposed = { cols: 80, rows: 24 }; FakeSocket.instances = []; fetchMock.mockReset(); @@ -684,3 +698,398 @@ describe('the server coming back kicks Pane B', () => { expect(branch).toContain('this._splitPane?.reconnectNow?.();'); }); }); + +/** + * xterm's CompositionHelper, reduced to what the keyCode-229 controller touches. + * Its `_handleAnyTextareaChanges` is xterm's own append-only diff as shipped + * (node_modules/@xterm/xterm/src/browser/input/CompositionHelper.ts), so a + * control without the controller reproduces the device-log duplicate, and its + * `triggerDataEvent` feeds the tile's onData, as xterm's core service does. + */ +type Helper = { + _isComposing: boolean; + _isSendingComposition: boolean; + _dataAlreadySent: string; + _coreService: { triggerDataEvent: (data: string, wasUserInput?: boolean) => void }; + _handleAnyTextareaChanges: () => void; +}; + +/** + * Gives every FakeTerminal created from now on a composition helper. Returns + * them in creation order, with xterm's own diff each one started with. + */ +function withCompositionHelpers() { + const helpers: Helper[] = []; + const originals: Array<Helper['_handleAnyTextareaChanges']> = []; + FakeTerminal.coreFactory = (term) => { + const helper: Helper = { + _isComposing: false, + _isSendingComposition: false, + _dataAlreadySent: '', + _coreService: { triggerDataEvent: (data: string) => term.type(data) }, + _handleAnyTextareaChanges(this: Helper) { + const oldValue = term.textarea.value; + setTimeout(() => { + if (this._isComposing) return; + const newValue = term.textarea.value; + const diff = newValue.replace(oldValue, ''); + this._dataAlreadySent = diff; + if (newValue.length > oldValue.length) this._coreService.triggerDataEvent(diff, true); + else if (newValue.length < oldValue.length) this._coreService.triggerDataEvent('\x7f', true); + else if (newValue !== oldValue) this._coreService.triggerDataEvent(newValue, true); + }, 0); + }, + }; + helpers.push(helper); + originals.push(helper._handleAnyTextareaChanges); + return { _compositionHelper: helper }; + }; + return Object.assign(helpers, { originals }); +} + +/** + * Drives a tile the way an Android soft keyboard drives xterm. The fake xterm + * runs no CompositionHelper.keydown of its own, so `key229()` does what xterm + * does, in xterm's order: the custom key handler first, then (keyCode 229, no + * composition) the helper's `_handleAnyTextareaChanges()`, read off the helper + * at call time so the controller's patch is what runs. + */ +function softKeyboard(term: FakeTerminal, helper: Helper) { + const textarea = term.textarea; + const key229 = () => { + term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 }); + helper._handleAnyTextareaChanges(); + }; + return { + key229, + /** One appended character, settled on its own timer before the next key. */ + typeKeys(text: string) { + for (const ch of text) { + key229(); + textarea.value += ch; + vi.advanceTimersByTime(1); + } + }, + /** The textarea now reads `value` (what the keyboard's input event left there). */ + edit(value: string) { + textarea.value = value; + }, + /** Enter: the custom handler, then xterm's own \r, then xterm clearing its textarea. */ + enter() { + const passed = term.keyHandler!({ type: 'keydown', key: 'Enter', keyCode: 13 }); + if (passed) term.type('\r'); + textarea.value = ''; + }, + }; +} + +/** Every byte the tile sent as input, in order. */ +const joinFrames = (frames: Array<{ d?: string }>) => frames.map((f) => f.d ?? '').join(''); +const wireOf = (ws: FakeSocket) => joinFrames(ws.inputFrames()); + +/** The line a shell ends up with: every DEL erases the character before it. */ +function lineOf(frames: Array<{ d?: string }>) { + const out: string[] = []; + for (const ch of joinFrames(frames)) { + if (ch === '\x7f') out.pop(); + else out.push(ch); + } + return out.join(''); +} + +type ControllerTile = Tile & { _keyCode229Recovery: unknown }; + +describe("TerminalTile wires the primary pane's soft-keyboard controller (#441, #541)", () => { + it("installs on THIS tile's composition helper and textarea, and destroy() restores xterm's own", async () => { + const helpers = withCompositionHelpers(); + const { tile, term } = await connectTile(makeApp()); + const helper = helpers[0]; + expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); + + // The controller patched this tile's helper (xterm's diff is no longer the one that runs) and + // listens on this tile's textarea in the CAPTURE phase (see the module's measured table). + expect(helper._handleAnyTextareaChanges).not.toBe(helpers.originals[0]); + const captured = term.textareaListeners.filter((l) => l.capture === true).map((l) => l.type); + expect(captured.sort()).toEqual(['compositionend', 'compositionstart', 'input']); + + tile.destroy(); + + expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); + expect(helper._handleAnyTextareaChanges).toBe(helpers.originals[0]); + expect(term.textareaListeners).toEqual([]); + }); + + it('an autocorrect on space is sent as an edit, not a duplicated line', async () => { + vi.useFakeTimers(); + const helpers = withCompositionHelpers(); + const app = makeApp(); + app.activeSessionId = 'some-other-session'; + const { tile, ws, term } = await connectTile(app); + expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); + ws.open(); + const kb = softKeyboard(term, helpers[0]); + + kb.typeKeys('testing the peompt'); + // The device log's shape: ONE keydown deleting five characters, a second inserting `rompt `, + // both before any timer runs. + kb.key229(); + kb.edit('testing the p'); + kb.key229(); + kb.edit('testing the prompt '); + vi.advanceTimersByTime(1); + + const frames = ws.inputFrames(); + expect(lineOf(frames)).toBe('testing the prompt '); + expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5); + // Every byte went to THIS tile's session through the exactly-once queue, never the active one. + expect(frames.every((f) => Number.isInteger(f.seq))).toBe(true); + expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(frames.map((f) => f.d)); + expect(app._pendingDeliveries.has('some-other-session')).toBe(false); + }); + + it('control: without the controller, xterm alone duplicates the line exactly as the device did', async () => { + vi.useFakeTimers(); + const helpers = withCompositionHelpers(); + const saved = windowStub.CodemanKeyCode229Recovery; + delete windowStub.CodemanKeyCode229Recovery; + try { + const { tile, ws, term } = await connectTile(makeApp()); + expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); + ws.open(); + const kb = softKeyboard(term, helpers[0]); + + kb.typeKeys('testing the peompt'); + kb.key229(); + kb.edit('testing the p'); + kb.key229(); + kb.edit('testing the prompt '); + vi.advanceTimersByTime(1); + + expect(lineOf(ws.inputFrames())).toBe('testing the peompttesting the prompt rompt '); + } finally { + windowStub.CodemanKeyCode229Recovery = saved; + } + }); + + it('a 229 last character in the same task as Enter goes out ahead of the \\r (#441 + #541)', async () => { + vi.useFakeTimers(); + const helpers = withCompositionHelpers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + const kb = softKeyboard(term, helpers[0]); + + kb.typeKeys('hell'); + // One task: the last character's keydown and edit, then Enter, no timer in between. + kb.key229(); + kb.edit('hello'); + kb.enter(); + // Settled synchronously at the Enter keydown, not by its timer. + expect(wireOf(ws)).toBe('hello\r'); + + vi.advanceTimersByTime(1); + const wire = wireOf(ws); + expect(wire).toBe('hello\r'); + expect(wire).not.toContain('\x7f'); + }); + + it('an autocorrect plus Enter in one task submits the corrected line', async () => { + vi.useFakeTimers(); + const helpers = withCompositionHelpers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + const kb = softKeyboard(term, helpers[0]); + + kb.typeKeys('testing the peompt'); + kb.key229(); + kb.edit('testing the p'); + kb.key229(); + kb.edit('testing the prompt '); + kb.enter(); + vi.advanceTimersByTime(1); + + const frames = ws.inputFrames(); + expect(lineOf(frames)).toBe('testing the prompt \r'); + expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5); + }); + + it('a 229 keystroke xterm diffed itself is delivered once, not again by the recovery', async () => { + vi.useFakeTimers(); + const helpers = withCompositionHelpers(); + const { tile, ws, term } = await connectTile(makeApp()); + expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); + ws.open(); + const kb = softKeyboard(term, helpers[0]); + + kb.key229(); + kb.edit('y'); + term.textarea.fire('input', { inputType: 'insertText', data: 'y', isComposing: false }); + vi.advanceTimersByTime(1); + + expect(ws.inputFrames().map((f) => f.d)).toEqual(['y']); + }); +}); + +describe("the tile's onData tells the controller only about what a human typed", () => { + /** A keystroke xterm refused: a keydown, then the committed `insertText` it did not forward. */ + const orphan = (term: FakeTerminal, data: string) => { + term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 }); + term.textarea.fire('input', { inputType: 'insertText', data, isComposing: false }); + }; + + it('recovers a refused insertText through a query reply and a focus report, to this tile', async () => { + vi.useFakeTimers(); + const app = makeApp(); + const { tile, ws, term } = await connectTile(app); + expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); + ws.open(); + + orphan(term, 'x'); + term.type('\x1b[?1;2c'); // a DA reply xterm answers on its own: dropped, and not "xterm spoke" + term.type('\x1b[I'); // a focus report: sent ephemeral, and not "xterm spoke" either + vi.advanceTimersByTime(1); + + const frames = ws.inputFrames(); + expect(frames.map((f) => f.d)).toEqual(['\x1b[I', 'x']); + const recovered = frames.find((f) => f.d === 'x')!; + expect(Number.isInteger(recovered.seq)).toBe(true); + expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['x']); + }); + + it("never counts the controller's own recovered bytes as xterm's: two refused inserts after one keydown both arrive", async () => { + // Pins WHERE the notify lives: in the onData lambda, not in _onTerminalData(), which the + // recovered bytes also go through. Counted there, the first recovery would read as "xterm + // spoke" for the second candidate, which shares its keydown snapshot, and drop it. + vi.useFakeTimers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + + term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 }); + term.textarea.fire('input', { inputType: 'insertText', data: 'a', isComposing: false }); + term.textarea.fire('input', { inputType: 'insertText', data: 'b', isComposing: false }); + vi.advanceTimersByTime(1); + + expect(ws.inputFrames().map((f) => f.d)).toEqual(['a', 'b']); + }); + + it('stands down when xterm really did deliver the keystroke', async () => { + vi.useFakeTimers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + + orphan(term, 'x'); + term.type('x'); // xterm's own canonical emission for this keystroke + vi.advanceTimersByTime(1); + + expect(ws.inputFrames().map((f) => f.d)).toEqual(['x']); + }); +}); + +describe('the controller can never break a tile', () => { + type FakeController = { + handleKeyEvent: ReturnType<typeof vi.fn>; + notifyCanonicalData: ReturnType<typeof vi.fn>; + destroy: ReturnType<typeof vi.fn>; + }; + let saved: unknown; + beforeEach(() => { + saved = windowStub.CodemanKeyCode229Recovery; + }); + afterEach(() => { + windowStub.CodemanKeyCode229Recovery = saved; + }); + + const fakeController = (overrides: Partial<FakeController> = {}): FakeController => ({ + handleKeyEvent: vi.fn(), + notifyCanonicalData: vi.fn(), + destroy: vi.fn(), + ...overrides, + }); + + it('a create() that throws leaves the tile connected and typing', async () => { + windowStub.CodemanKeyCode229Recovery = { + create: () => { + throw new Error('broken'); + }, + }; + const { tile, ws, term } = await connectTile(makeApp()); + expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); + ws.open(); + + term.type('a'); + + expect(ws.inputFrames().map((f) => f.d)).toEqual(['a']); + }); + + it("a handleKeyEvent that throws leaves every one of the tile's key gates working", async () => { + const controller = fakeController({ + handleKeyEvent: vi.fn(() => { + throw new Error('broken'); + }), + }); + windowStub.CodemanKeyCode229Recovery = { create: () => controller }; + const { term } = await connectTile(makeApp()); + + expect(term.keyHandler!({ type: 'keydown', key: '1', code: 'Digit1', altKey: true })).toBe(false); + expect(term.keyHandler!({ type: 'keydown', key: 'z', code: 'KeyZ', ctrlKey: true })).toBe(false); + expect(term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 })).toBe(true); + expect(term.keyHandler!({ type: 'keydown', key: 'a', code: 'KeyA', keyCode: 65 })).toBe(true); + // It still ran first, for every one of them. + expect(controller.handleKeyEvent).toHaveBeenCalledTimes(4); + }); + + it('a destroy() that throws still lets the tile dispose its xterm', async () => { + const controller = fakeController({ + destroy: vi.fn(() => { + throw new Error('broken'); + }), + }); + windowStub.CodemanKeyCode229Recovery = { create: () => controller }; + const { tile, term } = await connectTile(makeApp()); + const dispose = vi.spyOn(term, 'dispose'); + + tile.destroy(); + + expect(controller.destroy).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); + }); + + it('each tile gets its own controller on its own textarea, and destroys only its own', async () => { + const made: Array<{ options: { textarea: unknown }; controller: FakeController }> = []; + windowStub.CodemanKeyCode229Recovery = { + create: (options: { textarea: unknown }) => { + const controller = fakeController(); + made.push({ options, controller }); + return controller; + }, + }; + const app = makeApp(); + const a = await connectTile(app); + const b = await connectTile(app); + + expect(made).toHaveLength(2); + expect(made[0].options.textarea).toBe(a.term.textarea); + expect(made[1].options.textarea).toBe(b.term.textarea); + + a.tile.destroy(); + + expect(made[0].controller.destroy).toHaveBeenCalledTimes(1); + expect(made[1].controller.destroy).not.toHaveBeenCalled(); + }); +}); + +describe('terminal-tile.js keeps the controller call where it works (source pin)', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8'); + + it('calls handleKeyEvent ABOVE the IME early return, so a 229 keydown reaches it', () => { + const call = source.indexOf('this._keyCode229Recovery?.handleKeyEvent?.(ev)'); + const earlyReturn = source.indexOf("ev.key === 'Process' || ev.keyCode === 229) return true"); + expect(call).toBeGreaterThan(-1); + expect(earlyReturn).toBeGreaterThan(-1); + expect(call).toBeLessThan(earlyReturn); + }); + + it("hands the controller this tile's own composition helper", () => { + expect(source).toMatch(/getCompositionHelper:\s*\(\)\s*=>\s*this\.terminal\?\._core\?\._compositionHelper/); + }); +}); From 24a73ecd81cb6d495caf1afa57a0a0dfd8a42fb7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 08:40:04 +0200 Subject: [PATCH 4/6] fix(tiles): page a hollow tile only from the live screen, review follow-up A tile counts as hollow when every row above its screen is its own overflow (baseY minus _overflowRows is 0), so unlike the primary pane, whose hollow buffer has baseY 0, its viewport can sit above the bottom while it is hollow: Shift+PageUp, a scrollbar drag or a wheel during the first replay leave it up there. _maybePageCliTranscript never looked at the viewport, so every wheel, wheel-down included, was turned into PageUp/PageDown and swallowed. xterm never scrolled back, the stale rows stayed on screen while the CLI paged out of view, and clicks were dropped too, because the click report refuses an off-bottom viewport. The tile now pages only while _terminalViewportAtBottom holds for its own terminal, checked before the pending travel is touched. Off the bottom the wheel stays with xterm, so a wheel-down brings the viewport home and paging resumes from there. The primary pane is unchanged: its hollow test already implies a viewport at the bottom, which the twin comment now says. Tests: a unit case for a tile hollow by the discount with its viewport above the bottom (no page key, no preventDefault, and no travel carried over once back home), and the real-browser case now scrolls a hollow tile up and proves a real wheel-down scrolls xterm home with no page key sent, then pages again. Both go red with the gate removed, and the unit case also with the gate moved below the pending-travel update. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- src/web/public/terminal-tile.js | 16 ++++++++-- src/web/public/terminal-ui.js | 4 ++- test/terminal-tile-scroll.browser.test.ts | 37 ++++++++++++++++++++--- test/terminal-tile-scroll.test.ts | 32 ++++++++++++++++++++ 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 233632b3..2fabb633 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. -**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. +**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. **Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing. diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 39d04ee0..bb987d2e 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -24,7 +24,8 @@ * so the wheel pages the CLI's own transcript with PageUp/PageDown * (_maybePageCliTranscript) through the primary pane's gates, plus an * overflow-row discount for this pane's capture-before-resize load - * (_localRows). + * (_localRows), and only while the viewport is on the live screen (a + * wheel-down from those overflow rows is xterm's, and brings it home). * - The desktop click report: a plain left-click hand-encoded as SGR while * the session's CLI has mouse tracking on (cliMouseTracking), for the modes * whose mouse DECSETs the server strips (_installClickListener). @@ -41,7 +42,7 @@ * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) - * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_handleDesktopTerminalClick) + * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_handleDesktopTerminalClick) * @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js */ @@ -887,6 +888,17 @@ // follow-up 4), so the wheel stays with xterm, as before. if (app._shouldForwardWheelToApp?.(ev, target)) return false; if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false; + // Only from the live screen. The one gate the primary pane never needs: a + // primary hollow buffer has baseY 0, so its viewport is always at the + // bottom, while a tile's is hollow with its own overflow rows still above + // the screen, and Shift+PageUp, a scrollbar drag or a wheel during the + // first replay can leave the viewport up there. Paging from there would + // swallow every wheel (wheel-down included) and keep the stale rows on + // screen while the CLI pages out of view; left to xterm, a wheel-down + // brings the viewport home and paging resumes from there. The click + // report refuses an off-bottom viewport for the same reason + // (_terminalViewportAtBottom). + if (!app._terminalViewportAtBottom?.(this.terminal)) return false; const lines = input.wheelDeltaLines(ev, this.terminal.rows); if (!lines) return false; const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 92d31442..64088a1d 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -5688,7 +5688,9 @@ Object.assign(CodemanApp.prototype, { * * Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile * through the same gates and the same pageKeysForTravel arithmetic; keep the - * two in step. + * two in step. The tile adds one gate this pane cannot need, viewport at the + * bottom: hollow here means baseY 0, so this viewport is always there, while a + * tile is hollow with its own discounted rows still above the screen. */ _maybePageCliTranscript(ev, lines) { if (!lines || ev?.shiftKey || !this.activeSessionId) return false; diff --git a/test/terminal-tile-scroll.browser.test.ts b/test/terminal-tile-scroll.browser.test.ts index a37ae39b..55eebce4 100644 --- a/test/terminal-tile-scroll.browser.test.ts +++ b/test/terminal-tile-scroll.browser.test.ts @@ -10,6 +10,9 @@ * screen, and a row-shrinking fit pushes more rows up, which is what the * tile's overflow discount (`_localRows`) counts. Output that scrolls real * lines is history, and the tile stops paging. + * - a viewport left up in those discounted rows gets its wheel back: a real + * wheel-down scrolls xterm home instead of being paged, and paging resumes + * from the live screen. * * The wheel is a real one (`page.mouse.wheel()`), and the last step proves it * reaches xterm: a wheel the tile does not page scrolls xterm's viewport, so @@ -69,14 +72,15 @@ describe('TerminalTile wheel paging in a real browser', () => { } as Snap; }); - /** A real wheel-up of a whole screen over the tile, then time for the 40 ms flush and a frame. */ - async function wheelUp(rows: number) { + /** A real wheel of `rows` lines over the tile (negative = up), then time for the 40 ms flush and a frame. */ + async function wheelBy(rows: number) { await page.mouse.move(200, 60); - await page.mouse.wheel(0, -rows * 25); + await page.mouse.wheel(0, rows * 25); await page.waitForTimeout(150); } + const wheelUp = (rows: number) => wheelBy(-rows); - it('pages a hollow tile, keeps xterm still, survives a shrink, and stops once real lines scroll', async () => { + it('pages a hollow tile, keeps xterm still, survives a shrink, returns an off-bottom wheel, and stops at real lines', async () => { await page.evaluate(async (id) => { const w = window as any; const app = w.app; @@ -147,6 +151,29 @@ describe('TerminalTile wheel paging in a real browser', () => { expect(afterSecondWheel.sent.length).toBeGreaterThan(afterWheel.sent.length); expect(afterSecondWheel.viewportY).toBe(afterShrink.baseY); + // A viewport left up in those rows (Shift+PageUp, a scrollbar drag): the + // wheel is xterm's again, so a wheel-down really scrolls it home and sends + // no page key, and from the live screen the wheel pages once more. + await page.evaluate(() => (window as any).__tileProbe.tile.terminal.scrollLines(-5)); + const scrolledUp = await snap(); + expect(scrolledUp.viewportY).toBe(afterShrink.baseY - 5); + expect(scrolledUp.localRows).toBe(0); + // xterm scrolls a few lines per real wheel event, so wheel down until home + // (bounded), each step really moving it and none of them paged. + let backHome = scrolledUp; + for (let i = 0; i < 10 && backHome.viewportY < afterShrink.baseY; i++) { + const before = backHome.viewportY; + await wheelBy(afterShrink.rows); + backHome = await snap(); + expect(backHome.viewportY).toBeGreaterThan(before); + } + expect(backHome.viewportY).toBe(afterShrink.baseY); + expect(backHome.sent.length).toBe(afterSecondWheel.sent.length); + await wheelUp(afterShrink.rows); + const pagedAgain = await snap(); + expect(pagedAgain.sent.length).toBeGreaterThan(backHome.sent.length); + expect(pagedAgain.viewportY).toBe(afterShrink.baseY); + // Output that scrolled real lines is history: the wheel goes back to // xterm, which scrolls its own buffer, and no page key is sent. await page.evaluate( @@ -159,7 +186,7 @@ describe('TerminalTile wheel paging in a real browser', () => { expect(afterOutput.localRows).toBe(3); await wheelUp(afterOutput.rows); const afterThirdWheel = await snap(); - expect(afterThirdWheel.sent.length).toBe(afterSecondWheel.sent.length); + expect(afterThirdWheel.sent.length).toBe(pagedAgain.sent.length); expect(afterThirdWheel.viewportY).toBeLessThan(afterOutput.baseY); } finally { await page.evaluate((id) => { diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 4a4e55d5..072ee16f 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -451,6 +451,38 @@ describe('rows the tile pushed above the screen itself are not history', () => { expect(flushed(ws)).toEqual([]); }); + it('leaves the wheel to xterm while the viewport sits in those rows, and pages again from the bottom', async () => { + // Hollow by the discount alone: baseY > 0, every row above the screen the + // tile's own. Shift+PageUp, a scrollbar drag or a wheel during the replay + // can leave the viewport up there; a primary hollow buffer (baseY 0) never + // can. Paging from there would swallow every wheel and keep the stale rows + // on screen, so xterm gets the wheel and a wheel-down brings it home. + serveCapture(lines(40), 40); + const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + const baseY = term.buffer.active.baseY; + expect(baseY).toBe(16); + expect((tile as unknown as { _localRows(): number })._localRows()).toBe(0); + + term.buffer.active.viewportY = baseY - 5; + for (const ev of [wheelLines(-12), wheelLines(12), wheelLines(-5), wheelLines(-5)]) { + mount.fire('wheel', ev); + expect(ev.preventDefault).not.toHaveBeenCalled(); + expect(ev.stopPropagation).not.toHaveBeenCalled(); + } + expect(flushed(ws)).toEqual([]); + + // Back on the live screen, only travel made there counts: two quarter-screen + // wheels are one PageUp, with nothing carried over from the wheels xterm had + // (the gate sits before the pending travel is touched). + term.buffer.active.viewportY = baseY; + const first = wheelLines(-6); + mount.fire('wheel', first); + expect(first.preventDefault).toHaveBeenCalled(); + expect(flushed(ws)).toEqual([]); + mount.fire('wheel', wheelLines(-6)); + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + it('keeps paging when the PTY geometry report reflows the rows above the screen', async () => { serveCapture(lines(40), 40); const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); From a96a94fb7e15a8394328560f8d4c249c6fc6d6e4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 08:42:03 +0200 Subject: [PATCH 5/6] fix(tiles): send a tile's click report ephemeral, review follow-up The tile's hand-encoded click report went through _handleDesktopTerminalClick and _sendSyntheticSgrTap to _sendInputAsync, so it took a seq, was persisted and would be redelivered after a reload. The documented TerminalTile rule (CLAUDE.md, Split-pane sessions) is that only typed input enters that queue and focus/mouse reports go out ephemeral, and the tile's own _onTerminalData says the same. Before #555 an opencode tile's click went through xterm's encoder and that ephemeral path. A click still unacknowledged when the page reloads, or sent during a server restart, could be replayed onto a later screen, where a press+release can pick a dialog option. _sendSyntheticSgrTap now takes an opt-in `ephemeral` field on its target and sends through _sendInputEphemeral when it is set; _handleDesktopTerminalClick passes the target through unchanged, and TerminalTile._installClickListener sets it. Without the flag nothing changes, so the primary pane's own click and touch tap reports stay on _sendInputAsync exactly as before (whether the primary pane should also go ephemeral is a separate question, out of scope here). Tests: the tile case now requires a frame with no seq and nothing pending in the reliable queue, and the targeted-click case in terminal-touch-tap spies on both send paths: a target with the flag goes ephemeral, an untargeted click and an untargeted tap stay durable. Dropping `ephemeral: true` from the tile, or the branch in _sendSyntheticSgrTap, turns the matching test red. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- src/web/public/terminal-tile.js | 6 +++++- src/web/public/terminal-ui.js | 23 ++++++++++++++++------- test/terminal-tile-scroll.test.ts | 7 +++++-- test/terminal-touch-tap.test.ts | 27 +++++++++++++++++++++++---- 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 2fabb633..0b3a5396 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -801,7 +801,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ### 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 an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile, and its own keyCode-229 soft-keyboard controller from terminal-keycode229-recovery.js, the #441 next-keydown drain and #541's edit-based diff for Android autocorrect, since the 1180px width gate is reachable by a wide Android tablet; see terminal-tile.js's fileoverview) — 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 below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), 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 `TerminalTile` 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 the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. 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'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) 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/Ctrl+Shift+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+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell straight to `return true`, silently ceding the keystroke to the BROWSER's own handling (e.g. Chrome's Inspect-Element binding) with no feedback and no copy attempt — Pane A always intercepts it. Ctrl+V goes through the primary pane's paste trap aimed at Pane B (`_handleImagePaste({ terminal, sessionId })`), so a pasted image uploads to Pane B's session. ⚠️ `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 keystrokes go through the exactly-once queue (`_sendInputAsync`) over its own socket, registered in the app's input-socket map (`_registerInputSocket` / `_inputSocketFor`), so they are seq-tagged, ACKed (`{t:'ia'}` is routed by the RECEIVING socket's session, since the frame names none), redelivered after a drop, and the ACK clears the idle alert. ⚠️ What xterm generates never enters that persisted queue: query replies are dropped (`shouldSuppressTerminalQueryResponse`, as the primary pane drops them) and focus/mouse reports go through `_sendInputEphemeral`, or a reload would replay them as typed text. ⚠️ A SHELL Pane B pulls scrollback itself when the wheel goes up at the top of its buffer (`_maybeLoadMoreHistory`/`_pullHistory`): tmux repaints a burst of output instead of scrolling it, so Pane B's own xterm holds about one screen of scrollback while tmux holds every line, and it loaded history exactly once at connect and never again. It is the same bounded pull as Pane A's (`?full=1&tail=TERMINAL_TAIL_SIZE`, no rewrite when the window holds no more rows than the pane already has or the pane is at its `scrollback + rows` cap, and a 60 s back-off instead of 4 s when that skipped window was truncated or the pane is full, since each ask costs the server a whole-history `capture-pane`), against Pane B's OWN terminal rather than `app.terminal`, so it cannot share `_maybeRefetchFullHistory`. The wheel listener is capture-phase because xterm `stopPropagation()`s the events it consumes; the alternate-screen skip (nano, vim, less) only matters for a direct-PTY shell, since under tmux the browser xterm never enters the alternate buffer; skipped too for a detached session (mirrors `_sendResize()`'s own check and app.js's `_maybeRefetchFullHistory`), since its own window already owns its PTY size and scrollback. Live frames arriving from the response onward, a `{t:'c'}` clear frame included, are held with their arrival time (`_liveQueue`, opened right after `await fetch(...)` beside `capturedAt`; a frame from before it is replaced by the capture or written unchanged, so the pane keeps painting during the round trip) and replayed in order only if they arrived after the capture (the response's arrival stands in for the capture instant, as in `_finishBufferLoad`, so a frame inside that one round trip can be lost or doubled); the request uses the primary pane's budget (`CodemanFetchDeadline.terminalFetchDeadlineMs({ full: true })`, 45 s, 10 s only if that helper is absent) and the body read gets 10 s once the headers land, because from then on the pull holds the pane's live output. The request phase holds no live output, but it does hold the single-flight flag, so a coalesced `{t:'r'}` refresh and a marker owed by a close (below) wait for the response, at worst for that whole budget (accepted: a Codeman restart resets an in-flight request along with the socket, so that pull fails at once and stamps the marker). ⚠️ The "disconnected" marker must be the LAST thing on screen. A replay's own `\x1bc` would otherwise wipe a marker written before the pull and paint a fresh, current-looking history while `onData` keeps silently dropping every keystroke on the dead socket (a Codeman restart drops the socket while the tmux session, and so the HTTP pull, still succeeds), so `_pullHistory()` re-stamps it after the live-frame flush. A close DURING any load (a pull, a refresh; `connect()` awaits the initial load before it creates the socket, so no close lands in that one) writes nothing: `_onSocketClosed()` sets `_markerOwed` while a load's work runs (`_loadRunning`; a load that only waits in the tile grid's queue writes the marker at once, and its refresh clears the screen only when its turn comes), since written there it would sit above the held frames the pull flushes after a skip, a downgrade, a failed fetch or the deadline, above a refresh's replay, or land mid-way through a chunked replay. Each load settles the marker in its OWN `finally` (`_stampMarkerIfOwed()`), after the queue flush, EXCEPT when a trailing refresh is pending: that refresh's `clear()` is synchronous while xterm parses a `write()` on a later tick, so a marker stamped just before it lands in the freshly cleared buffer ABOVE the refresh's replay (a second, stale copy; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously and cannot show it, so an async-parse fake there pins it). The marker stays owed instead, and the trailing refresh, which re-owes it on a closed socket anyway, writes the one copy below its own replay. Anything that wipes the terminal on a closed socket (a replay's `\x1bc`, a refresh's `clear()`) sets `_markerOwed` too, so the marker is rewritten whether or not the close landed during the load. Tracked via `_wsClosed`/`_markerOwed` rather than routed through `_onLiveOutput()`, since a close landing before the response is stamped before the cutoff and would be dropped with the rest of the pre-capture queue. There is no "Load full history" banner in Pane B, so a shell history past that 1 MiB window stays out of reach there. Non-shell Pane B is unchanged: it already loads `full=1`, and its history is out of scope for this pull (codex and Claude's inline renderer do grow tmux history; this just isn't how they recover it). ⚠️ App-level terminal actions find their pane through `_focusedPane()`, the terminal focused LAST (not `document.activeElement`, which the mic or a header button takes): Ctrl+L, Ctrl+Shift+R, voice and image paste act on Pane B while it holds the keyboard. Ctrl+W is not an app shortcut at all (Close Session has no default key), so it reaches whichever pane is focused as delete-word. ⚠️ Geometry: `TerminalTile.fit()` sends the size the xterm actually holds, unfloored (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, re-sends on every fresh socket, and adopts the PTY's column count from `{t:'zc'}`; the font, family and weight setters call `tile.fit()`, never a local refit alone (#464). Every capture a pane fetches carries a deadline covering the body (`CodemanFetchDeadline`), so a capture that never answers cannot hold its single-flight flag forever. Design: `docs/split-pane-sessions-plan.md`; the tile class: `docs/tile-grid-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 an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile, and its own keyCode-229 soft-keyboard controller from terminal-keycode229-recovery.js, the #441 next-keydown drain and #541's edit-based diff for Android autocorrect, since the 1180px width gate is reachable by a wide Android tablet; see terminal-tile.js's fileoverview) — 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 below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), 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 `TerminalTile` 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 the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. 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'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) 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/Ctrl+Shift+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+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell straight to `return true`, silently ceding the keystroke to the BROWSER's own handling (e.g. Chrome's Inspect-Element binding) with no feedback and no copy attempt — Pane A always intercepts it. Ctrl+V goes through the primary pane's paste trap aimed at Pane B (`_handleImagePaste({ terminal, sessionId })`), so a pasted image uploads to Pane B's session. ⚠️ `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 keystrokes go through the exactly-once queue (`_sendInputAsync`) over its own socket, registered in the app's input-socket map (`_registerInputSocket` / `_inputSocketFor`), so they are seq-tagged, ACKed (`{t:'ia'}` is routed by the RECEIVING socket's session, since the frame names none), redelivered after a drop, and the ACK clears the idle alert. ⚠️ What xterm generates never enters that persisted queue: query replies are dropped (`shouldSuppressTerminalQueryResponse`, as the primary pane drops them) and focus/mouse reports go through `_sendInputEphemeral`, or a reload would replay them as typed text. That includes the click report a tile hand-encodes for a mouse-strip CLI: `TerminalTile._installClickListener` passes `ephemeral: true` in its target, which `_sendSyntheticSgrTap` honours, while the primary pane's own report (no flag) stays on `_sendInputAsync` as before. ⚠️ A SHELL Pane B pulls scrollback itself when the wheel goes up at the top of its buffer (`_maybeLoadMoreHistory`/`_pullHistory`): tmux repaints a burst of output instead of scrolling it, so Pane B's own xterm holds about one screen of scrollback while tmux holds every line, and it loaded history exactly once at connect and never again. It is the same bounded pull as Pane A's (`?full=1&tail=TERMINAL_TAIL_SIZE`, no rewrite when the window holds no more rows than the pane already has or the pane is at its `scrollback + rows` cap, and a 60 s back-off instead of 4 s when that skipped window was truncated or the pane is full, since each ask costs the server a whole-history `capture-pane`), against Pane B's OWN terminal rather than `app.terminal`, so it cannot share `_maybeRefetchFullHistory`. The wheel listener is capture-phase because xterm `stopPropagation()`s the events it consumes; the alternate-screen skip (nano, vim, less) only matters for a direct-PTY shell, since under tmux the browser xterm never enters the alternate buffer; skipped too for a detached session (mirrors `_sendResize()`'s own check and app.js's `_maybeRefetchFullHistory`), since its own window already owns its PTY size and scrollback. Live frames arriving from the response onward, a `{t:'c'}` clear frame included, are held with their arrival time (`_liveQueue`, opened right after `await fetch(...)` beside `capturedAt`; a frame from before it is replaced by the capture or written unchanged, so the pane keeps painting during the round trip) and replayed in order only if they arrived after the capture (the response's arrival stands in for the capture instant, as in `_finishBufferLoad`, so a frame inside that one round trip can be lost or doubled); the request uses the primary pane's budget (`CodemanFetchDeadline.terminalFetchDeadlineMs({ full: true })`, 45 s, 10 s only if that helper is absent) and the body read gets 10 s once the headers land, because from then on the pull holds the pane's live output. The request phase holds no live output, but it does hold the single-flight flag, so a coalesced `{t:'r'}` refresh and a marker owed by a close (below) wait for the response, at worst for that whole budget (accepted: a Codeman restart resets an in-flight request along with the socket, so that pull fails at once and stamps the marker). ⚠️ The "disconnected" marker must be the LAST thing on screen. A replay's own `\x1bc` would otherwise wipe a marker written before the pull and paint a fresh, current-looking history while `onData` keeps silently dropping every keystroke on the dead socket (a Codeman restart drops the socket while the tmux session, and so the HTTP pull, still succeeds), so `_pullHistory()` re-stamps it after the live-frame flush. A close DURING any load (a pull, a refresh; `connect()` awaits the initial load before it creates the socket, so no close lands in that one) writes nothing: `_onSocketClosed()` sets `_markerOwed` while a load's work runs (`_loadRunning`; a load that only waits in the tile grid's queue writes the marker at once, and its refresh clears the screen only when its turn comes), since written there it would sit above the held frames the pull flushes after a skip, a downgrade, a failed fetch or the deadline, above a refresh's replay, or land mid-way through a chunked replay. Each load settles the marker in its OWN `finally` (`_stampMarkerIfOwed()`), after the queue flush, EXCEPT when a trailing refresh is pending: that refresh's `clear()` is synchronous while xterm parses a `write()` on a later tick, so a marker stamped just before it lands in the freshly cleared buffer ABOVE the refresh's replay (a second, stale copy; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously and cannot show it, so an async-parse fake there pins it). The marker stays owed instead, and the trailing refresh, which re-owes it on a closed socket anyway, writes the one copy below its own replay. Anything that wipes the terminal on a closed socket (a replay's `\x1bc`, a refresh's `clear()`) sets `_markerOwed` too, so the marker is rewritten whether or not the close landed during the load. Tracked via `_wsClosed`/`_markerOwed` rather than routed through `_onLiveOutput()`, since a close landing before the response is stamped before the cutoff and would be dropped with the rest of the pre-capture queue. There is no "Load full history" banner in Pane B, so a shell history past that 1 MiB window stays out of reach there. Non-shell Pane B is unchanged: it already loads `full=1`, and its history is out of scope for this pull (codex and Claude's inline renderer do grow tmux history; this just isn't how they recover it). ⚠️ App-level terminal actions find their pane through `_focusedPane()`, the terminal focused LAST (not `document.activeElement`, which the mic or a header button takes): Ctrl+L, Ctrl+Shift+R, voice and image paste act on Pane B while it holds the keyboard. Ctrl+W is not an app shortcut at all (Close Session has no default key), so it reaches whichever pane is focused as delete-word. ⚠️ Geometry: `TerminalTile.fit()` sends the size the xterm actually holds, unfloored (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, re-sends on every fresh socket, and adopts the PTY's column count from `{t:'zc'}`; the font, family and weight setters call `tile.fit()`, never a local refit alone (#464). Every capture a pane fetches carries a deadline covering the body (`CodemanFetchDeadline`), so a capture that never answers cannot hold its single-flight flag forever. Design: `docs/split-pane-sessions-plan.md`; the tile class: `docs/tile-grid-plan.md`. ### Tile grid diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index bb987d2e..378c929f 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -28,7 +28,8 @@ * wheel-down from those overflow rows is xterm's, and brings it home). * - The desktop click report: a plain left-click hand-encoded as SGR while * the session's CLI has mouse tracking on (cliMouseTracking), for the modes - * whose mouse DECSETs the server strips (_installClickListener). + * whose mouse DECSETs the server strips (_installClickListener), sent + * ephemeral, like every mouse report from this pane (_onTerminalData). * - The soft-keyboard controller (terminal-keycode229-recovery.js), one per * pane, on this pane's own textarea and composition helper and sending to * this pane's session (_createKeyCode229Recovery): it forwards an @@ -860,6 +861,9 @@ terminal: this.terminal, sessionId: this.sessionId, linkHovered: this._linkHovered, + // Like every mouse report from this pane (_onTerminalData): once, + // never persisted, so a reload cannot replay it onto a later screen. + ephemeral: true, }); }; this.mountEl.addEventListener('click', this._onClick); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 64088a1d..48d134bc 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -5465,8 +5465,13 @@ Object.assign(CodemanApp.prototype, { // Encode a tap as an SGR mouse report (press + release at button 0) and send it // to the PTY directly, bypassing xterm's mouse encoder. `target` ({ terminal, - // sessionId }) aims it at a TerminalTile instead of the primary pane; either - // field left out means the primary pane's. + // sessionId, ephemeral }) aims it at a TerminalTile instead of the primary + // pane; either of the first two left out means the primary pane's. + // `ephemeral: true` sends it through _sendInputEphemeral instead of the + // persisted exactly-once queue: a TerminalTile's mouse reports never enter + // that queue (CLAUDE.md, Split-pane sessions), or a reload would replay one + // onto a later screen. Left out, the report stays on _sendInputAsync, as the + // primary pane always sent it. _sendSyntheticSgrTap(clientX, clientY, target = {}) { const sessionId = target.sessionId || this.activeSessionId; const terminal = target.terminal || this.terminal; @@ -5474,7 +5479,9 @@ Object.assign(CodemanApp.prototype, { if (!this._terminalViewportAtBottom(terminal)) return; // scrollback click → misfire, do nothing const pos = this._clientPointToCell(clientX, clientY, terminal); if (!pos) return; - this._sendInputAsync(sessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`); + const report = `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`; + if (target.ephemeral) this._sendInputEphemeral(sessionId, report); + else this._sendInputAsync(sessionId, report); }, // True when a parsed CLI version string ('2.1.187' — banner-parsed on the @@ -5758,10 +5765,12 @@ Object.assign(CodemanApp.prototype, { // clicks outside the cell grid, and sessions where xterm's own encoder is // live (it reported the click itself — a second report would double-move). // - // `target` ({ terminal, sessionId, linkHovered }) runs the same skips for a - // TerminalTile's click: its own terminal, its own session's tracking flag and - // its own link hover (the primary pane's _linkHovered belongs to its terminal - // alone). Every field left out means the primary pane's. + // `target` ({ terminal, sessionId, linkHovered, ephemeral }) runs the same + // skips for a TerminalTile's click: its own terminal, its own session's + // tracking flag and its own link hover (the primary pane's _linkHovered + // belongs to its terminal alone). `ephemeral` reaches _sendSyntheticSgrTap, + // so a TerminalTile's mouse report never enters the persisted input queue. + // Every field left out means the primary pane's. _handleDesktopTerminalClick(ev, target = {}) { const terminal = target.terminal || this.terminal; if (!terminal || !ev?.isTrusted) return; diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 072ee16f..3c6b60a3 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -514,13 +514,16 @@ describe('a tile reports a plain click to a CLI with mouse tracking on', () => { }); const TAP = '\x1b[<0;21;6M\x1b[<0;21;6m'; - it("sends one seq-tagged SGR press+release on the tile's socket, from the tile's own geometry", async () => { + it("sends one ephemeral SGR press+release on the tile's socket, from the tile's own geometry", async () => { const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode', cliMouseTracking: true } }); const { ws, mount } = await connectTile(app); mount.fire('click', click()); - expect(ws.inputFrames().map((f) => [f.d, typeof f.seq])).toEqual([[TAP, 'number']]); + // No seq: like every mouse report from a tile, it never enters the + // persisted exactly-once queue, so a reload cannot replay it. + expect(ws.inputFrames().map((f) => [f.d, typeof f.seq])).toEqual([[TAP, 'undefined']]); + expect((app._pendingDeliveries as Map<string, unknown[]>).get('s-tile')?.length ?? 0).toBe(0); }); it('sends nothing without the flag, over a selection, over its own hovered link, or scrolled up', async () => { diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 28bbd254..7b91e93b 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -583,12 +583,16 @@ describe('terminal touch tap mouse guard', () => { // primary pane's terminal and active session are not consulted. const { app } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; + // A tile's report must stay out of the persisted queue: it asks for the + // ephemeral path, and only a target that says so gets it. + const durable: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; app.sessions = new Map([ ['sess-1', { mode: 'claude', cliMouseTracking: false }], ['s2', { mode: 'opencode', cliMouseTracking: true }], ]); - app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); + app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data }); + app._sendInputAsync = (id: string, data: string) => durable.push({ id, data }); app._linkHovered = true; // the PRIMARY pane's hover: must not block the tile app.terminal = { cols: 80, @@ -618,14 +622,16 @@ describe('terminal touch tap mouse guard', () => { target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) }, }; - app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: false }); + const tile = { terminal: other, sessionId: 's2', linkHovered: false, ephemeral: true }; + app._handleDesktopTerminalClick(click, tile); expect(sent).toEqual([{ id: 's2', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]); + expect(durable).toEqual([]); // The tile's own selection and its own link hover do block it. otherSelected = true; - app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: false }); + app._handleDesktopTerminalClick(click, tile); otherSelected = false; - app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: true }); + app._handleDesktopTerminalClick(click, { ...tile, linkHovered: true }); expect(sent).toHaveLength(1); // With no target the primary pane answers for itself, exactly as before. @@ -633,6 +639,19 @@ describe('terminal touch tap mouse guard', () => { expect(app._shouldReportMouseToCli('s2')).toBe(true); app._handleDesktopTerminalClick(click); expect(sent).toHaveLength(1); + + // And the primary pane's own reports stay on the durable queue: an + // untargeted click and an untargeted touch tap. + app.sessions.set('sess-1', { mode: 'claude', cliMouseTracking: true }); + app.terminal = { ...app.terminal, hasSelection: () => false, buffer: { active: { viewportY: 50, baseY: 50 } } }; + app._linkHovered = false; + app._handleDesktopTerminalClick(click); + app._sendSyntheticSgrTap(50, 50); + expect(durable).toEqual([ + { id: 'sess-1', data: '\x1b[<0;22;7M\x1b[<0;22;7m' }, // the primary's own origin (0, 0) + { id: 'sess-1', data: '\x1b[<0;7;4M\x1b[<0;7;4m' }, + ]); + expect(sent).toHaveLength(1); }); it("tap: a target uses that pane's geometry and scroll position", () => { From 7409ad2655a4abca60fe3dde6e7d944d557cd8f5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 08:42:22 +0200 Subject: [PATCH 6/6] fix(tiles): say the split and grid width gate is width alone, review follow-up Two lines the #541 parity commit edited still called the split "desktop-only" and listed "Phones and tablets" as a tile grid non-goal, right next to the new note that a wide Android tablet clears the gate. The same commit documents the gate as width alone in terminal-tile.js and architecture-invariants, and that is what the code does: terminal-split.js and canOpenTileGrid in tile-grid.js only compare window.innerWidth with SPLIT_PANE_MIN_WIDTH. CLAUDE.md's Split-pane line now reads "desktop-only at 1180px (width alone, so a wide Android tablet clears it)", in step with the Tile grid line, and the tile-grid-plan non-goal names phones only and says a wide tablet or an unfolded foldable in landscape can reach the grid, pointing at the keyboard exception below it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 2 +- docs/tile-grid-plan.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f142a8f2..19b47262 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,7 +276,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) +**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) **Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE with the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) of `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order, the active one focused; trimmed or filled by `tileGridSetForCount`, a stored grid re-formed into its cells by `reformTileCells`), never a menu; right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot <cliId>` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index da30d020..c568f20f 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -215,8 +215,10 @@ no `+`, owner decision 9). ## Non-goals (v1) -- Phones and tablets. The grid is desktop-only, gated at 1180 px like the - split (`SPLIT_PANE_MIN_WIDTH`) and the home rail (`HOME_SESSIONS_MIN_WIDTH`). +- Phones. The grid is gated on width alone at 1180 px like the split + (`SPLIT_PANE_MIN_WIDTH`) and the home rail (`HOME_SESSIONS_MIN_WIDTH`); a + wide tablet, or a large foldable unfolded in landscape, can reach it (see the + keyboard exception below). - More than 9 tiles. - WebGL rendering inside tiles (see "Rendering" below). - Full parity with the main terminal's touch and IME features: local-echo