diff --git a/src/web/public/voice-input.js b/src/web/public/voice-input.js index f792242d..741296ab 100644 --- a/src/web/public/voice-input.js +++ b/src/web/public/voice-input.js @@ -1007,15 +1007,30 @@ const VoiceInput = { } else { // Direct mode: inject into local echo overlay if available, else send to PTY. // The overlay belongs to the ACTIVE session's terminal, so text dictated - // for any other session must not be typed into it. + // for any other session must not be typed into it. It also belongs to the + // MAIN terminal, which the tile grid parks (display: none): with tiles + // open the text would sit in an invisible overlay the focused tile never + // sees, so it goes straight to the session instead. const isActive = target === app.activeSessionId; - if (isActive && app._localEchoEnabled && app._localEchoOverlay) { + const tilesOpen = !!app._tilesOwnTerminal?.(); + if (isActive && !tilesOpen && app._localEchoEnabled && app._localEchoOverlay) { app._localEchoOverlay.appendText(trimmed); } else { this._sendToTarget(target, trimmed).catch(() => {}); } this._showVoiceSendBtn(); - setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150); + setTimeout(() => { + if (!isActive) return; + // With the grid open the keyboard belongs to the focused tile; the + // parked main terminal cannot take focus. Split view keeps the main + // terminal, even if Pane B took focus meanwhile (it is not the target). + if (app._tilesOwnTerminal?.()) { + const pane = app._focusedPane?.(); + if (pane?.sessionId === target) pane.terminal?.focus(); + } else if (app.terminal) { + app.terminal.focus(); + } + }, 150); } }, @@ -1045,9 +1060,10 @@ const VoiceInput = { if (!target) return; // Simulate Enter key: if local echo is active, flush its buffer + send \r; // otherwise just send \r directly to the PTY. Both the overlay and the - // predictions belong to the ACTIVE session's terminal, so a dictation - // for another session just sends its Enter there. - if (target !== app.activeSessionId) { + // predictions belong to the ACTIVE session's MAIN terminal, so a + // dictation for another session, or for a tile while the grid has the + // main terminal parked, just sends its Enter there. + if (target !== app.activeSessionId || app._tilesOwnTerminal?.()) { this._sendToTarget(target, '\r').catch(() => {}); } else if (app._localEchoEnabled && app._localEchoOverlay) { const text = app._localEchoOverlay.pendingText || ''; diff --git a/test/voice-input-target.test.ts b/test/voice-input-target.test.ts index 624431b2..f33fb21e 100644 --- a/test/voice-input-target.test.ts +++ b/test/voice-input-target.test.ts @@ -8,6 +8,12 @@ * session. The target is now captured in `start()` (through `_focusedPane()`, * so a second terminal pane can claim it later) and every send path uses it. * + * With the tile grid open the main terminal (and with it the local-echo + * overlay) is parked with `display: none`, so direct-mode dictation used to be + * typed into an invisible overlay and never reached the focused tile. While + * `_tilesOwnTerminal()` is true the overlay is skipped and the keyboard goes + * back to the focused tile, not the parked main terminal. + * * Loaded via `vm` with a stubbed `app` (no jsdom). */ import { readFileSync } from 'node:fs'; @@ -27,10 +33,13 @@ type Voice = { _targetSessionId: string | null; }; -function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string } = {}) { +type Pane = { sessionId: string; isPrimary?: boolean; terminal?: { focus: () => void } }; + +function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string; tiles?: boolean; pane?: Pane } = {}) { const sendInput = vi.fn(async () => {}); const sendInputAsync = vi.fn(); const appendText = vi.fn(); + const overlayClear = vi.fn(); const showToast = vi.fn(); const gear = { classList: { contains: () => false, add: vi.fn(), remove: vi.fn() }, @@ -53,9 +62,19 @@ function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string showToast, terminal: { focus: vi.fn() }, _localEchoEnabled: !!opts.localEcho, - _localEchoOverlay: opts.localEcho ? { appendText, pendingText: '', clear: vi.fn() } : null, + _localEchoOverlay: opts.localEcho + ? { appendText, pendingText: '', clear: overlayClear, suppressBufferDetection: vi.fn() } + : null, + _predictiveEcho: { clearPredictions: vi.fn() }, }; if (opts.focused) app._focusedPane = () => ({ sessionId: opts.focused }); + // A full pane record, as terminal-ui.js _focusedPane() returns it. Reassign + // app._focusedPane in a test to move focus between panes. + if (opts.pane) { + const pane = opts.pane; + app._focusedPane = () => pane; + } + if (opts.tiles) app._tilesOwnTerminal = () => true; const context = vm.createContext({ console, setTimeout: (fn: () => void) => fn(), @@ -81,7 +100,12 @@ function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string // Recording itself is out of scope: start() only has to pick the target. voice._resolveProvider = () => 'webspeech'; voice._startWebSpeech = vi.fn(); - return { voice, app, sendInput, sendInputAsync, appendText, showToast, gear }; + return { voice, app, sendInput, sendInputAsync, appendText, overlayClear, showToast, gear }; +} + +function clickGreenSend(gear: { addEventListener: { mock: { calls: unknown[][] } } }) { + const handler = gear.addEventListener.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as () => void; + handler(); } describe('dictation target', () => { @@ -163,6 +187,63 @@ describe('dictation target', () => { expect(showToast).toHaveBeenCalledWith('That session has closed; dictation not sent', 'warning'); }); + it('with the tile grid open, dictation goes to the focused tile, not the parked overlay', () => { + const tileTerminal = { focus: vi.fn() }; + const { voice, app, appendText, sendInput, sendInputAsync } = load({ + localEcho: true, + tiles: true, + pane: { sessionId: 'session-a', isPrimary: false, terminal: tileTerminal }, + }); + voice.start(); + + voice._insertText('into the tile'); + + expect(appendText).not.toHaveBeenCalled(); + expect(sendInput).toHaveBeenCalledWith('into the tile'); + expect(sendInputAsync).not.toHaveBeenCalled(); + // The keyboard goes back to the tile; the main terminal is display: none. + expect(tileTerminal.focus).toHaveBeenCalled(); + expect((app.terminal as { focus: ReturnType }).focus).not.toHaveBeenCalled(); + }); + + it('with the tile grid open, the green send button sends only Enter to the tile session', () => { + const tileTerminal = { focus: vi.fn() }; + const { voice, app, gear, sendInput, overlayClear } = load({ + localEcho: true, + tiles: true, + pane: { sessionId: 'session-a', isPrimary: false, terminal: tileTerminal }, + }); + voice.start(); + voice._insertText('ship it'); + // Something stale in the parked main overlay must not ride along. + (app._localEchoOverlay as { pendingText: string }).pendingText = 'stale main-terminal text'; + + clickGreenSend(gear); + + expect(overlayClear).not.toHaveBeenCalled(); + expect(sendInput).not.toHaveBeenCalledWith('stale main-terminal text'); + expect(sendInput).toHaveBeenLastCalledWith('\r'); + // The parked main terminal's predictions are not this pane's. + expect( + (app._predictiveEcho as { clearPredictions: ReturnType }).clearPredictions + ).not.toHaveBeenCalled(); + }); + + it('split view is unchanged: a Pane A dictation keeps the overlay and refocuses the main terminal', () => { + const paneB = { focus: vi.fn() }; + const { voice, app, appendText, sendInput } = load({ localEcho: true, focused: 'session-a' }); + voice.start(); + // The user clicked into Pane B while speaking. + app._focusedPane = () => ({ sessionId: 'session-b', isPrimary: false, terminal: paneB }); + + voice._insertText('for pane a'); + + expect(appendText).toHaveBeenCalledWith('for pane a'); + expect(sendInput).not.toHaveBeenCalled(); + expect((app.terminal as { focus: ReturnType }).focus).toHaveBeenCalled(); + expect(paneB.focus).not.toHaveBeenCalled(); + }); + it('refuses to start with no session at all', () => { const { voice, app, showToast } = load(); app.activeSessionId = null;