diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index e3412fb8f..46676cb75 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -3595,6 +3595,20 @@ Object.assign(CodemanApp.prototype, { // A synthetic xterm click can focus its helper textarea. Blur after the // report so collapsing a readback never opens or retains the keyboard. this._blurMobileTerminalInput(); + } else if (intent === 'content' && startedWithTerminalFocus) { + // Tapping INERT transcript with the keyboard already up closes it. + // + // Every terminal tap re-focuses, so once the keyboard is open the only way + // to close it is the accessory bar's dismiss chevron. Tapping the + // transcript to get the screen back is the obvious gesture, and nothing + // else claims it: an inert row has no action to trigger, so by this point + // the tap has already done its only other job (the mouse report above). + // + // Scoped to 'content' ON PURPOSE. The prompt row ('input') keeps + // focus-then-position, so a second tap there still places the caret — + // pinned by "keeps the first prompt tap focus-only so it cannot activate a + // CLI row". Toggling there would trade away real capability. + this._blurMobileTerminalInput(); } else { this._focusMobileTerminalInput(); } diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index df0c4cd13..bdf2ee5d1 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -890,7 +890,7 @@ describe('Virtual Keyboard', () => { expect(state.sentInputs[0]).toMatch(/^\x1b\[<0;\d+;1M\x1b\[<0;\d+;1m$/); }); - it('keeps the hidden keyboard input focused after an inert Claude transcript tap', async () => { + it('toggles the keyboard shut on a second inert Claude transcript tap', async () => { const point = await page.evaluate(async () => { window.__sentInputs = []; app.activeSessionId = 'mobile-claude-transcript-tap-test'; @@ -941,8 +941,12 @@ describe('Virtual Keyboard', () => { await page.touchscreen.tap(point!.x, point!.y); + // The setup above leaves the terminal focused, so this tap is the SECOND + // one on an inert row — the case that now closes the keyboard. Previously + // it re-focused, which left the accessory bar's chevron as the only way to + // dismiss. The prompt row is unaffected and still positions the caret. const activeClass = await page.evaluate(() => document.activeElement?.className); - expect(activeClass).toContain('xterm-helper-textarea'); + expect(activeClass).not.toContain('xterm-helper-textarea'); }); it('prevents Claude subagent status taps from opening the hidden keyboard input', async () => { diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 8562871f5..3b7551466 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -199,6 +199,43 @@ describe('terminal touch tap mouse guard', () => { expect(app.terminal.focus).toHaveBeenCalledOnce(); }); + it('closes the keyboard on a second tap of INERT transcript content', () => { + const { app, setActiveElement } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4); + app._sendInputAsync = vi.fn(); + + // Keyboard DOWN: the tap opens it. + setActiveElement(null); + expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, false)).toBe('content'); + expect(app.terminal.focus).toHaveBeenCalledOnce(); + expect(app.terminal.textarea.blur).not.toHaveBeenCalled(); + + // Keyboard UP on the same inert row: the tap closes it. + app.terminal.focus.mockClear(); + setActiveElement(app.terminal.textarea); + expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, true)).toBe('content'); + expect(app.terminal.textarea.blur).toHaveBeenCalledOnce(); + expect(app.terminal.focus).not.toHaveBeenCalled(); + }); + + it('keeps the prompt row focusing rather than toggling, so the caret can still be placed', () => { + // The toggle is scoped to 'content' on purpose: a second tap on the PROMPT + // must still position the cursor. This is the guarantee that makes the + // change safe to make, so it is pinned separately. + const { app, setActiveElement } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ask', ''], 4); + app._sendInputAsync = vi.fn(); + + setActiveElement(app.terminal.textarea); + expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, true)).toBe('input'); + expect(app.terminal.textarea.blur).not.toHaveBeenCalled(); + expect(app.terminal.focus).toHaveBeenCalledOnce(); + }); + it('suppresses browser trusted compatibility mouse events during the tap window', () => { const { app } = loadTerminalUiHarness(); const { element, dispatch } = createElementHarness();