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:
- 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. - 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. - 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:
- 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. - 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. - 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
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:
styles.cssputsuser-select: none !importantplus-webkit-touch-callout: noneon the whole terminal subtree underbody.touch-device— viewport, screen, rows, accessibility tree, selection layer, helper textarea. Deliberate, and correct for the tap-to-position gesture that owns taps there.webglRendererEnableddefaults 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.terminal.select(/selectLines/selectionStartappear zero times in any touch path, and there's no long-press handler interminal-ui.jsormobile-handlers.js. xterm's selection is a mouse DRAG, while_dispatchSyntheticTerminalClicksends 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 interminal-ui.jsbut is wired to no button anywhere —grepfinds no call site.navigator.clipboarddirectly, which isundefinedon the plain-HTTP LAN installinstall.shoffers, 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.✕orCopy).copyTerminalSelection(), inheriting the execCommand fallback that is the only route that works on plain HTTP.styles.css, notmobile.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:
touchend.CoreBrowserTerminalfocuses from its screen-elementmousedownandSelectionServiceresets 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 ispreventDefaulted so the synthesis stops at the source.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.contextmenuis suppressed for the same window, and the 350 ms threshold sits clear of the platform's.copyTerminalSelection()ends withterminal.focus()— right on a desktop, wrong on a phone, where the keyboard covers what you just copied with nothing waiting to be typed.Verification
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-assetsall clean. (cron-service.test.tshas one failure that reproduces on a cleanmaster, unrelated.)Environment
npm i -g aicodeman), Linux hostHappy 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