diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index da4dba17..1e244518 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -3462,6 +3462,60 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Which 'content' taps should DISMISS the mobile keyboard. Expandable + * readbacks, tool results and decision rows are TUI-owned: tapping them acts + * on the CLI, so popping the keyboard there is wrong. An inert transcript row + * still sends its mouse report, but must keep the keyboard reachable — + * touchstart's preventDefault cancels the compatibility click that would + * otherwise focus xterm, so focus has to be restored explicitly. + */ + _isActionableMobileTerminalTap(clientX, clientY) { + if (document.body?.classList?.contains('terminal-action-pending')) return true; + + const pos = this._clientPointToCell(clientX, clientY); + const buffer = this.terminal?.buffer?.active; + if (!pos || !buffer?.getLine) return false; + + const rows = Math.max(1, this.terminal.rows || 1); + const lines = []; + const wrappedRows = []; + for (let row = 0; row < rows; row++) { + const line = buffer.getLine(buffer.viewportY + row); + lines.push(line?.translateToString?.(true) || ''); + wrappedRows.push(Boolean(line?.isWrapped)); + } + + const tappedRow = pos.row - 1; + let logicalLineStart = tappedRow; + while (logicalLineStart > 0 && wrappedRows[logicalLineStart]) logicalLineStart--; + let logicalLineEnd = tappedRow; + while (logicalLineEnd + 1 < rows && wrappedRows[logicalLineEnd + 1]) logicalLineEnd++; + const tappedLine = lines.slice(logicalLineStart, logicalLineEnd + 1).join(''); + + // Match the AFFORDANCE a CLI prints, not the row's title text: an + // expandable readback, tool result or status row advertises how to act on + // it ("ctrl+r to expand", "tap to collapse", "esc to interrupt"). Keying on + // titles instead would only recognise the exact strings a fixture happens + // to use, and would let a real readback keep the keyboard open. + // + // The hint sits on its own row, so a readback's TITLE row — the one a + // finger actually lands on — carries no affordance text itself. Look at the + // adjacent row too, which is how these blocks are laid out in practice. + const affordance = + /\b(?:ctrl\+\w+|tap|click|enter|esc)\b[^.]{0,24}\bto\s+(?:expand|collapse|view|open|interrupt|see)\b/i; + const blockStart = Math.max(0, logicalLineStart - 1); + const blockEnd = Math.min(rows - 1, logicalLineEnd + 1); + for (let row = blockStart; row <= blockEnd; row++) { + if (affordance.test(lines[row])) return true; + } + if (/^\s*[•·]\s*Working\b/i.test(tappedLine)) return true; + + const hasMenuPrompt = lines.some((line) => /^\s*[❯›]\s+\d+[.)]\s/.test(line)); + const hasMenuChoice = lines.some((line) => /^\s+\d+[.)]\s/.test(line)); + return hasMenuPrompt && hasMenuChoice; + }, + _focusMobileTerminalInput() { this._syncMobileHelperTextareaToCursor(); const cjkInput = document.getElementById('cjkInput'); @@ -3493,7 +3547,7 @@ Object.assign(CodemanApp.prototype, { this._sendSyntheticSgrTap(touch.clientX, touch.clientY); } - if (intent === 'content') { + if (intent === 'content' && this._isActionableMobileTerminalTap(touch.clientX, touch.clientY)) { // 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(); diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 6ea9accd..e583c21b 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -756,6 +756,205 @@ describe('Virtual Keyboard', () => { it('focuses the terminal helper textarea when the terminal is tapped', async () => { await page.evaluate(() => { + + it('collapses a terminal readback without focusing the hidden textarea', async () => { + const point = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-readback-tap-test'; + app.sessions.set('mobile-readback-tap-test', { + id: 'mobile-readback-tap-test', + mode: 'codex', + status: 'running', + }); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app.terminal.reset(); + await new Promise((resolve) => + app.terminal.write('Agent readback\r\n tap to collapse\r\n\r\n› ask', resolve) + ); + app.terminal.focus(); + + const screen = app.terminal.element?.querySelector('.xterm-screen'); + const cell = app.terminal._core?._renderService?.dimensions?.css?.cell; + const rect = screen?.getBoundingClientRect(); + if (!rect || !cell?.width || !cell?.height) return null; + return { + x: rect.left + cell.width * 2, + y: rect.top + cell.height / 2, + }; + }); + expect(point).not.toBeNull(); + + await page.touchscreen.tap(point!.x, point!.y); + + const state = await page.evaluate(() => ({ + activeClass: document.activeElement?.className, + sentInputs: window.__sentInputs, + })); + expect(state.activeClass).not.toContain('xterm-helper-textarea'); + expect(state.sentInputs).toHaveLength(1); + 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 () => { + const point = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-claude-transcript-tap-test'; + app.sessions.set('mobile-claude-transcript-tap-test', { + id: 'mobile-claude-transcript-tap-test', + mode: 'claude', + cliVersion: '2.1.220', + status: 'working', + }); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app.terminal.reset(); + await new Promise((resolve) => + app.terminal.write( + 'Transcript row one\r\nTranscript row two\r\nTranscript row three\r\nTranscript row four\r\nTranscript row five\r\nTranscript row six\r\nTranscript row seven\r\nTranscript row eight\r\nTranscript row nine\r\nTranscript row ten\r\n\r\n❯ ', + resolve + ) + ); + app.terminal.focus(); + + const screen = app.terminal.element?.querySelector('.xterm-screen'); + const cell = app.terminal._core?._renderService?.dimensions?.css?.cell; + const rect = screen?.getBoundingClientRect(); + if (!screen || !rect || !cell?.width || !cell?.height) return null; + const cursorRow = app.terminal.buffer.active.cursorY; + const transcriptRow = Math.max(1, Math.floor(cursorRow / 2)); + const x = rect.left + cell.width * 2; + const y = rect.top + cell.height * (transcriptRow + 0.5); + return { + x, + y, + intent: app._classifyMobileTerminalTap(x, y), + activeClass: document.activeElement?.className, + }; + }); + expect(point).toEqual( + expect.objectContaining({ + intent: 'content', + activeClass: expect.stringContaining('xterm-helper-textarea'), + }) + ); + + await page.touchscreen.tap(point!.x, point!.y); + + const activeClass = await page.evaluate(() => document.activeElement?.className); + expect(activeClass).toContain('xterm-helper-textarea'); + }); + + it('prevents Claude subagent status taps from opening the hidden keyboard input', async () => { + const point = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-claude-subagent-tap-test'; + app.sessions.set('mobile-claude-subagent-tap-test', { + id: 'mobile-claude-subagent-tap-test', + mode: 'claude', + cliVersion: '2.1.220', + status: 'working', + }); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + app.hideWelcome(); + app.terminal.reset(); + const statusRow = Math.max(0, app.terminal.rows - 2); + await new Promise((resolve) => + app.terminal.write( + `${'\r\n'.repeat(statusRow)}• Working (1m 50s • esc to interrupt) · 1 background teammate`, + resolve + ) + ); + app.terminal.focus(); + + const screen = app.terminal.element?.querySelector('.xterm-screen'); + const cell = app.terminal._core?._renderService?.dimensions?.css?.cell; + const rect = screen?.getBoundingClientRect(); + if (!screen || !rect || !cell?.width || !cell?.height) return null; + const cursorRow = app.terminal.buffer.active.cursorY; + const x = rect.left + cell.width * 2; + const y = rect.top + cell.height * (cursorRow + 0.5); + return { + x, + y, + intent: app._classifyMobileTerminalTap(x, y), + cursorRow, + screenBottom: rect.bottom, + }; + }); + expect(point).toEqual( + expect.objectContaining({ + intent: 'content', + }) + ); + + const dispatch = await page.evaluate(({ x, y }) => { + const target = document.querySelector('#terminalContainer .xterm-screen'); + if (!(target instanceof Element)) { + return { prevented: false, insideTerminal: false, targetClass: null }; + } + const touch = new Touch({ + identifier: 3, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }); + const allowed = target.dispatchEvent( + new TouchEvent('touchstart', { + touches: [touch], + changedTouches: [touch], + bubbles: true, + cancelable: true, + }) + ); + target.dispatchEvent( + new TouchEvent('touchend', { + touches: [], + changedTouches: [touch], + bubbles: true, + cancelable: true, + }) + ); + return { + prevented: !allowed, + insideTerminal: Boolean(target.closest('#terminalContainer')), + targetClass: target.className, + }; + }, point!); + + const state = await page.evaluate(() => ({ + activeClass: document.activeElement?.className, + sentInputs: window.__sentInputs, + })); + expect(dispatch).toEqual( + expect.objectContaining({ + prevented: true, + insideTerminal: true, + }) + ); + expect(state.activeClass).not.toContain('xterm-helper-textarea'); + expect(state.sentInputs).toHaveLength(1); + }); + + it('focuses the terminal helper textarea when the visible prompt is tapped', async () => { + const point = await page.evaluate(async () => { + window.__sentInputs = []; app.activeSessionId = 'mobile-focus-visible-input-test'; app.sessions.set('mobile-focus-visible-input-test', { id: 'mobile-focus-visible-input-test',