diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 3a9de819..17d8bb00 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -23,6 +23,7 @@ // short window, only the app's synthetic tap-to-position mouse event should // reach xterm. const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450; + const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4; function isTerminalQueryResponse(data) { return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data); @@ -62,6 +63,7 @@ shouldSuppressTerminalQueryResponse, USER_SCROLL_STICKY_SUPPRESS_MS, TOUCH_COMPAT_MOUSE_SUPPRESS_MS, + TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, }; global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES; global.codemanCurrentXtermTheme = currentXtermTheme; @@ -421,6 +423,8 @@ Object.assign(CodemanApp.prototype, { let lastTime = 0; let scrollFrame = null; let isTouching = false; + let touchForwardsToApp = false; + let touchLastX = 0; const scrollLoop = (timestamp) => { const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; @@ -429,12 +433,20 @@ Object.assign(CodemanApp.prototype, { if (!isTouching && Math.abs(velocity) > 0.3) { // Momentum phase — convert pixel velocity to lines const lines = Math.round(velocity / cellHeight()); - if (lines !== 0) this.terminal.scrollLines(lines); + if (lines !== 0) { + if (touchForwardsToApp) { + this._sendSyntheticSgrWheel(touchLastX, touchLastY, lines); + } else { + this._noteTerminalUserScroll(lines); + this.terminal.scrollLines(lines); + } + } velocity *= 0.92; scrollFrame = requestAnimationFrame(scrollLoop); } else if (!isTouching) { scrollFrame = null; velocity = 0; + touchForwardsToApp = false; } else { scrollFrame = requestAnimationFrame(scrollLoop); } @@ -445,17 +457,29 @@ Object.assign(CodemanApp.prototype, { let didScroll = false; // track whether touchmove fired (tap vs scroll) let touchStartY = 0; + let tapStartedWithTerminalFocus = false; const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll container.addEventListener( 'touchstart', (ev) => { if (ev.touches.length === 1) { + touchLastX = ev.touches[0].clientX; touchLastY = ev.touches[0].clientY; touchStartY = touchLastY; velocity = 0; pixelAccum = 0; isTouching = true; didScroll = false; + touchForwardsToApp = this._shouldForwardTouchScrollToApp(); + tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused(); + const touchStartIntent = this._classifyMobileTerminalTap(touchLastX, touchLastY); + if (touchStartIntent !== 'input') { + // Cancel xterm/browser focus before the compatibility click can + // open the OS keyboard. Content taps are re-emitted as SGR on + // touchend; history taps deliberately remain inert. + ev.preventDefault(); + this._blurMobileTerminalInput(); + } lastTime = 0; if (scrollFrame) { cancelAnimationFrame(scrollFrame); @@ -463,7 +487,7 @@ Object.assign(CodemanApp.prototype, { } } }, - { passive: true } + { passive: false } ); container.addEventListener( @@ -482,6 +506,7 @@ Object.assign(CodemanApp.prototype, { // fling, so a jittery tap would both position the cursor AND scroll. if (!didScroll) return; ev.preventDefault(); + touchLastX = ev.touches[0].clientX; const delta = touchLastY - touchY; // positive = scroll down pixelAccum += delta; velocity = delta * 1.2; @@ -490,8 +515,12 @@ Object.assign(CodemanApp.prototype, { const ch = cellHeight(); const lines = Math.trunc(pixelAccum / ch); if (lines !== 0) { - this._noteTerminalUserScroll(lines); - this.terminal.scrollLines(lines); + if (touchForwardsToApp) { + this._sendSyntheticSgrWheel(touchLastX, touchY, lines); + } else { + this._noteTerminalUserScroll(lines); + this.terminal.scrollLines(lines); + } pixelAccum -= lines * ch; } } @@ -507,44 +536,13 @@ 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(); + this._handleMobileTerminalTap(touch, tapStartedWithTerminalFocus); } } + tapStartedWithTerminalFocus = false; }, { passive: true } ); @@ -555,6 +553,8 @@ Object.assign(CodemanApp.prototype, { isTouching = false; velocity = 0; pixelAccum = 0; + touchForwardsToApp = false; + tapStartedWithTerminalFocus = false; }, { passive: true } ); @@ -2629,6 +2629,163 @@ 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'; + + // Permission/elicitation prompts own the full live terminal until answered. + if (document.body?.classList?.contains('terminal-action-pending')) return 'content'; + + 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(''); + if ( + mode === 'claude' && + /^\s*[•·]\s*Working\b.*(?:background|esc to interrupt)/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?.(); + } + }, + + _focusMobileTerminalInput() { + this._syncMobileHelperTextareaToCursor(); + const cjkInput = document.getElementById('cjkInput'); + if (cjkInput?.classList.contains('cjk-input-visible')) { + cjkInput.focus(); + } else { + this.terminal?.focus(); + } + }, + + _handleMobileTerminalTap(touch, startedWithTerminalFocus) { + if (!touch || !this.terminal) return 'history'; + const intent = this._classifyMobileTerminalTap(touch.clientX, touch.clientY); + if (intent === 'history') { + this._blurMobileTerminalInput(); + 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') { + // 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 // ═══════════════════════════════════════════════════════════════ @@ -2760,6 +2917,15 @@ Object.assign(CodemanApp.prototype, { return this._terminalViewportAtBottom(); }, + // Claude keeps most transcript history inside its own TUI rather than xterm + // scrollback. On verified versions, route a touch drag through the same SGR + // wheel path as desktop. Codex keeps the existing local touch behavior. + _shouldForwardTouchScrollToApp() { + const session = this.sessions?.get(this.activeSessionId); + if (session?.mode !== 'claude') return false; + return this._shouldForwardWheelToApp({ shiftKey: false }); + }, + // Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer // cell. Reports are coalesced into one fire-and-forget write per ~40ms: a // trackpad emits dozens of wheel events per second and each send becomes a diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 5032f653..d403cbf3 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -605,25 +605,226 @@ describe('Virtual Keyboard', () => { expect(state?.bodyClass).toBe(false); }); - 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('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.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } }); + 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 activeClass = await page.evaluate(() => document.activeElement?.className); - expect(activeClass).toContain('xterm-helper-textarea'); + 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 () => { @@ -695,6 +896,77 @@ describe('Virtual Keyboard', () => { expect(calls.some((lines) => lines !== 0)).toBe(true); }); + it('routes Claude touch drags to its transcript without moving local xterm history', async () => { + const result = await page.evaluate(async () => { + app.activeSessionId = 'mobile-claude-scroll-test'; + app.sessions.set('mobile-claude-scroll-test', { + id: 'mobile-claude-scroll-test', + mode: 'claude', + cliVersion: '2.1.220', + status: 'running', + }); + app.hideWelcome(); + + const sgrCalls: number[] = []; + const localCalls: number[] = []; + app._sendSyntheticSgrWheel = (_x: number, _y: number, lines: number) => { + sgrCalls.push(lines); + }; + app.terminal.scrollLines = (lines: number) => { + localCalls.push(lines); + }; + + const target = + document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer'); + if (!target) return { sgrCalls, localCalls }; + const rect = target.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const startY = rect.top + Math.min(100, rect.height - 20); + const endY = startY + 100; + + function createTouch(y: number) { + return new Touch({ + identifier: 2, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }); + } + + target.dispatchEvent( + new TouchEvent('touchstart', { + touches: [createTouch(startY)], + changedTouches: [createTouch(startY)], + bubbles: true, + cancelable: true, + }) + ); + target.dispatchEvent( + new TouchEvent('touchmove', { + touches: [createTouch(endY)], + changedTouches: [createTouch(endY)], + bubbles: true, + cancelable: true, + }) + ); + target.dispatchEvent( + new TouchEvent('touchend', { + touches: [], + changedTouches: [createTouch(endY)], + bubbles: true, + cancelable: true, + }) + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + return { sgrCalls, localCalls }; + }); + + expect(result.sgrCalls.some((lines) => lines < 0)).toBe(true); + expect(result.localCalls).toEqual([]); + }); + it('keeps typed phone text in the terminal local echo path', async () => { await page.evaluate(() => { window.__sentInputs = []; diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 7c4ad557..81f7e531 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(); @@ -370,6 +513,25 @@ describe('terminal touch tap mouse guard', () => { expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); }); + it('touch: forwards verified Claude transcript scrolling but keeps Codex touch in local history', () => { + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.terminal = { + modes: { mouseTrackingMode: 'none' }, + buffer: { active: { viewportY: 50, baseY: 50 } }, + }; + + app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]); + expect(app._shouldForwardTouchScrollToApp()).toBe(true); + + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); + expect(app._shouldForwardTouchScrollToApp()).toBe(false); + + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + app.sessions = new Map([['sess-1', { mode: 'codex' }]]); + expect(app._shouldForwardTouchScrollToApp()).toBe(false); + }); + it('wheel: the local-scrollback opt-out pins the plain wheel to local scrollback (issue #154)', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1';