fix(mobile): focus terminal input after TUI touch

This commit is contained in:
Brennan Benson
2026-07-31 17:55:10 -07:00
parent 5738d61fe0
commit fe16936c2b
6 changed files with 31 additions and 12 deletions
@@ -179,9 +179,8 @@ export const TERMINAL_MOUSE_CLICK_DRAG_JS = `
// Why: a dismissing tap only clears the selection (touch parity); it must
// not also open a link or focus the keyboard underneath.
if (gesture.dismissedSelection) return;
// Plain click == touch tap: links and file paths win, then tracking-mode
// click reports, then keyboard focus.
notifyTerminalSurfaceTap(e.clientX, e.clientY);
// Pointer clicks keep their current link, file, TUI mouse, and focus priority.
notifyTerminalSurfaceTap(e.clientX, e.clientY, false);
}, true);
targetSurface.addEventListener('pointercancel', function(e) {
@@ -18,6 +18,7 @@ describe('terminal WebView external mouse click', () => {
expect(bytes.slice(0, 4)).toBe(`${ESC}[M `)
expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`)
expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12))
expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([])
})
it('dismisses an existing selection with a click without focusing the keyboard', () => {
@@ -212,10 +212,12 @@ describe('TerminalWebView scroll routing', () => {
"document.addEventListener('touchend'",
'}, { capture: true, passive: true });'
)
expect(touchEndBlock).toContain('notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y)')
expect(touchEndBlock).toContain(
'notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true)'
)
const tapHandlerBlock = sliceBetween(
'function notifyTerminalSurfaceTap(originX, originY)',
'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)',
"document.addEventListener('touchstart'"
)
expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan(
@@ -229,6 +231,9 @@ describe('TerminalWebView scroll routing', () => {
)
expect(tapHandlerBlock).toContain("notify({ type: 'open-url', url: tappedUrl });")
expect(tapHandlerBlock).toContain("notify({ type: 'terminal-input', bytes: clickInput });")
expect(tapHandlerBlock).toContain(
'if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode()))'
)
expect(tapHandlerBlock).toContain("notify({ type: 'terminal-tap' });")
})
@@ -163,7 +163,7 @@ export const TERMINAL_TAP_DISPATCH_JS = `
selMode !== 'select' &&
Date.now() - tapCandidate.t <= TAP_MAX_MS
) {
notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y);
notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true);
}
clearLongPress();
tapCandidate = null;
@@ -19,12 +19,12 @@ function bodyMarkup(): string {
}
// Minimal xterm stub: one scrollback line containing a URL, fixed 8x15 cells.
function makeTerminal(lineRef: { current: string }) {
function makeTerminal(lineRef: { current: string }, mouseTrackingMode = 'none') {
return {
cols: 80,
rows: 24,
options: { fontSize: 13 },
modes: {},
modes: { mouseTrackingMode },
element: { scrollWidth: 800, scrollHeight: 360 },
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } },
buffer: {
@@ -76,13 +76,14 @@ type OscLinkRange = { row: number; startCol: number; endCol: number; uri: string
function boot(
line: string,
oscLinks?: OscLinkRange[]
oscLinks?: OscLinkRange[],
mouseTrackingMode = 'none'
): { posted: Posted; setLine: (line: string) => void } {
const posted: Posted = []
const lineRef = { current: line }
const w = window as unknown as { Terminal: unknown; ReactNativeWebView: unknown }
w.Terminal = function () {
return makeTerminal(lineRef)
return makeTerminal(lineRef, mouseTrackingMode)
}
w.ReactNativeWebView = {
postMessage(s: string) {
@@ -150,6 +151,17 @@ describe('terminal WebView tap routing', () => {
expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/foo')
})
it('focuses native input after reporting a touch tap to a mouse-tracking TUI', async () => {
const { posted } = boot('interactive prompt', undefined, 'drag')
await settle()
fireTouch('touchstart', [{ x: 20, y: tapY }])
fireTouch('touchend', [])
expect(posted.find((message) => message.type === 'terminal-input')).toBeDefined()
expect(posted.filter((message) => message.type === 'terminal-tap')).toHaveLength(1)
})
it('opens the URL even right after a width-change reflow', async () => {
// Why: scrollback reflow rewraps the local buffer; the tap path must keep
// working afterward (the reflow message must not disturb tap routing).
@@ -205,7 +205,7 @@ export const URL_TAP_WEBVIEW_JS = `
} catch (e) { return 0; }
}
function notifyTerminalSurfaceTap(originX, originY) {
function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) {
var tappedOscLink = oscLinkAtViewportPoint(originX, originY);
if (tappedOscLink && tappedOscLink.kind === 'file') {
notify({
@@ -245,7 +245,9 @@ export const URL_TAP_WEBVIEW_JS = `
var clickInput = buildMouseClickInput(originX, originY);
if (clickInput) {
notify({ type: 'terminal-input', bytes: clickInput });
} else if (!isClickMouseTrackingMode(getMouseTrackingMode())) {
}
// Touch still needs native input focus after the TUI consumes its mouse click.
if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) {
notify({ type: 'terminal-tap' });
}
}