diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 256603e7..f42adea9 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -115,6 +115,7 @@ // Whether the pointer is over a link in THIS pane (the primary pane's own // flag, app._linkHovered, belongs to its terminal alone). this._linkHovered = false; + this._onFocusIn = null; } async connect() { @@ -151,6 +152,11 @@ this._installWheelListener(); + // Focusing this terminal makes it the pane the keyboard is in, so the + // app-level shortcuts, voice and paste act on it (app._focusedPane). + this._onFocusIn = () => global.app?._noteFocusedTile?.(this); + this.terminal.textarea?.addEventListener('focus', this._onFocusIn); + this.terminal.onData((data) => this._onTerminalData(data)); // Pane B has no gates of its own by default, so every app-level chord @@ -850,6 +856,13 @@ this._onWheel = null; } this._detachSocket(); + if (this._onFocusIn) { + this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn); + this._onFocusIn = null; + } + // 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); if (this.terminal) { this.terminal.dispose(); this.terminal = null; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 8a0c9f08..88cc6a54 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -544,6 +544,9 @@ Object.assign(CodemanApp.prototype, { this._installMobileTapMouseGuard(); this._installShiftDragSelection(); this._installTouchSelectionFocusGuard(); + // Focus coming back to the primary terminal ends a second pane's claim on + // the keyboard (see _focusedPane). + this.terminal.textarea?.addEventListener('focus', () => this._noteFocusedTile(null)); // Let xterm's CompositionHelper own IME key events. In particular, a // non-composing keyCode 229 is how an active IME commits numbers and @@ -4498,15 +4501,27 @@ Object.assign(CodemanApp.prototype, { // ═══════════════════════════════════════════════════════════════ /** - * The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary }`. + * The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary, tile }`. * * The ONE place a shortcut, voice or paste should ask "which pane?", rather * than reading `this.terminal` / `this.activeSessionId`, which always mean the - * primary pane. Today it always answers with the primary pane; the split - * pane's second terminal plugs in here once it tracks its own focus. + * primary pane. It answers with the pane whose terminal was focused LAST, not + * with `document.activeElement`: clicking the mic or a header button moves + * DOM focus to that button, and the dictation it starts still belongs to the + * pane the user was typing in. A second pane (the split pane's Pane B) claims + * it from its own terminal's focus; the primary terminal's focus gives it back. */ _focusedPane() { - return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true }; + const tile = this._focusedTile; + if (tile && !tile._destroyed && tile.terminal) { + return { terminal: tile.terminal, sessionId: tile.sessionId, isPrimary: false, tile }; + } + return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true, tile: null }; + }, + + /** Record which second pane holds the keyboard (null: the primary terminal). */ + _noteFocusedTile(tile) { + this._focusedTile = tile || null; }, /** @@ -4519,8 +4534,11 @@ Object.assign(CodemanApp.prototype, { if (this._splitPane?.terminal) fn(this._splitPane); }, + // Clears the pane the keyboard is in. The chord itself also reaches that + // pane's xterm (the capture handler only preventDefault()s), so the ^L lands + // in the same pane whose display is cleared, never a different one. clearTerminal() { - this.terminal.clear(); + this._focusedPane().terminal?.clear(); }, /** Insert editable text at the active prompt without pressing Enter. */ @@ -4584,6 +4602,14 @@ Object.assign(CodemanApp.prototype, { * Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation"). */ async restoreTerminalSize() { + // A second pane owns its own geometry: refit it and force its PTY to the + // size it renders at (TerminalTile.fit), whatever another device set. + const pane = this._focusedPane(); + if (!pane.isPrimary) { + pane.tile.fit({ force: true }); + this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success'); + return; + } if (!this.activeSessionId) { this.showToast('No active session', 'warning'); return; diff --git a/test/focused-pane-shortcuts.test.ts b/test/focused-pane-shortcuts.test.ts new file mode 100644 index 00000000..562f5dd4 --- /dev/null +++ b/test/focused-pane-shortcuts.test.ts @@ -0,0 +1,150 @@ +/** + * @fileoverview App-level terminal shortcuts act on the pane the keyboard is in. + * + * With the split pane open, the global shortcut handler (app.js, capture phase) + * used to resolve every terminal action against the PRIMARY pane: Ctrl+L typed + * into Pane B cleared Pane A's display while xterm sent the ^L into Pane B's + * PTY, and Ctrl+Shift+R restored Pane A's size. `_focusedPane()` (terminal-ui.js) + * now answers with the pane whose terminal was focused last, and the actions + * that are about a TERMINAL (clear, restore size) go through it. + * + * Ctrl+W deliberately does NOT follow focus yet: it kills a session outright, + * with no confirm, so moving it changes which agent a muscle-memory press kills + * (docs/tile-grid-plan.md, decision 5). It stays on the active session. + * + * Real code under test: constants.js + terminal-ui.js in a `vm` context. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); + +function loadApp() { + const CodemanApp = function CodemanApp(this: unknown) {}; + const context = vm.createContext({ + window: {}, + document: { + body: { classList: { contains: () => false } }, + activeElement: null, + addEventListener: vi.fn(), + getElementById: () => null, + }, + CodemanApp, + console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn(), error: vi.fn() }, + _crashDiag: { log: vi.fn() }, + performance: { now: () => 0 }, + requestAnimationFrame: () => 1, + setTimeout: () => 1, + MobileDetection: { isTouchDevice: () => false }, + DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + TERMINAL_CHUNK_SIZE: 32 * 1024, + }); + vm.runInContext(read('constants.js'), context, { filename: 'constants.js' }); + vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' }); + const app = new (CodemanApp as unknown as new () => Record)(); + app.activeSessionId = 'session-a'; + app.terminal = { clear: vi.fn(), cols: 120, rows: 40 }; + app.showToast = vi.fn(); + app.sendResize = vi.fn(async () => true); + app.getTerminalDimensions = () => ({ cols: 120, rows: 40 }); + return app; +} + +function paneB(overrides: Record = {}) { + return { + sessionId: 'session-b', + terminal: { clear: vi.fn(), cols: 60, rows: 30 }, + fit: vi.fn(), + _destroyed: false, + ...overrides, + }; +} + +describe('_focusedPane()', () => { + it('is the primary pane until another pane claims the keyboard', () => { + const app = loadApp(); + + const pane = app._focusedPane(); + + expect(pane.isPrimary).toBe(true); + expect(pane.terminal).toBe(app.terminal); + expect(pane.sessionId).toBe('session-a'); + }); + + it('is the second pane after its terminal was focused, and the primary again after the primary was', () => { + const app = loadApp(); + const tile = paneB(); + + app._noteFocusedTile(tile); + expect(app._focusedPane()).toMatchObject({ isPrimary: false, sessionId: 'session-b', terminal: tile.terminal }); + + app._noteFocusedTile(null); + expect(app._focusedPane().isPrimary).toBe(true); + }); + + it('never answers with a destroyed pane', () => { + const app = loadApp(); + const tile = paneB(); + app._noteFocusedTile(tile); + + tile._destroyed = true; + + expect(app._focusedPane().isPrimary).toBe(true); + }); +}); + +describe('terminal shortcuts follow the focused pane', () => { + it('Ctrl+L clears the focused second pane, not the primary', () => { + const app = loadApp(); + const tile = paneB(); + app._noteFocusedTile(tile); + + app.clearTerminal(); + + expect(tile.terminal.clear).toHaveBeenCalledTimes(1); + expect(app.terminal.clear).not.toHaveBeenCalled(); + }); + + it('Ctrl+L still clears the primary when it holds the keyboard', () => { + const app = loadApp(); + + app.clearTerminal(); + + expect(app.terminal.clear).toHaveBeenCalledTimes(1); + }); + + it("Ctrl+Shift+R forces the focused second pane's size onto its own PTY", async () => { + const app = loadApp(); + const tile = paneB(); + app._noteFocusedTile(tile); + + await app.restoreTerminalSize(); + + expect(tile.fit).toHaveBeenCalledWith({ force: true }); + expect(app.sendResize).not.toHaveBeenCalled(); + expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success'); + }); + + it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => { + const app = loadApp(); + + await app.restoreTerminalSize(); + + expect(app.sendResize).toHaveBeenCalledWith('session-a', { force: true }); + }); +}); + +describe('Ctrl+W stays on the active session (decision 5)', () => { + it('killActiveSession closes activeSessionId and never consults the focused pane', () => { + const appSource = read('app.js'); + const body = appSource.slice( + appSource.indexOf('async killActiveSession() {'), + appSource.indexOf('async killAllSessions() {') + ); + + expect(body).toContain('await this.closeSession(this.activeSessionId);'); + expect(body).not.toContain('_focusedPane'); + }); +}); diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts index cba31c62..9dcbe55e 100644 --- a/test/terminal-tile-input.test.ts +++ b/test/terminal-tile-input.test.ts @@ -91,6 +91,18 @@ class FakeTerminal { this.dataCb = cb; } keyHandler: ((ev: Record) => boolean) | null = null; + focusListeners: Array<() => void> = []; + textarea = { + addEventListener: (type: string, fn: () => void) => { + if (type === 'focus') this.focusListeners.push(fn); + }, + removeEventListener: (type: string, fn: () => void) => { + if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn); + }, + }; + focusTextarea() { + for (const fn of this.focusListeners) fn(); + } attachCustomKeyEventHandler(fn: (ev: Record) => boolean) { this.keyHandler = fn; } @@ -199,6 +211,13 @@ type Tile = { }; const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile; +/** + * Every tile a test creates, destroyed after it. A tile closed with real timers + * schedules a real reconnect, and one firing during a LATER test opens a socket + * there (FakeSocket.instances is shared), which flaked under full-suite load. + */ +const liveTiles: Tile[] = []; + async function connectTile(app: App, opts: Record = {}) { windowStub.app = app; const tile = new TerminalTile( @@ -206,12 +225,14 @@ async function connectTile(app: App, opts: Record = {}) { { addEventListener: vi.fn(), removeEventListener: vi.fn() }, { mode: 'claude', ...opts } ); + liveTiles.push(tile); await tile.connect(); const ws = FakeSocket.instances.at(-1)!; return { tile, ws, term: FakeTerminal.last! }; } afterEach(() => { + for (const tile of liveTiles.splice(0)) tile.destroy(); vi.useRealTimers(); }); @@ -640,3 +661,18 @@ describe('TerminalTile links and paste follow THIS pane', () => { expect(paste).not.toHaveBeenCalled(); }); }); + +describe('TerminalTile claims the keyboard for the app-level shortcuts', () => { + it('focusing its terminal makes it the focused pane; destroy() hands the keyboard back', async () => { + const app = makeApp(); + const { tile, term } = await connectTile(app); + expect(app._focusedTile ?? null).toBeNull(); + + term.focusTextarea(); + expect(app._focusedTile).toBe(tile); + + tile.destroy(); + expect(app._focusedTile).toBeNull(); + expect(term.focusListeners).toEqual([]); + }); +});