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