From 756728e55329ccc07f5b8d3b06af1e17a4d0dc36 Mon Sep 17 00:00:00 2001 From: Rounak Datta Date: Wed, 19 Aug 2026 00:07:28 +0000 Subject: [PATCH] feat(mobile): long-press to select terminal text, tap to extend, Copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/web/public/styles.css | 51 +++++ src/web/public/terminal-ui.js | 393 +++++++++++++++++++++++++++++++- test/terminal-touch-tap.test.ts | 197 +++++++++++++++- 3 files changed, 639 insertions(+), 2 deletions(-) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index e0ee7308..f4033582 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3395,6 +3395,57 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { -webkit-touch-callout: none !important; } +/* Touch text-selection bar (long-press → select → Copy). + Lives in styles.css, NOT mobile.css: the gesture is touch-driven, not + width-driven, and mobile.css is media-gated to ≤1023px — a touch tablet in + landscape would get the gesture with no bar to copy from. + Built in JS (index.html is read once at server start, so markup added there + would need a restart to appear). z-index 900 sits above terminal content and + the local-echo overlay (7) and deliberately BELOW floating agent windows + (1000), so it can never cover their controls. */ +.term-select-bar { + position: absolute; + z-index: 900; + display: none; + gap: 2px; + padding: 3px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45); +} + +.term-select-bar.visible { + display: flex; +} + +.term-select-btn { + min-height: 38px; + min-width: 46px; + padding: 0 0.7rem; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text); + font-family: inherit; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + /* The bar is the one place in the terminal subtree a tap must land on a + control rather than a cell, so it opts out of the gesture styles above. */ + touch-action: manipulation; +} + +.term-select-btn:active { + background: var(--bg-hover); +} + +.term-select-btn--close { + min-width: 38px; + padding: 0; + color: var(--text-muted); +} + /* Welcome Overlay */ .welcome-overlay { position: absolute; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 18eec8a2..e7400a3d 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -38,6 +38,19 @@ // a gesture the terminal treats as a scroll but the dismiss handler treats as // a tap would close the keyboard mid-scroll and drop the composer. const MOBILE_KEYBOARD_DISMISS_TAP_SLOP = 8; + // Hold this long, finger still, before a press becomes a text selection. + // + // ⚠️ It must fire well BEFORE the platform's own long-press threshold (~500ms on + // Android), not just under it: the guards this gesture installs are armed when it + // fires, and at 450ms they were still being armed as Chrome ran its own handling + // — which focuses the nearest editable element, so the keyboard shot up over the + // selection the moment it appeared. 350ms is still far above a tap (~100-150ms). + const TOUCH_SELECT_LONG_PRESS_MS = 350; + // How long after a selection gesture the terminal input stays un-focusable. Long + // enough to cover the platform's long-press handling and the compatibility events + // that trail a touchend; short and self-expiring, so a stuck flag can never leave + // the keyboard unreachable. + const TOUCH_SELECT_FOCUS_GUARD_MS = 800; // Regions where a tap must NOT dismiss the on-screen keyboard // (_installMobileKeyboardDismiss). Two groups: anything that is about to take // focus itself, and the accessory bar, which is built to be used while the @@ -206,6 +219,8 @@ TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_TAP_SLOP, + TOUCH_SELECT_LONG_PRESS_MS, + TOUCH_SELECT_FOCUS_GUARD_MS, }; global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES; global.codemanCurrentXtermTheme = currentXtermTheme; @@ -270,6 +285,7 @@ Object.assign(CodemanApp.prototype, { const container = document.getElementById('terminalContainer'); this.terminal.open(container); this._installMobileTapMouseGuard(); + this._installTouchSelectionFocusGuard(); // Suppress xterm key handling during CJK IME composition. // Without this, xterm processes raw keyDown events (e.g., "Process" key) @@ -567,6 +583,18 @@ Object.assign(CodemanApp.prototype, { // Register link provider for clickable file paths in Bash tool output this.registerFilePathLinkProvider(); + // Bar visible ⟺ a selection exists. xterm drops the selection on any keypress, + // on reset and on a tab switch, and a Copy button floating over nothing is a + // trap — one that would copy the PREVIOUS session's text if it still worked. + this.terminal.onSelectionChange?.(() => { + if (!this.terminal?.hasSelection?.()) { + this._touchSelecting = false; + this._touchSelectionActive = false; + this._touchSelectionAnchor = null; + this._hideTouchSelectionBar(); + } + }); + // Mouse wheel: forward to the TUI only for sessions verified to handle SGR // wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local // scrollback otherwise. Claude Code 2.1.187+ scrolls its own @@ -686,6 +714,9 @@ Object.assign(CodemanApp.prototype, { let pixelAccum = 0; let didScroll = false; // track whether touchmove fired (tap vs scroll) + let longPressTimer = null; // armed on touchstart, becomes a text selection + let longPressStartX = 0; + let longPressStartY = 0; let touchStartY = 0; let tapStartedWithTerminalFocus = false; let tapStartIntentCache = null; @@ -695,6 +726,13 @@ Object.assign(CodemanApp.prototype, { container.addEventListener( 'touchstart', (ev) => { + // The selection bar is a child of this container: its buttons own their + // own taps and must not arm a gesture on the terminal underneath. + if (ev.target?.closest?.('.term-select-bar')) return; + if (ev.touches.length !== 1) { + clearTimeout(longPressTimer); + longPressTimer = null; + } if (ev.touches.length === 1) { touchLastX = ev.touches[0].clientX; touchLastY = ev.touches[0].clientY; @@ -725,6 +763,15 @@ Object.assign(CodemanApp.prototype, { ev.preventDefault(); this._blurMobileTerminalInput(); } + // Hold still and this press becomes a text selection. Cancelled by any + // travel past the shared tap slop below, so a scroll can never become one. + longPressStartX = touchLastX; + longPressStartY = touchLastY; + clearTimeout(longPressTimer); + longPressTimer = setTimeout(() => { + longPressTimer = null; + this._beginTouchSelection(longPressStartX, longPressStartY); + }, window.CodemanTerminalInput.TOUCH_SELECT_LONG_PRESS_MS); lastTime = 0; if (scrollFrame) { cancelAnimationFrame(scrollFrame); @@ -738,6 +785,24 @@ Object.assign(CodemanApp.prototype, { container.addEventListener( 'touchmove', (ev) => { + // A drag that follows the long press grows the selection instead of + // scrolling; preventDefault keeps the page from taking the gesture back. + if (this._touchSelecting) { + ev.preventDefault(); + const selTouch = ev.touches[0]; + if (selTouch) this._extendTouchSelection(selTouch.clientX, selTouch.clientY); + return; + } + if (longPressTimer && ev.touches.length === 1) { + const t = ev.touches[0]; + if ( + Math.abs(t.clientX - longPressStartX) > TAP_THRESHOLD || + Math.abs(t.clientY - longPressStartY) > TAP_THRESHOLD + ) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + } if (ev.touches.length === 1 && isTouching) { const touchY = ev.touches[0].clientY; if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) { @@ -779,7 +844,21 @@ Object.assign(CodemanApp.prototype, { container.addEventListener( 'touchend', (ev) => { + if (ev.target?.closest?.('.term-select-bar')) return; + clearTimeout(longPressTimer); + longPressTimer = null; isTouching = false; + if (this._touchSelecting) { + // Lifting ends the DRAG, not the selection: the bar stays up so the + // range can still be extended by tapping, or copied. preventDefault + // cancels the compatibility mouse events this touchend would otherwise + // synthesize — see _endTouchSelectionGesture. + ev.preventDefault(); + velocity = 0; + this._endTouchSelectionGesture(); + tapStartedWithTerminalFocus = false; + return; + } if (!scrollFrame && Math.abs(velocity) > 0.3) { scrollFrame = requestAnimationFrame(scrollLoop); } @@ -798,13 +877,28 @@ Object.assign(CodemanApp.prototype, { } tapStartedWithTerminalFocus = false; }, - { passive: true } + // NOT passive: the selection branch above must be able to preventDefault + // the compatibility mouse events. Every other path leaves the event alone. + { passive: false } ); + // Android Chrome fires `contextmenu` at its long-press threshold and then runs + // its default long-press behaviour. Suppressed ONLY while a selection gesture + // is in flight — a desktop right-click keeps its menu, since the timer is null + // and no gesture is active there. + container.addEventListener('contextmenu', (ev) => { + if (longPressTimer !== null || this._touchSelecting || this._touchSelectionActive) { + ev.preventDefault(); + } + }); + container.addEventListener( 'touchcancel', () => { + clearTimeout(longPressTimer); + longPressTimer = null; isTouching = false; + this._touchSelecting = false; velocity = 0; pixelAccum = 0; tapStartedWithTerminalFocus = false; @@ -1641,6 +1735,294 @@ Object.assign(CodemanApp.prototype, { return true; }, + // ═══════════════════════════════════════════════════════════════ + // Touch text selection — long-press to select, tap to extend, Copy + // ═══════════════════════════════════════════════════════════════ + // + // There was no way to copy terminal text from a phone at all. Three layers + // ruled it out at once: `user-select: none` on the whole terminal subtree + // (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 tap path + // dispatches a zero-movement mousedown/mouseup pair, i.e. a click. + // + // So the gesture drives xterm's selection API directly (`select`, public and + // 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. + + /** + * While a selection gesture is in flight, the terminal input must not hold focus. + * + * ⚠️ This is the guard that actually fixes "the keyboard pops up the moment the + * selection appears". The mouse-event guard cannot: the focus does not arrive + * through a mouse event at all. Android Chrome runs its own long-press handling + * at ~500ms and focuses the nearest editable element — xterm's helper textarea, + * a real