Skip to content

Mobile: no way to copy terminal text (selection is impossible on touch) — have a branch, want a read before PR #322

Description

@rounakdatta

The gap

There is no way to copy terminal text from a phone. Not a word, not a line, not anything — the gesture doesn't exist and nothing else exposes the text.

That's not an oversight in one place; three separate layers rule it out, which is why I'm raising it as an issue rather than sending a patch cold:

  1. CSS forbids selection on touch.styles.css puts user-select: none !important plus -webkit-touch-callout: none on the whole terminal subtree under body.touch-device — viewport, screen, rows, accessibility tree, selection layer, helper textarea. Deliberate, and correct for the tap-to-position gesture that owns taps there.
  2. There would be nothing to select anyway.webglRendererEnabled defaults to true, so the visible glyphs are GPU-drawn pixels; the DOM underneath is the accessibility tree, not the text you see. Lifting the CSS would hand you a selection of invisible nodes.
  3. Nothing drives xterm's own selection from touch.terminal.select( / selectLines / selectionStart appear zero times in any touch path, and there's no long-press handler in terminal-ui.js or mobile-handlers.js. xterm's selection is a mouse DRAG, while _dispatchSyntheticTerminalClick sends a zero-movement mousedown/mouseup pair — a click.

Two smaller things I noticed while confirming the above, both mobile-relevant:

  • copyTerminal() (copy the whole buffer) exists in terminal-ui.js but is wired to no button anywhere — grep finds no call site.
  • It also calls navigator.clipboard directly, which is undefined on the plain-HTTP LAN install install.sh offers, so it would fail there even if it were wired. copyTerminalSelection() next to it does this correctly via _copyText's execCommand fallback.

What I built, and would like a read on before opening a PR

Branch, device-verified and CI-clean: https://github.com/rounakdatta/Codeman/tree/feat/mobile-touch-selection

The gesture drives xterm's public select() — renderer-independent, and the highlight is drawn by xterm itself, so it works under WebGL where native selection cannot exist. Long-press is free real estate: tap and swipe are taken, long-press and double-tap are used by nothing.

  • Long-press (350 ms, finger still within the shared tap slop) selects the run of non-whitespace under the finger. Whitespace-only delimiting is deliberate: a punctuation-aware word rule cuts a path, URL or hash in half, which is exactly what you're reaching for.
  • Drag while held extends it; tap while the bar is up extends it too. The tap form is the ergonomic core — picking up a 4 px handle with a fingertip is a coin flip, tapping the other end is not. Dismissal stays explicit ( or Copy).
  • Copy goes through the existing copyTerminalSelection(), inheriting the execCommand fallback that is the only route that works on plain HTTP.
  • Line takes the whole logical line, wraps included, trailing pad trimmed.
  • The bar is built in JS (index.html is read once at server start) and its styles live in styles.css, not mobile.css: the gesture is touch-driven, not width-driven, so a touch tablet in landscape would otherwise get the gesture with no bar to copy from.

The three guards, and why each exists

Every one of these was a symptom measured on a real phone, not a hypothetical:

  1. The compat mouse pair after touchend.CoreBrowserTerminal focuses from its screen-element mousedown and SelectionService resets the model there — so lifting your finger popped the keyboard and dissolved the selection together. The tap path already owns a guard for those events (_installMobileTapMouseGuard); the selection path just never armed it. Armed now, and the touchend is preventDefaulted so the synthesis stops at the source.
  2. The platform's own long-press. Android Chrome runs its handling at ~500 ms 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 350 ms threshold sits clear of the platform's.
  3. Copy re-focusing the terminal.copyTerminalSelection() ends with terminal.focus() — right on a desktop, wrong on a phone, where the keyboard covers what you just copied with nothing waiting to be typed.

Verification

  • 12 new tests in test/terminal-touch-tap.test.ts (51 in the file): the word rule, forward/backward extension, cross-row selection, Line, tap-to-extend, the copy path, and each guard including the focus guard's expiry. Each guard has a test that fails without it.
  • test:ci, typecheck, lint, format:check, check:frontend-syntax, check:public-assets all clean. (cron-service.test.ts has one failure that reproduces on a clean master, unrelated.)
  • Verified by hand on Android + Chrome against a live instance, iterating until the keyboard behaviour was right — guards 1 and 2 above only surfaced on the device.

Environment

  • Codeman 1.19.3, npm install (npm i -g aicodeman), Linux host
  • Client: Android + Chrome
  • Session CLI: Claude Code 2.1.233

Happy to open this as a PR, split it (the copyTerminal() wiring/clipboard-fallback fix is genuinely separate), or adjust the gesture — particularly "tap extends the selection while the bar is up", which is the one choice I'd expect an opinion on. Related: #321, which fixes tapping a link on mobile.

🤖 Generated with Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions