diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 323f5f03..bf9e327e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -664,6 +664,14 @@ Object.assign(CodemanApp.prototype, { isTouching = true; didScroll = false; 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); @@ -671,7 +679,7 @@ Object.assign(CodemanApp.prototype, { } } }, - { passive: true } + { passive: false } ); container.addEventListener( @@ -690,6 +698,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; @@ -3368,11 +3377,13 @@ Object.assign(CodemanApp.prototype, { 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'; @@ -3403,6 +3414,17 @@ Object.assign(CodemanApp.prototype, { } 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; @@ -3652,6 +3674,15 @@ Object.assign(CodemanApp.prototype, { return true; }, + // 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 4fbbae6a..ccfe96b0 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -799,6 +799,101 @@ describe('Virtual Keyboard', () => { 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 = []; @@ -982,6 +1077,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 6d45d544..0a21dd9f 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -75,7 +75,7 @@ function createElementHarness() { }; } -function createTerminalGrid(lines: string[], cursorY: number) { +function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new Set()) { const textarea = { classList: { contains: (name: string) => name === 'xterm-helper-textarea' }, blur: vi.fn(), @@ -90,7 +90,9 @@ function createTerminalGrid(lines: string[], cursorY: number) { baseY: 0, cursorY, getLine: (row: number) => - row >= 0 && row < lines.length ? { translateToString: () => lines[row] } : undefined, + row >= 0 && row < lines.length + ? { isWrapped: wrappedRows.has(row), translateToString: () => lines[row] } + : undefined, }, }, element: { @@ -131,6 +133,19 @@ describe('terminal touch tap mouse guard', () => { 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('treats a highlighted numbered choice as TUI content, not an input prompt', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; @@ -518,6 +533,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';