diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index aaaf1f4f..e3412fb8 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -53,6 +53,7 @@ // 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; + 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 // H/F, CSI 1~/4~), Insert/Delete/PgUp/PgDn (CSI 2~/3~/5~/6~, optional @@ -180,6 +181,7 @@ KEY_PAGE_DOWN, PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, + TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, }; global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES; global.codemanCurrentXtermTheme = currentXtermTheme; @@ -650,6 +652,8 @@ Object.assign(CodemanApp.prototype, { let didScroll = false; // track whether touchmove fired (tap vs scroll) let touchStartY = 0; + let tapStartedWithTerminalFocus = false; + let tapStartIntentCache = null; const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll container.addEventListener( 'touchstart', @@ -662,6 +666,28 @@ Object.assign(CodemanApp.prototype, { pixelAccum = 0; isTouching = true; didScroll = false; + tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused(); + // Classifying scans the whole viewport with translateToString, and + // this runs at the start of EVERY gesture including scroll drags. + // Cache the result for the touchend of this same gesture rather than + // recomputing it; the cache is keyed on the exact start coordinates + // so a finger that moved re-classifies at its real position. + const touchStartIntent = this._classifyMobileTerminalTap(touchLastX, touchLastY); + tapStartIntentCache = { x: touchLastX, y: touchLastY, intent: touchStartIntent }; + if (touchStartIntent === 'content') { + // Cancel xterm/browser focus before the compatibility click can + // open the OS keyboard. Content taps are re-emitted as SGR on + // touchend. + // + // 'history' is deliberately NOT included. A scrolled-up viewport + // sends nothing, so there is no compatibility click worth + // cancelling — and preventDefault() here, paired with touchend's + // early return, closes both routes to focus at once. Since + // selectSession() ends with scrollToLastNonEmptyLine(), that made + // the keyboard unreachable after every tab switch. + ev.preventDefault(); + this._blurMobileTerminalInput(); + } lastTime = 0; if (scrollFrame) { cancelAnimationFrame(scrollFrame); @@ -669,7 +695,7 @@ Object.assign(CodemanApp.prototype, { } } }, - { passive: true } + { passive: false } ); container.addEventListener( @@ -721,44 +747,19 @@ Object.assign(CodemanApp.prototype, { scrollFrame = requestAnimationFrame(scrollLoop); } if (!didScroll && this.terminal) { - // ── Tap-to-position cursor ────────────────────────────────── - // Synthesize a click from the real touch point so the foreground app - // moves its cursor to the tapped cell (iOS doesn't reliably do this - // itself under touch-action:none). CRITICAL: only when mouse tracking - // is ON. xterm disables its local SelectionService while mouse events - // are active, so the synthetic click is forwarded to the PTY as an SGR - // report (cursor moves). But when tracking is OFF, that same click - // drives xterm's LOCAL selection (detail 1/2/3 → char/word/line) — a - // tap on CJK text would select & copy it instead of positioning. So - // gate strictly on the live mouse-tracking mode. const touch = ev.changedTouches && ev.changedTouches[0]; - const mouseMode = this.terminal.modes?.mouseTrackingMode; - const mouseTrackingOn = !!mouseMode && mouseMode !== 'none'; if (touch) { this._suppressTrustedTapMouseEvents(); - } - if (touch && mouseTrackingOn) { - this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); - } else if (touch && this._sessionUsesServerMouseStrip()) { - // The server strips mouse-tracking DECSETs from claude/codex/gemini - // output (isAltScreenStripMode, session.ts) so the wheel keeps - // scrolling scrollback — which leaves THIS xterm permanently at - // mouseTrackingMode 'none' even though the TUI on the PTY side has - // tracking ON and still understands SGR reports. Encode the report - // ourselves and send it straight to the PTY: no DOM click is - // dispatched, so xterm's local selection can't trigger either. - this._sendSyntheticSgrTap(touch.clientX, touch.clientY); - } - this._syncMobileHelperTextareaToCursor(); - // Route subsequent typing to the right place: keep the CJK input - // field focused when Chinese input is on, otherwise the terminal. - const cjkInput = document.getElementById('cjkInput'); - if (cjkInput?.classList.contains('cjk-input-visible')) { - cjkInput.focus(); - } else { - this.terminal.focus(); + const cached = + tapStartIntentCache && + tapStartIntentCache.x === touch.clientX && + tapStartIntentCache.y === touch.clientY + ? tapStartIntentCache.intent + : null; + this._handleMobileTerminalTap(touch, tapStartedWithTerminalFocus, cached); } } + tapStartedWithTerminalFocus = false; }, { passive: true } ); @@ -769,6 +770,7 @@ Object.assign(CodemanApp.prototype, { isTouching = false; velocity = 0; pixelAccum = 0; + tapStartedWithTerminalFocus = false; }, { passive: true } ); @@ -3373,6 +3375,232 @@ Object.assign(CodemanApp.prototype, { } catch {} }, + _isMobileTerminalInputFocused() { + const active = document.activeElement; + return ( + active === this.terminal?.textarea || + active?.classList?.contains('xterm-helper-textarea') || + active?.id === 'cjkInput' + ); + }, + + /** + * Separate terminal input from TUI-owned content on touch devices. A hidden + * keyboard must not consume taps on expandable readbacks, tool results, or + * decision rows; those taps belong to the foreground CLI. The visible prompt + * row remains the deliberate keyboard target. + */ + _classifyMobileTerminalTap(clientX, clientY) { + if (!this._terminalViewportAtBottom()) return 'history'; + + const pos = this._clientPointToCell(clientX, clientY); + if (!pos || !this.terminal) return 'input'; + + const mouseMode = this.terminal.modes?.mouseTrackingMode; + const mouseTrackingOn = !!mouseMode && mouseMode !== 'none'; + if (!mouseTrackingOn && !this._sessionUsesServerMouseStrip()) return 'input'; + + const buffer = this.terminal.buffer?.active; + if (!buffer?.getLine) return 'input'; + + const rows = Math.max(1, this.terminal.rows || 1); + const lines = []; + const wrappedRows = []; + let hasVisibleContent = false; + for (let row = 0; row < rows; row++) { + const line = buffer.getLine(buffer.viewportY + row); + const text = line?.translateToString?.(true) || ''; + lines.push(text); + wrappedRows.push(Boolean(line?.isWrapped)); + if (text.trim()) hasVisibleContent = true; + } + if (!hasVisibleContent) return 'input'; + + const cursorRow = Math.max(0, Math.min(rows - 1, buffer.cursorY || 0)); + const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; + let promptRow = -1; + let menuSelectionVisible = false; + + if (mode === 'opencode') { + if (lines[cursorRow]?.includes('\u2503')) promptRow = cursorRow; + } else { + for (let row = rows - 1; row >= 0; row--) { + const promptMatch = lines[row].match(/^\s*[❯›]/); + if (!promptMatch) continue; + const tail = lines[row].slice(promptMatch[0].length).trim(); + // A highlighted numbered choice is a menu row, not an editable prompt. + const hasSiblingChoice = lines.some( + (line, choiceRow) => choiceRow !== row && /^\s+\d+[.)]\s/.test(line) + ); + if (/^\d+[.)]\s/.test(tail) && hasSiblingChoice) { + menuSelectionVisible = true; + break; + } + promptRow = row; + break; + } + } + + 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(''); + // Claude's status row is TUI-owned: tapping it opens the teammate view, so it + // must not be treated as a keyboard target. Match the AFFORDANCE, not the + // wording — the bullet and verb are both unstable (claude 2.1.226 prints + // "✻ Cooked for 2m 6s", "✻ Baked for 9m 47s"; earlier builds printed + // "• Working …"), while "esc to interrupt" / "background" are what make the + // row actionable in the first place. + if (mode === 'claude' && /\b(?:esc to interrupt|background)\b/i.test(tappedLine)) { + return 'content'; + } + if (menuSelectionVisible) return 'content'; + if (promptRow >= 0) { + const inputEnd = cursorRow >= promptRow ? cursorRow : promptRow; + if (tappedRow >= promptRow && tappedRow <= inputEnd) return 'input'; + } else if ( + tappedRow === cursorRow || + tappedRow >= + Math.max( + 0, + rows - + window.CodemanTerminalInput + .TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM + ) + ) { + // During redraws a CLI can temporarily omit its prompt marker or place + // the cursor above a status footer. Keep the live cursor and a stable + // lower-screen focus band usable without turning transcript rows above + // that band into keyboard targets. + return 'input'; + } + + return 'content'; + }, + + _blurMobileTerminalInput() { + const active = document.activeElement; + if ( + active === this.terminal?.textarea || + active?.classList?.contains('xterm-helper-textarea') || + active?.id === 'cjkInput' + ) { + active.blur?.(); + } + }, + + /** + * 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) { + 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. + // Keyed on the ACTION VERB, and deliberately not on prose verbs. A CLI hint + // names a key or a gesture ("ctrl+r to expand", "tap to collapse", + // "esc to interrupt"); "click here to open the file" is transcript content + // and must keep the keyboard, so `click` and bare `here` are excluded. + // The hint may sit mid-line — Claude's status row is + // "✻ Cooked for 2m 6s · esc to interrupt" — so this is not anchored. + const affordance = + /\b(?:ctrl\+\w+|shift\+\w+|esc|enter|tab|tap)\s+to\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; + } + // A Claude status row ("✻ Cooked for 2m 6s · esc to interrupt") is caught by + // the affordance above; there is deliberately no verb literal here, because + // the verb is randomised per build. + + 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'); + if (cjkInput?.classList.contains('cjk-input-visible')) { + cjkInput.focus(); + } else { + this.terminal?.focus(); + } + }, + + _handleMobileTerminalTap(touch, startedWithTerminalFocus, cachedIntent = null) { + // A guard bail-out, not a classification: there is nothing to classify. It is + // deliberately NOT 'history', which would claim the viewport was scrolled up. + if (!touch || !this.terminal) return null; + // touchstart already classified this exact point; reuse it rather than paying + // a second full-viewport scan for the same gesture. + const intent = cachedIntent ?? this._classifyMobileTerminalTap(touch.clientX, touch.clientY); + if (intent === 'history') { + // Scrolled up: send NO mouse report — a tap on old output must not be + // delivered to the CLI as a click on whatever row now occupies that cell. + // Focus is a separate question, and the answer is yes: the user tapped the + // terminal, so let them type. Blurring here stranded activeElement on + // with no way back to the keyboard. + this._focusMobileTerminalInput(); + return intent; + } + + const mouseMode = this.terminal.modes?.mouseTrackingMode; + const mouseTrackingOn = !!mouseMode && mouseMode !== 'none'; + const shouldActivate = intent === 'content' || startedWithTerminalFocus; + if (shouldActivate && mouseTrackingOn) { + // xterm's mouse encoder owns live DECSET modes. The synthetic DOM click + // follows the same path as a desktop click. + this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); + } else if (shouldActivate && this._sessionUsesServerMouseStrip()) { + // Claude/Codex/Gemini DECSETs are stripped from the browser stream, so + // report directly to the PTY while retaining local touch scrollback. + this._sendSyntheticSgrTap(touch.clientX, touch.clientY); + } + + 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(); + } else { + this._focusMobileTerminalInput(); + } + return intent; + }, + // ═══════════════════════════════════════════════════════════════ // Synthetic tap → mouse report // ═══════════════════════════════════════════════════════════════ diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 38dc5d22..df0c4cd1 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -845,6 +845,283 @@ describe('Virtual Keyboard', () => { expect(state.sentInputs).toEqual([]); }); + 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', + 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) + ); + (document.activeElement as HTMLElement | null)?.blur?.(); + + 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 * (app.terminal.buffer.active.cursorY + 0.5), + }; + }); + 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).toContain('xterm-helper-textarea'); + expect(state.sentInputs).toEqual([]); + }); + + it('focuses the live Claude cursor when a redraw omits the prompt glyph', async () => { + const point = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-focus-promptless-claude-test'; + app.sessions.set('mobile-focus-promptless-claude-test', { + id: 'mobile-focus-promptless-claude-test', + mode: 'claude', + status: 'running', + }); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('Claude response\r\nready for input', resolve)); + (document.activeElement as HTMLElement | null)?.blur?.(); + + 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 * (app.terminal.buffer.active.cursorY + 0.5), + }; + }); + 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).toContain('xterm-helper-textarea'); + expect(state.sentInputs).toEqual([]); + }); + it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => { const calls = await page.evaluate(async () => { app.activeSessionId = 'mobile-touch-scroll-test'; diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 378ba1a7..8562871f 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -6,8 +6,17 @@ import { describe, expect, it, vi } from 'vitest'; function loadTerminalUiHarness() { const CodemanApp = function CodemanApp(this: any) {}; let now = 1_000; + let keyboardVisible = false; + let activeElement: unknown = null; const context = vm.createContext({ window: {}, + document: { + body: { classList: { contains: () => false } }, + get activeElement() { + return activeElement; + }, + getElementById: () => null, + }, CodemanApp, console: { warn: vi.fn(), log: vi.fn() }, _crashDiag: { log: vi.fn() }, @@ -25,6 +34,11 @@ function loadTerminalUiHarness() { MobileDetection: { isTouchDevice: () => true, }, + KeyboardHandler: { + get keyboardVisible() { + return keyboardVisible; + }, + }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, TERMINAL_CHUNK_SIZE: 32 * 1024, }); @@ -38,6 +52,12 @@ function loadTerminalUiHarness() { setNow: (value: number) => { now = value; }, + setKeyboardVisible: (visible: boolean) => { + keyboardVisible = visible; + }, + setActiveElement: (element: unknown) => { + activeElement = element; + }, }; } @@ -55,7 +75,130 @@ function createElementHarness() { }; } +function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new Set()) { + const textarea = { + classList: { contains: (name: string) => name === 'xterm-helper-textarea' }, + blur: vi.fn(), + }; + return { + cols: 80, + rows: lines.length, + modes: { mouseTrackingMode: 'none' }, + buffer: { + active: { + viewportY: 0, + baseY: 0, + cursorY, + getLine: (row: number) => + row >= 0 && row < lines.length + ? { isWrapped: wrappedRows.has(row), translateToString: () => lines[row] } + : undefined, + }, + }, + element: { + querySelector: (selector: string) => + selector === '.xterm-screen' ? { getBoundingClientRect: () => ({ left: 0, top: 0 }) } : null, + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + textarea, + focus: vi.fn(), + }; +} + describe('terminal touch tap mouse guard', () => { + it('recognizes focus only when a terminal input owns the active element', () => { + const { app, setActiveElement } = loadTerminalUiHarness(); + const textarea = { classList: { contains: () => true } }; + app.terminal = { textarea }; + + setActiveElement(null); + expect(app._isMobileTerminalInputFocused()).toBe(false); + + setActiveElement(textarea); + expect(app._isMobileTerminalInputFocused()).toBe(true); + }); + + it('routes a readback row to the TUI while keeping the prompt row as keyboard input', () => { + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'codex' }]]); + app.terminal = createTerminalGrid( + ['Agent readback mentions › inline', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'], + 4 + ); + + expect(app._classifyMobileTerminalTap(9, 1)).toBe('content'); // inline marker is not a prompt + expect(app._classifyMobileTerminalTap(9, 17)).toBe('content'); // row 2: readback + expect(app._classifyMobileTerminalTap(9, 65)).toBe('input'); // row 5: prompt + expect(app._classifyMobileTerminalTap(9, 81)).toBe('content'); // row 6: status + }); + + it('classifies Claude background-agent status as content rather than keyboard input', () => { + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]); + app.terminal = createTerminalGrid( + ['', '', '', '• Working (1m 50s • esc to ', 'interrupt) · 1 background teammate', ''], + 4, + new Set([4]) + ); + + expect(app._classifyMobileTerminalTap(9, 65)).toBe('content'); + }); + + it('keeps the live cursor focusable when Claude temporarily omits its prompt glyph', () => { + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.terminal = createTerminalGrid(['Prior response', '', 'ready for input', '', 'status footer', ''], 2); + + expect(app._classifyMobileTerminalTap(9, 33)).toBe('input'); + expect(app._classifyMobileTerminalTap(9, 1)).toBe('content'); + }); + + it('treats a highlighted numbered choice as TUI content, not an input prompt', () => { + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.terminal = createTerminalGrid(['Would you like to proceed?', '', '❯ 1. Yes', ' 2. No', '', ''], 2); + + expect(app._classifyMobileTerminalTap(9, 33)).toBe('content'); + expect(app._classifyMobileTerminalTap(9, 49)).toBe('content'); + }); + + it('collapses TUI readback content without opening or retaining the keyboard', () => { + const { app, setActiveElement } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'codex' }]]); + app.terminal = createTerminalGrid( + ['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'], + 4 + ); + app._sendInputAsync = vi.fn(); + setActiveElement(app.terminal.textarea); + + expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 17 }, true)).toBe('content'); + expect(app._sendInputAsync).toHaveBeenCalledWith('sess-1', '\x1b[<0;2;2M\x1b[<0;2;2m'); + expect(app.terminal.textarea.blur).toHaveBeenCalledOnce(); + expect(app.terminal.focus).not.toHaveBeenCalled(); + }); + + it('keeps the first prompt tap focus-only so it cannot activate a CLI row', () => { + const { app, setActiveElement } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'codex' }]]); + app.terminal = createTerminalGrid( + ['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'], + 4 + ); + app._sendInputAsync = vi.fn(); + setActiveElement(null); + + expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, false)).toBe('input'); + expect(app._sendInputAsync).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();