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>
This commit is contained in:
Rounak Datta
2026-08-19 19:14:52 +00:00
co-authored by Claude Opus 5
parent f2d3a7e3c1
commit 756728e553
3 changed files with 639 additions and 2 deletions
+196 -1
View File
@@ -97,13 +97,23 @@ function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new
active: {
viewportY: 0,
baseY: 0,
length: lines.length,
cursorY,
getLine: (row: number) =>
row >= 0 && row < lines.length
? { isWrapped: wrappedRows.has(row), translateToString: () => lines[row] }
? {
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,
@@ -899,3 +909,188 @@ describe('terminal link tap', () => {
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();
});
});