Files
Codeman/test/terminal-touch-tap.test.ts
T
Rounak DattaandClaude Opus 5 756728e553 feat(mobile): long-press to select terminal text, tap to extend, Copy
There was no way to copy terminal text from a phone at all, and three layers
ruled it out independently: `user-select: none` across the whole terminal subtree
on touch devices (taps are cursor gestures there, so the OS callout had to go),
the WebGL renderer drawing glyphs as pixels with only the accessibility tree
behind them, and xterm's own selection being a mouse DRAG while the touch path
dispatches a zero-movement mousedown/mouseup pair — a click. `copyTerminal()`
exists but is wired to no button and calls `navigator.clipboard` directly, which
is undefined on the plain-HTTP LAN install the installer offers.

So the gesture drives xterm's `select()` directly: public API, renderer-
independent, and the highlight is drawn by xterm itself. Long-press is free real
estate — tap and swipe are taken, long-press and double-tap are used by nothing.

- **Long-press** (350ms, finger still within the shared tap slop) selects the
  run of non-whitespace under the finger. Whitespace is the only delimiter on
  purpose: every punctuation-aware word rule cuts a path, URL or hash in half,
  which is what you came to copy.
- **Drag** while held extends the selection; touchmove diverts from scrolling.
- **Tap** while the bar is up extends it too. That is the ergonomic core:
  picking up a 4px handle with a fingertip is a coin flip, tapping the other end
  is not. Dismissal stays explicit (✕ or Copy), so no tap is spent leaving a mode
  the user is still using.
- **Copy** goes through the existing `copyTerminalSelection()`, so it inherits
  the execCommand fallback that is the only route that works on plain HTTP.
- **Line** takes the whole logical line, wraps included, trailing pad trimmed.

Three guards are what make the gesture survive contact with a real phone, and
each fixes a symptom measured on Android Chrome:

1. **The compat mouse pair after touchend.** xterm focuses from its screen-element
   mousedown and SelectionService resets the model there, so lifting your finger
   popped the keyboard and dissolved the selection in one go. The tap path already
   had a guard for those events; the selection path simply never armed it. Armed
   now, and the touchend is `preventDefault`ed so the synthesis is stopped at the
   source (that listener is no longer passive).
2. **The platform's own long-press.** Android Chrome runs its handling at ~500ms
   and focuses the nearest editable element — xterm's helper textarea, parked at
   the cursor — which no touch handler can preventDefault because it never sees an
   event. A focus guard blurs the terminal input for the duration of the gesture,
   whatever focused it, bounded by a self-expiring deadline so a stuck flag can
   never leave the keyboard unreachable. `contextmenu` is suppressed for the same
   window, and the threshold sits at 350ms so it lands clear of the platform's.
3. **Copy re-focusing the terminal.** `copyTerminalSelection()` ends with
   `terminal.focus()`, which is right on a desktop and wrong on a phone: the
   keyboard covers what was just copied with nothing waiting to be typed.

The bar is built in JS because index.html is read once at server start, and its
styles live in styles.css rather than mobile.css because the gesture is
touch-driven, not width-driven — a touch tablet in landscape gets the gesture and
would otherwise have no bar to copy from.

12 tests in `terminal-touch-tap.test.ts` cover the word rule, forward and
backward extension, cross-row selection, Line, tap-to-extend, the copy path, and
each of the three guards including the focus guard's expiry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:14:52 +00:00

1097 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
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;
// The module hangs its constants off window (CodemanTerminalInput) and the URL branch of
// the link provider opens through window.open, so tests need a handle on the same object.
const windowRef: Record<string, any> = {};
const context = vm.createContext({
window: windowRef,
document: {
body: { classList: { contains: () => false } },
get activeElement() {
return activeElement;
},
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => now },
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
Blob: function Blob() {},
URL: {
createObjectURL: () => 'blob:yield',
revokeObjectURL: () => {},
},
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
MobileDetection: {
isTouchDevice: () => true,
},
KeyboardHandler: {
get keyboardVisible() {
return keyboardVisible;
},
},
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
// constants.js first: the link provider calls absoluteFilePathPattern() and
// previewsInFileViewer() at scan time, and the SHIPPED definitions are what keep a tap and
// a hover resolving the same links.
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(constants, context, { filename: 'constants.js' });
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as any)();
return {
app,
windowRef,
setNow: (value: number) => {
now = value;
},
setKeyboardVisible: (visible: boolean) => {
keyboardVisible = visible;
},
setActiveElement: (element: unknown) => {
activeElement = element;
},
};
}
function createElementHarness() {
const listeners = new Map<string, (ev: any) => void>();
return {
element: {
addEventListener: vi.fn((type: string, listener: (ev: any) => void) => {
listeners.set(type, listener);
}),
},
dispatch(type: string, event: any) {
listeners.get(type)?.(event);
},
};
}
function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new Set<number>()) {
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,
length: lines.length,
cursorY,
getLine: (row: number) =>
row >= 0 && row < lines.length
? {
isWrapped: wrappedRows.has(row),
// xterm pads an UNTRIMMED row to the full width; the selection offset
// math is linear over joined rows and would shift without it.
translateToString: (trim?: boolean) => (trim === false ? lines[row].padEnd(80) : lines[row]),
}
: undefined,
},
},
select: vi.fn(),
clearSelection: vi.fn(),
hasSelection: () => false,
getSelectionPosition: () => 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('keeps the keyboard reachable while a selection dialog is on screen', () => {
// The lock this pins: a visible dialog used to make EVERY row of the
// terminal "actionable" (both menu tests scanned the whole viewport), so
// every tap blurred and the on-screen keyboard could not be opened until
// the dialog was answered, leaving tapping an option (the one gesture that
// commits an answer) as the only thing a phone could do.
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(
['Do you want to proceed?', '', '❯ 1. Yes', ' 2. No, tell Claude what to do', '', ''],
2
);
app._sendInputAsync = vi.fn();
setActiveElement(null);
// The dialog's own rows stay TUI-owned: report the tap, keep the keyboard down.
expect(app._isActionableMobileTerminalTap(9, 33)).toBe(true); // ❯ 1. Yes
expect(app._isActionableMobileTerminalTap(9, 49)).toBe(true); // 2. No, …
// Everything else is inert, and must still be able to summon the keyboard.
expect(app._isActionableMobileTerminalTap(9, 1)).toBe(false); // question title
expect(app._isActionableMobileTerminalTap(9, 65)).toBe(false); // blank row
app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, false);
expect(app.terminal.focus).toHaveBeenCalledOnce();
app.terminal.focus.mockClear();
app._handleMobileTerminalTap({ clientX: 9, clientY: 33 }, false);
expect(app.terminal.focus).not.toHaveBeenCalled();
});
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('closes the keyboard on a second tap of INERT transcript content', () => {
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4);
app._sendInputAsync = vi.fn();
// Keyboard DOWN: the tap opens it.
setActiveElement(null);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, false)).toBe('content');
expect(app.terminal.focus).toHaveBeenCalledOnce();
expect(app.terminal.textarea.blur).not.toHaveBeenCalled();
// Keyboard UP on the same inert row: the tap closes it.
app.terminal.focus.mockClear();
setActiveElement(app.terminal.textarea);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, true)).toBe('content');
expect(app.terminal.textarea.blur).toHaveBeenCalledOnce();
expect(app.terminal.focus).not.toHaveBeenCalled();
});
it('keeps the prompt row focusing rather than toggling, so the caret can still be placed', () => {
// The toggle is scoped to 'content' on purpose: a second tap on the PROMPT
// must still position the cursor. This is the guarantee that makes the
// change safe to make, so it is pinned separately.
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ask', ''], 4);
app._sendInputAsync = vi.fn();
setActiveElement(app.terminal.textarea);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, true)).toBe('input');
expect(app.terminal.textarea.blur).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();
app.terminal = { element };
app._installMobileTapMouseGuard();
app._suppressTrustedTapMouseEvents();
const event = {
isTrusted: true,
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
};
dispatch('mousedown', event);
expect(event.preventDefault).toHaveBeenCalledOnce();
expect(event.stopImmediatePropagation).toHaveBeenCalledOnce();
});
it('allows the app synthetic mouse event through the tap window', () => {
const { app } = loadTerminalUiHarness();
const { element, dispatch } = createElementHarness();
app.terminal = { element };
app._installMobileTapMouseGuard();
app._suppressTrustedTapMouseEvents();
const event = {
isTrusted: false,
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
};
dispatch('mousedown', event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
});
it('encodes a tap as an SGR press+release when the server strips mouse DECSETs (claude mode)', () => {
const { app } = loadTerminalUiHarness();
const sent: Array<{ id: string; data: string }> = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
app.terminal = {
cols: 80,
rows: 24,
element: {
querySelector: () => ({ getBoundingClientRect: () => ({ left: 10, top: 20 }) }),
},
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
};
expect(app._sessionUsesServerMouseStrip()).toBe(true);
// touch at x=10+8*20+1, y=20+16*5+1 → col 21, row 6 (1-based)
app._sendSyntheticSgrTap(171, 101);
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]);
});
it('clamps SGR tap coordinates to the terminal grid', () => {
const { app } = loadTerminalUiHarness();
const sent: string[] = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
app.terminal = {
cols: 80,
rows: 24,
element: {
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
},
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
};
app._sendSyntheticSgrTap(-50, 99999);
expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']);
});
it('does not treat shell sessions as server-mouse-strip mode', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
expect(app._sessionUsesServerMouseStrip()).toBe(false);
});
it('desktop click: encodes SGR press+release for a plain left-click in strip mode', () => {
const { app } = loadTerminalUiHarness();
const sent: Array<{ id: string; data: string }> = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
app.terminal = {
cols: 80,
rows: 24,
modes: { mouseTrackingMode: 'none' },
hasSelection: () => false,
element: {
querySelector: () => ({ getBoundingClientRect: () => ({ left: 10, top: 20 }) }),
},
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
};
app._handleDesktopTerminalClick({
isTrusted: true,
button: 0,
detail: 1,
clientX: 171,
clientY: 101,
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
});
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]);
});
it('desktop click: skips clicks that already have a meaning elsewhere', () => {
const { app } = loadTerminalUiHarness();
const sent: string[] = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
const terminal = () => ({
cols: 80,
rows: 24,
modes: { mouseTrackingMode: 'none' } as { mouseTrackingMode: string },
hasSelection: () => false,
element: {
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
},
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
});
const click = (overrides: Record<string, unknown> = {}) => ({
isTrusted: true,
button: 0,
detail: 1,
clientX: 50,
clientY: 50,
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
...overrides,
});
app.terminal = terminal();
app._handleDesktopTerminalClick(click({ isTrusted: false })); // synthetic
app._handleDesktopTerminalClick(click({ button: 1 })); // middle button
app._handleDesktopTerminalClick(click({ detail: 2 })); // double-click word select
app._handleDesktopTerminalClick(click({ shiftKey: true })); // selection override
app._handleDesktopTerminalClick(click({ target: { closest: () => null } })); // outside grid
app.terminal = { ...terminal(), hasSelection: () => true }; // drag-selection just ended
app._handleDesktopTerminalClick(click());
app.terminal = { ...terminal(), modes: { mouseTrackingMode: 'vt200' } }; // xterm encoder live
app._handleDesktopTerminalClick(click());
app.terminal = terminal();
app.sessions = new Map([['sess-1', { mode: 'shell' }]]); // not a strip mode
app._handleDesktopTerminalClick(click());
expect(sent).toEqual([]);
});
it('desktop click: skips the click while a terminal link is hovered (activate() handles it)', () => {
const { app } = loadTerminalUiHarness();
const sent: string[] = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
app.terminal = {
cols: 80,
rows: 24,
modes: { mouseTrackingMode: 'none' },
hasSelection: () => false,
element: {
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
},
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
};
const click = {
isTrusted: true,
button: 0,
detail: 1,
clientX: 50,
clientY: 50,
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
};
app._linkHovered = true; // link provider hover() fired — this click opens the link
app._handleDesktopTerminalClick(click);
expect(sent).toEqual([]);
app._linkHovered = false; // leave() fired — plain clicks report again
app._handleDesktopTerminalClick(click);
expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']);
});
it('desktop click: skips the compat click that follows a touch tap', () => {
const { app, setNow } = loadTerminalUiHarness();
const sent: string[] = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
app.terminal = {
cols: 80,
rows: 24,
modes: { mouseTrackingMode: 'none' },
hasSelection: () => false,
element: {
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
},
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
};
const click = {
isTrusted: true,
button: 0,
detail: 1,
clientX: 50,
clientY: 50,
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
};
app._suppressTrustedTapMouseEvents(); // touchend just handled the tap
app._handleDesktopTerminalClick(click);
expect(sent).toEqual([]);
setNow(10_000); // window expired — a genuine mouse click reports again
app._handleDesktopTerminalClick(click);
expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']);
});
it('tap: does nothing while the viewport is scrolled up into local scrollback', () => {
const { app } = loadTerminalUiHarness();
const sent: string[] = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
app.terminal = {
cols: 80,
rows: 24,
buffer: { active: { viewportY: 10, baseY: 50 } }, // scrolled up
element: {
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
},
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
};
app._sendSyntheticSgrTap(50, 50);
expect(sent).toEqual([]);
app.terminal.buffer.active.viewportY = 50; // back at the bottom
app._sendSyntheticSgrTap(50, 50);
expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']);
});
it('wheel: forwards to the app for verified sessions without Shift, at ANY scroll position', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]);
app.terminal = {
modes: { mouseTrackingMode: 'none' },
buffer: { active: { viewportY: 50, baseY: 50 } },
};
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
expect(app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false); // Shift = local scrollback
// Scrolled up into local scrollback still forwards. Gating this on the
// viewport being at the bottom is what let a repaint-mode CLI's own prompt
// box scroll off the screen: scrollToLastNonEmptyLine() parks the viewport
// above the bottom, so a tab switch silently pinned the wheel to local
// scrollback full of stale replayed frames. The wheel handler snaps the
// viewport back to the bottom before encoding the report instead.
app.terminal.buffer.active.viewportY = 10;
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
app.terminal.buffer.active.viewportY = 50;
app.terminal.modes.mouseTrackingMode = 'vt200'; // xterm's own encoder live
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
app.terminal.modes.mouseTrackingMode = 'none';
app.sessions = new Map([['sess-1', { mode: 'shell' }]]); // not a strip mode
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
});
it('wheel: converts deltaMode line/page units instead of assuming pixels', () => {
const { app } = loadTerminalUiHarness();
app.terminal = { rows: 40 };
// DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad): ~110px per notch.
expect(app._wheelScrollLines({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4);
// DOM_DELTA_LINE (Firefox mouse wheel): deltaY is already lines. Read as
// pixels this rounded to 0 and fell through to the ±1 fallback.
expect(app._wheelScrollLines({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3);
expect(app._wheelScrollLines({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3);
// DOM_DELTA_PAGE: one page is one screenful.
expect(app._wheelScrollLines({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40);
// A pure horizontal swipe must not fall through to a phantom -1.
expect(app._wheelScrollLines({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0);
// Shift + macOS trackpad reports the magnitude on deltaX (issue #154).
expect(app._wheelScrollLines({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4);
});
it('wheel: gates claude forwarding on CLI version 2.1.187+ (unknown or older stays local)', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.terminal = {
modes: { mouseTrackingMode: 'none' },
buffer: { active: { viewportY: 50, baseY: 50 } },
};
const withVersion = (cliVersion?: string) => {
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion }]]);
return app._shouldForwardWheelToApp({ shiftKey: false });
};
expect(withVersion(undefined)).toBe(false); // banner not parsed yet → assume older
expect(withVersion('2.1.186')).toBe(false); // last version whose menus capture wheel
expect(withVersion('2.1.187')).toBe(true); // first version verified safe
expect(withVersion('2.2.0')).toBe(true);
expect(withVersion('3.0.0')).toBe(true);
expect(withVersion('garbage')).toBe(false); // unparseable → assume older
});
it('wheel: only claude forwards — codex and gemini keep the local wheel', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.terminal = {
modes: { mouseTrackingMode: 'none' },
buffer: { active: { viewportY: 50, baseY: 50 } },
};
// Codex used to forward unconditionally, which is PR #227's regression: measured
// on codex-cli 0.147.0, it never enables mouse tracking and ignores SGR wheel
// reports outright, so forwarding ate every tick while its real local scrollback
// (the codex transcript lives there — inline viewport, no in-app pager) sat unused.
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9' }]]); // no version rescues it
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9' }]]); // unverified TUI
expect(app._shouldForwardWheelToApp({ shiftKey: false })).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';
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]);
app.terminal = {
modes: { mouseTrackingMode: 'none' },
buffer: { active: { viewportY: 50, baseY: 50 } },
};
// Default (setting absent) forwards the plain wheel to the CLI transcript.
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
// Opt-out ON → the plain wheel stays on xterm's own scrollback (pre-#144).
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
// OFF again → forwarding resumes.
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
});
it('wheel: reads the dominant axis under Shift so a macOS trackpad can page scrollback (issue #154)', () => {
const { app } = loadTerminalUiHarness();
// Plain vertical wheel: unchanged, driven by deltaY.
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4);
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2);
// Shift on a macOS trackpad: deltaY≈0, deltaX carries direction+magnitude.
// Old code collapsed this to a fixed -1; now it tracks the horizontal delta.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down
// Shift with a real vertical wheel (mouse): deltaY dominates, deltaX ignored.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4);
// Sub-25px delta still nudges one line in the gesture's direction.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-1);
});
it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => {
const { app } = loadTerminalUiHarness();
const sent: Array<{ id: string; data: string }> = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
// Wheel reports flush via the ephemeral (fire-and-forget) path, not the
// durable queue — so they never show in the pending-bytes indicator (#154).
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
app.terminal = {
cols: 80,
rows: 24,
element: {
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
},
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
};
app._sendSyntheticSgrWheel(50, 50, -2); // 2 ticks up
app._sendSyntheticSgrWheel(50, 50, 9); // capped at 5 ticks down
expect(sent).toEqual([]); // nothing until the flush timer fires
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) + '\x1b[<65;7;4M'.repeat(5) }]);
app._flushWheelSgrQueue(); // queue drained — no duplicate send
expect(sent).toHaveLength(1);
});
it('forwarded scrolls (wheel AND touch) snap the viewport home first, then encode SGR ticks', () => {
const { app } = loadTerminalUiHarness();
const sent: Array<{ id: string; data: string }> = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
const scrolledToBottom: boolean[] = [];
app.terminal = {
cols: 80,
rows: 24,
// Scrolled up into local scrollback: SGR coordinates address the LIVE
// screen, so the report would hit-test the wrong row without the snap.
buffer: { active: { viewportY: 10, baseY: 50 } },
scrollToBottom: () => scrolledToBottom.push(true),
element: {
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
},
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
};
app._forwardScrollToApp(50, 50, -3);
expect(scrolledToBottom).toEqual([true]);
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(3) }]);
// Already at the bottom: no snap, just the report.
app.terminal.buffer.active.viewportY = 50;
app._forwardScrollToApp(50, 50, 2);
expect(scrolledToBottom).toHaveLength(1);
app._flushWheelSgrQueue();
expect(sent).toHaveLength(2);
});
it('allows trusted mouse events after the tap window expires', () => {
const { app, setNow } = loadTerminalUiHarness();
const { element, dispatch } = createElementHarness();
app.terminal = { element };
app._installMobileTapMouseGuard();
app._suppressTrustedTapMouseEvents();
setNow(2_000);
const event = {
isTrusted: true,
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
};
dispatch('mousedown', event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
});
});
describe('terminal link tap', () => {
// xterm resolves a link from mousemove and activates it on mouseup over its SCREEN element.
// A touch tap produces none of those (touch-action:none and touchstart's preventDefault
// suppress the compatibility mouse events, the post-tap guard drops the rest, and the
// synthetic pair this app dispatches for mouse REPORTING lands on the .xterm root, an
// ancestor of the node the linkifier listens on). So the tap path activates the link
// itself, through the same provider, or every URL and path in the terminal stays inert on
// a phone.
//
// Grid geometry from createTerminalGrid: 8×16 cells, screen rect at (0,0), viewportY 0 —
// so 0-based character index i on 0-based row r sits at (i * 8 + 4, r * 16 + 8).
const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 });
/** A claude-mode app with the shipped link provider registered over `lines`. */
function linkHarness(lines: string[], cursorY = lines.length - 1) {
const harness = loadTerminalUiHarness();
const { app, windowRef } = harness;
const sent: string[] = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
app.terminal = createTerminalGrid(lines, cursorY);
app.terminal.registerLinkProvider = vi.fn();
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._isExternalPreviewPath = () => false;
windowRef.open = vi.fn();
app.registerFilePathLinkProvider();
return { app, windowRef, sent };
}
it('opens a URL under the finger in a new tab', () => {
const line = 'Login at https://claude.ai/oauth/authorize?code=true&client_id=abc to finish';
const { app, windowRef } = linkHarness([line, '', '❯ ']);
expect(app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'content')).toBe('link');
expect(windowRef.open).toHaveBeenCalledWith(
'https://claude.ai/oauth/authorize?code=true&client_id=abc',
'_blank',
'noopener,noreferrer'
);
});
it('sends no mouse report for the tap it just spent on a link', () => {
// The CLI must not also see a click there: that is how a tap on a URL printed inside a
// permission dialog would answer the dialog. Desktop already skips the SGR tap for a
// hovered link (_handleDesktopTerminalClick).
const line = 'see https://example.com/x for more';
const { app, sent } = linkHarness([line, '', '❯ ']);
expect(app._sessionUsesServerMouseStrip()).toBe(true);
app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'content');
expect(sent).toEqual([]);
});
it('leaves a tap beside the link as an ordinary tap', () => {
// Containment is xterm's own rule (flattened cell index), so tap and click agree on
// where a link ends; a tap on the prose around it keeps its mouse report.
const line = 'see https://example.com/x for more';
const { app, windowRef, sent } = linkHarness([line, '', '❯ ']);
expect(app._handleMobileTerminalTap(at(line.indexOf('for more') + 3), false, 'content')).toBe('content');
expect(windowRef.open).not.toHaveBeenCalled();
expect(sent).toHaveLength(1);
});
it('opens a tapped file path in the preview overlay', () => {
const line = 'wrote the chart to /tmp/out/chart.png just now';
const { app } = linkHarness([line, '', '❯ ']);
expect(app._handleMobileTerminalTap(at(line.indexOf('/tmp')), false, 'content')).toBe('link');
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/out/chart.png', 'sess-1');
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
});
it('sends a tapped log path to the log viewer', () => {
const line = 'tail -f /var/log/app.log';
const { app } = linkHarness([line, '', '❯ ']);
expect(app._handleMobileTerminalTap(at(line.indexOf('/var')), false, 'content')).toBe('link');
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/var/log/app.log', 'sess-1');
});
it('activates a link in scrollback, where the tap sends no report at all', () => {
// A scrolled-up tap deliberately reports nothing (it would land on whatever row now
// occupies the cell), but reading old output and tapping a URL in it is the common case.
const line = 'docs at https://example.com/guide';
const { app, windowRef, sent } = linkHarness([line, '', '']);
expect(app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'history')).toBe('link');
expect(windowRef.open).toHaveBeenCalledOnce();
expect(sent).toEqual([]);
});
it('never hijacks a TUI-owned choice row that happens to carry a path', () => {
// On a phone the dialog is the only interaction that matters, and its rows routinely
// name the very file a link would open — answering it must keep winning.
// ⚠️ The caret is parked on the QUESTION row, not the choice: with the caret on the
// tapped row this would pass through _tapIsOnCaretLine and pin nothing.
const line = '❯ 1. Yes, edit /home/user/src/app.ts';
const { app, windowRef, sent } = linkHarness(['Do you want to make this edit?', line, ' 2. No, keep it as is'], 0);
expect(app._handleMobileTerminalTap(at(line.indexOf('/home'), 1), false, 'content')).toBe('content');
expect(windowRef.open).not.toHaveBeenCalled();
expect(app.openFilePreview).not.toHaveBeenCalled();
expect(sent).toHaveLength(1); // the choice still reaches the CLI
});
it('leaves a URL the user typed in the composer editable', () => {
// Tapping your own prompt text means "put the caret here". Opening it instead would
// punish the phone gesture for fixing a typo in a pasted link.
const composer = '❯ summarize https://example.com/guide for me';
const { app, windowRef, sent } = linkHarness(['earlier output', '', composer], 2);
expect(app._handleMobileTerminalTap(at(composer.indexOf('https'), 2), true, 'input')).toBe('input');
expect(windowRef.open).not.toHaveBeenCalled();
expect(sent).toHaveLength(1); // the tap still positions the caret via the mouse report
});
it('activates a link in a plain shell session, where every tap classifies as input', () => {
// A shell has no TUI to own taps, so _classifyMobileTerminalTap short-circuits to
// 'input' for the whole screen — gating link taps on the intent would leave every URL
// in shell output (curl, npm, git remote) inert. The caret line is the real boundary.
const line = 'remote: https://github.com/Ark0N/Codeman.git';
const harness = loadTerminalUiHarness();
const { app, windowRef } = harness;
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
app._sendInputAsync = vi.fn();
app.terminal = createTerminalGrid([line, '', 'bash-5.3$ '], 2);
app.terminal.registerLinkProvider = vi.fn();
windowRef.open = vi.fn();
app.registerFilePathLinkProvider();
// No cachedIntent below: real classification runs, and for a shell it answers 'input'.
const point = at(line.indexOf('https'));
expect(app._classifyMobileTerminalTap(point.clientX, point.clientY)).toBe('input');
expect(app._handleMobileTerminalTap(point, false)).toBe('link');
expect(windowRef.open).toHaveBeenCalledWith(
'https://github.com/Ark0N/Codeman.git',
'_blank',
'noopener,noreferrer'
);
});
it('keeps taps working when no provider was ever registered', () => {
const harness = loadTerminalUiHarness();
const { app } = harness;
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['plain output', '', '❯ '], 2);
expect(app._terminalLinkAtPoint(4, 8)).toBeNull();
expect(app._activateTerminalLinkAtPoint(4, 8)).toBe(false);
});
});
describe('terminal touch selection', () => {
// Copying from a phone was impossible in three layers at once: `user-select: none`
// on the whole terminal subtree, a WebGL renderer that draws glyphs as pixels with
// only the accessibility tree behind them, and xterm's selection being a mouse DRAG
// while the tap path dispatches a zero-movement mousedown/mouseup pair. The gesture
// therefore drives xterm's own `select()`, which is renderer-independent.
//
// Grid geometry (createTerminalGrid): 80 cols, 8×16 cells, screen rect at (0,0),
// viewportY 0 — 0-based index i on 0-based row r sits at (i * 8 + 4, r * 16 + 8).
const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 });
const press = (app: any, index: number, row = 0) =>
app._beginTouchSelection(at(index, row).clientX, at(index, row).clientY);
const dragTo = (app: any, index: number, row = 0) =>
app._extendTouchSelection(at(index, row).clientX, at(index, row).clientY);
const COLS = 80;
const LINE = 'wrote the chart to /tmp/out/chart.png just now';
const PATH_AT = LINE.indexOf('/tmp');
function selectionHarness(lines = [LINE, '', '❯ '], cursorY = 2, wrapped = new Set<number>()) {
const { app, setNow } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = vi.fn();
app.terminal = createTerminalGrid(lines, cursorY, wrapped);
return { app, setNow, select: app.terminal.select as ReturnType<typeof vi.fn> };
}
it('selects the whitespace-delimited token under a long press', () => {
// Whitespace is the only delimiter on purpose: a punctuation-aware word rule
// cuts a path, a URL or a hash in half, which is exactly what you came to copy.
const { app, select } = selectionHarness();
expect(press(app, PATH_AT + 4)).toBe(true);
expect(select).toHaveBeenCalledWith(PATH_AT, 0, '/tmp/out/chart.png'.length);
});
it('selects nothing when the press lands on blank space', () => {
const { app, select } = selectionHarness();
expect(press(app, LINE.length + 10)).toBe(false);
expect(select).not.toHaveBeenCalled();
});
it('grows the selection as the finger drags past the anchor word', () => {
const { app, select } = selectionHarness();
press(app, PATH_AT + 4);
select.mockClear();
dragTo(app, LINE.length - 1);
// From the word's start through the cell under the finger, inclusive.
expect(select).toHaveBeenCalledWith(PATH_AT, 0, LINE.length - PATH_AT);
});
it('keeps the anchor word inside the selection when the drag goes backwards', () => {
const { app, select } = selectionHarness();
press(app, PATH_AT + 4);
select.mockClear();
dragTo(app, 6);
const wordEnd = PATH_AT + '/tmp/out/chart.png'.length;
expect(select).toHaveBeenCalledWith(6, 0, wordEnd - 6);
});
it('extends across rows, where a linear length is what xterm wants', () => {
const { app, select } = selectionHarness(['first row text', 'second row text', '❯ '], 2);
press(app, 0);
select.mockClear();
dragTo(app, 5, 1);
// Row 1 cell 5 is absolute cell 85; the selection runs from 0 through it.
expect(select).toHaveBeenCalledWith(0, 0, COLS + 6);
});
it('Line takes the whole logical line, wraps included, without the padding', () => {
const wrappedTail = 'tail';
const { app, select } = selectionHarness(['x'.repeat(COLS), wrappedTail, '❯ '], 2, new Set([1]));
press(app, 2, 1);
select.mockClear();
app._selectTouchSelectionLine();
expect(select).toHaveBeenCalledWith(0, 0, COLS + wrappedTail.length);
});
it('a tap while a selection is up extends it instead of moving the cursor', () => {
const { app, select } = selectionHarness();
press(app, PATH_AT + 4);
select.mockClear();
expect(app._handleMobileTerminalTap(at(LINE.length - 1), false, 'content')).toBe('select');
expect(select).toHaveBeenCalledWith(PATH_AT, 0, LINE.length - PATH_AT);
// and the CLI never sees a click it would act on
expect(app._sendInputAsync).not.toHaveBeenCalled();
});
it('copies through the shared clipboard path and drops the selection', () => {
// copyTerminalSelection is the one that falls back to execCommand, which is the
// only route that works on the plain-HTTP LAN install the installer offers.
const { app } = selectionHarness();
app.copyTerminalSelection = vi.fn().mockResolvedValue(true);
press(app, PATH_AT + 4);
return app._copyTouchSelection().then(() => {
expect(app.copyTerminalSelection).toHaveBeenCalledOnce();
expect(app._touchSelectionActive).toBe(false);
expect(app._touchSelectionAnchor).toBeNull();
expect(app.terminal.clearSelection).toHaveBeenCalled();
});
});
it('lifting the finger cannot let a compat mousedown steal focus and drop the selection', () => {
// The bug this pins: on lift the browser synthesizes a trusted mousedown, xterm
// focuses on it (keyboard up) and SelectionService resets the model (bar gone).
// Ending the gesture arms the same guard the tap path uses.
const { app } = selectionHarness();
const { element, dispatch } = createElementHarness();
app.terminal.element = { ...app.terminal.element, addEventListener: element.addEventListener };
app._installMobileTapMouseGuard();
press(app, PATH_AT + 4);
app._endTouchSelectionGesture();
const ev = { isTrusted: true, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() };
dispatch('mousedown', ev);
expect(ev.preventDefault).toHaveBeenCalledOnce();
expect(ev.stopImmediatePropagation).toHaveBeenCalledOnce();
expect(app._touchSelecting).toBe(false);
expect(app._touchSelectionActive).toBe(true); // the selection outlives the gesture
});
it('copying does not pop the on-screen keyboard back over the text', () => {
// copyTerminalSelection hands focus to the terminal, which on a phone means the
// keyboard covers what you just copied with nothing waiting to be typed.
const { app } = selectionHarness();
app.copyTerminalSelection = vi.fn().mockResolvedValue(true);
press(app, PATH_AT + 4);
app._blurMobileTerminalInput = vi.fn(); // stubbed AFTER the press, which blurs too
return app._copyTouchSelection().then(() => {
expect(app._blurMobileTerminalInput).toHaveBeenCalledOnce();
});
});
it('blurs the terminal input if anything focuses it during the gesture', () => {
// The one that matters on Android: Chrome runs its own long-press handling at
// ~500ms and focuses the helper textarea directly — no mouse event to guard, so
// the keyboard shot up over the selection the instant it appeared.
const { app, setNow } = selectionHarness();
const listeners = new Map<string, () => void>();
app.terminal.textarea = {
addEventListener: (type: string, fn: () => void) => listeners.set(type, fn),
classList: { contains: (n: string) => n === 'xterm-helper-textarea' },
blur: vi.fn(),
};
app._installTouchSelectionFocusGuard();
press(app, PATH_AT + 4);
app._endTouchSelectionGesture();
// Whatever focused it, the guard takes the focus straight back off.
app._blurMobileTerminalInput = vi.fn();
listeners.get('focus')?.();
expect(app._blurMobileTerminalInput).toHaveBeenCalledOnce();
// …and the guard expires on its own, so a stuck flag can never make the
// keyboard permanently unreachable.
setNow(1_000 + 5_000);
app._blurMobileTerminalInput = vi.fn();
listeners.get('focus')?.();
expect(app._blurMobileTerminalInput).not.toHaveBeenCalled();
});
it('survives a missing bar container instead of throwing mid-gesture', () => {
// index.html is read once at server start, so the bar is built in JS — and a
// solo popup or an early gesture can run before the container exists.
const { app } = selectionHarness();
expect(() => app._showTouchSelectionBar()).not.toThrow();
expect(app._ensureTouchSelectionBar()).toBeNull();
});
});