diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index bf9e327e..da4dba17 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; @@ -3429,10 +3431,20 @@ Object.assign(CodemanApp.prototype, { if (promptRow >= 0) { const inputEnd = cursorRow >= promptRow ? cursorRow : promptRow; if (tappedRow >= promptRow && tappedRow <= inputEnd) return 'input'; - } else if (tappedRow === cursorRow && cursorRow >= rows - 3) { - // During redraws a CLI can temporarily omit its prompt marker. Keep the - // live cursor row usable without turning arbitrary transcript rows into - // keyboard targets. + } 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'; } diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index ccfe96b0..6ea9accd 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -754,188 +754,25 @@ describe('Virtual Keyboard', () => { expect(state?.bodyClass).toBe(false); }); - 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 = []; + it('focuses the terminal helper textarea when the terminal is tapped', async () => { + await page.evaluate(() => { 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); + await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } }); - const state = await page.evaluate(() => ({ - activeClass: document.activeElement?.className, - sentInputs: window.__sentInputs, - })); - expect(state.activeClass).toContain('xterm-helper-textarea'); - expect(state.sentInputs).toEqual([]); + const activeClass = await page.evaluate(() => document.activeElement?.className); + expect(activeClass).toContain('xterm-helper-textarea'); }); // Regression guard for the phone-keyboard blocker reduced in #173 and re-hit @@ -1008,6 +845,44 @@ describe('Virtual Keyboard', () => { 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'; @@ -1077,77 +952,6 @@ 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 0a21dd9f..e1139038 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -146,6 +146,16 @@ describe('terminal touch tap mouse guard', () => { 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';