From 7936a75e28ccb0c02f5b365825a458d3fb894da8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 10:46:11 +0200 Subject: [PATCH] feat(terminal): Auto Copy, put a finished selection on the clipboard App Settings > Terminal & Input > Selection & clipboard > Auto Copy Selection (`autoCopySelection`, per-device, default OFF). With it on, highlighting text in the terminal copies it: mouse drag, double-click word, triple-click line, and the phone long-press selection. Ctrl+C is untouched and still copies on demand. Three things decide the shape of it: * It fires at the END of a gesture, never in onSelectionChange. That callback runs for every cell a drag crosses, so copying there would be one clipboard write per mouse move. It only arms a pending flag; a document-level mouseup listener flushes, and the touch path calls the flush itself because it preventDefaults its touchend and no mouseup ever arrives there. * The flush is synchronous inside the handler, because both clipboard paths need user activation: Firefox gates navigator.clipboard .writeText on it, and execCommand('copy'), the fallback the plain-HTTP LAN install lands on, has to run in the gesture's own task. A timer or a wait for onSelectionChange loses it, invisibly in Chrome. * It deliberately does NOT do what copyTerminalSelection() does. That one clears the selection (so a second Ctrl+C is an interrupt) and focuses the terminal. Clearing would make text vanish under the cursor that just highlighted it, and focusing opens the on-screen keyboard over it on a phone. Focus is instead restored to whatever held it, which only matters for the execCommand fallback. Guards are pure in decideAutoCopy() (constants.js): off, blank or whitespace-only text, and a 1M-char cap, since a drag off the top of the viewport autoscrolls and one gesture can sweep the whole 50k-line scrollback. Past the cap the copy is refused rather than truncated, with a toast pointing at Ctrl+C. Feedback is silent on success except once per page load, so a feature that works by doing nothing visible can still be told from a dead toggle; failures and refusals toast, throttled to 10s. Per-device on both counts the settings rule requires: in `displayKeys` and absent from the .strict() SettingsUpdateSchema, because clipboard access differs by device and by origin. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 + docs/architecture-invariants.md | 16 ++ docs/wiki/Settings-Reference.md | 1 + src/web/public/constants.js | 53 +++++ src/web/public/i18n.js | 5 + src/web/public/index.html | 13 ++ src/web/public/settings-ui.js | 11 ++ src/web/public/terminal-ui.js | 126 ++++++++++++ test/terminal-auto-copy.test.ts | 334 ++++++++++++++++++++++++++++++++ 9 files changed, 561 insertions(+) create mode 100644 test/terminal-auto-copy.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index e6efbd1c..902e1a80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -226,6 +226,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`. +**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ It fires at the END of a gesture, never in `onSelectionChange` (that callback runs per cell crossed, so copying there is one clipboard write per mouse move); it only ARMS `_autoCopyPending`, and a document-level `mouseup` listener flushes. ⚠️ The flush is SYNCHRONOUS inside the handler because both clipboard paths need user activation (Firefox gates `navigator.clipboard.writeText` on it, and the plain-HTTP `execCommand` fallback must run in the gesture's own task); a timer or a wait for `onSelectionChange` loses it, invisibly in Chrome. ⚠️ Touch needs its OWN calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()`: that path `preventDefault()`s its touchend, so no mouseup ever arrives and the toggle would be dead on phones. ⚠️ Unlike `copyTerminalSelection()` it must NOT clear the selection (the text would vanish under the cursor that highlighted it) and must NOT focus the terminal (that opens the on-screen keyboard over it); focus is RESTORED to whatever held it, which only matters for the `execCommand` fallback. Guards are pure in `decideAutoCopy()` (constants.js): off, blank/whitespace-only, and a 1M-char cap (an autoscrolling drag can sweep the whole 50k-line scrollback), refused rather than truncated with a toast pointing at Ctrl+C. Silent on success except once per page load; failures toast, throttled 10s. Tests: `test/terminal-auto-copy.test.ts`. + **Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for **claude ≥ 2.1.187 ONLY** at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (`mouse_any_flag=0`, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Detached start + service install** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 75be306f..504ecfa1 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -258,6 +258,22 @@ Invariants: Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses). +### Auto Copy (copy-on-select) + +**Auto Copy** (`autoCopySelection`, per-device, default OFF) puts a finished terminal selection on the clipboard without a keystroke. It is a thin layer over the smart-copy machinery above and shares `_copyText()` with it, but the two paths differ in every decision that matters: + +- **It fires at the END of a gesture, never on selection change.** `onSelectionChange` runs for every cell a drag crosses, so copying there would be one clipboard write per mouse move. The callback only ARMS `_autoCopyPending`; the flush is a document-level `mouseup` listener installed once in `initTerminal`, plus explicit calls from the touch selection path. +- **The flush is synchronous inside the handler.** Both clipboard paths need user activation: Firefox gates `navigator.clipboard.writeText` on it, and `document.execCommand('copy')` (the plain-HTTP fallback that `install.sh`'s LAN option lands on) has to run in the gesture's own task. Deferring to a timer or waiting for `onSelectionChange` loses it, and the failure is browser-specific and invisible in Chrome. +- **The listener is on `document`, not the terminal container**, because a drag that ends outside the terminal (sweeping up past the header) delivers its mouseup to the document. Unrelated mouseups elsewhere on the page are filtered by the decision helper, not by the listener's target. +- **Touch has its own entry point.** `_endTouchSelectionGesture()` and `_selectTouchSelectionLine()` call the flush directly, because the touch path `preventDefault()`s its touchend (that is what stops the compat mouse pair from stealing the selection back), so no mouseup ever reaches the document there. Without those two calls the toggle is simply dead on a phone. +- **It must NOT do what `copyTerminalSelection()` does.** That one clears the selection (so a second `Ctrl+C` is an interrupt) and focuses the terminal. Clearing would make text vanish from under the cursor that just highlighted it, and focusing opens the on-screen keyboard over it on a phone. Focus is instead RESTORED to whatever held it before the copy, which only matters for the `execCommand` fallback (it focuses a temp textarea on the way through); the Clipboard API path never moves focus at all. + +`decideAutoCopy()` (constants.js, pure) holds the guards: setting off, blank or whitespace-only text (what a drag across empty cells produces), and a `AUTO_COPY_MAX_CHARS` (1M) cap. ⚠️ The cap is not decoration: a drag off the top of the viewport autoscrolls, so one gesture can sweep the whole 50k-line scrollback. Past it the copy is REFUSED rather than truncated, with a toast pointing at `Ctrl+C`, which still copies everything through the explicit path. + +⚠️ **Two dedupe rules, and both earn their place.** A genuine selection change (`pending`) always copies, so re-selecting the same text after copying something else in between still works. Otherwise only text differing from the last auto-copy does, which is what stops an unrelated mouseup from re-copying a stale selection AND what makes the first copy of a drag work at all: xterm fires `onSelectionChange` from its own document `mouseup` handler, and listener order between the two is registration order, not something this code controls. Gating on `pending` alone silently drops that first copy. + +Feedback is silent on success except ONCE per page load (a feature that works by doing nothing visible cannot otherwise be told from a dead toggle); failures and refusals toast, throttled to 10s so a permanently blocked clipboard cannot paint a toast on every drag. The setting is per-device on both counts required by the settings rule: it is in `displayKeys` AND absent from the `.strict()` `SettingsUpdateSchema` (clipboard access differs by device and by origin, and the plain-HTTP LAN install has no `navigator.clipboard` at all). Tests: `test/terminal-auto-copy.test.ts`. + ### Settings surface: App Settings, Session Options, Add Case **One visual language, three modals.** `#appSettingsModal`, `#sessionOptionsModal` and `#createCaseModal` share the `set-*` surface (left rail, sections of grouped row cards, label + description on the left, control pinned right) through a single `:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal)` scope in `styles.css`. An `:is()` list takes the specificity of its **most specific argument**, and all three arguments are ids, so every rule kept exactly the weight it had when the block was `#appSettingsModal`-only: nothing downstream shifted in the cascade. That property is what let the surface absorb Session Options and then Add Case in two separate commits without a cascade audit each time. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 3eec0d2d..6e8e31ed 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -45,6 +45,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See | CJK Input | Off | IME composition through a dedicated text field. | | Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. | | Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. | +| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. | | WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. | | Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. | diff --git a/src/web/public/constants.js b/src/web/public/constants.js index aaa9b31f..e672be19 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -562,6 +562,55 @@ function resolveTerminalFontFamily(custom) { return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`; } +// --------------------------------------------------------------------------- +// Auto Copy (copy-on-select). Pure decision, so every guard below is testable +// without a terminal, a clipboard, or a browser. +// --------------------------------------------------------------------------- + +/** + * Upper bound on an AUTO-copied selection. + * + * A drag that runs off the top of the viewport autoscrolls, so one gesture can + * sweep the entire 50k-line scrollback (millions of characters), and writing + * that to the clipboard on every mouseup is a real hazard on a phone. Past the + * cap the copy is REFUSED rather than truncated (half a selection on the + * clipboard is worse than none) and the user is told to press Ctrl+C, which + * still copies the whole thing through the explicit path. + */ +const AUTO_COPY_MAX_CHARS = 1_000_000; + +/** + * What an auto-copy attempt should do at the end of a selection gesture. + * + * `pending` is set by xterm's onSelectionChange and cleared on every flush; + * `lastCopied` is the text this surface auto-copied last. Either one alone is + * wrong, which is why both are here: + * + * - onSelectionChange does not reliably fire BEFORE the mouseup that ends the + * drag (xterm fires it from its own document-level mouseup handler, and + * listener order between the two is registration order, not something this + * code controls). Gating on `pending` alone would silently drop the first + * copy of a drag-selection. + * - Gating on `text !== lastCopied` alone drops a deliberate re-selection of + * the same text after the user copied something else in between, and it + * would let any unrelated mouseup on the page re-copy a stale selection. + * + * So: a genuine selection change (`pending`) always copies, and otherwise only + * text that differs from the last auto-copy does. + * + * @param {{enabled?: boolean, text?: string, lastCopied?: string, pending?: boolean}} params + * @returns {'copy'|'skip'|'too-large'} + */ +function decideAutoCopy({ enabled, text, lastCopied, pending } = {}) { + if (!enabled) return 'skip'; + // Whitespace-only is what a drag across blank cells produces; putting a wall + // of spaces on the clipboard is never what the gesture meant. + if (typeof text !== 'string' || !text.trim()) return 'skip'; + if (!pending && text === lastCopied) return 'skip'; + if (text.length > AUTO_COPY_MAX_CHARS) return 'too-large'; + return 'copy'; +} + if (typeof window !== 'undefined') { window.WEBGL_FALLBACK = WEBGL_FALLBACK; window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip; @@ -595,6 +644,10 @@ if (typeof window !== 'undefined') { compare: compareSessionActivity, sort: sortSessionsByActivity, }; + window.CodemanAutoCopy = { + decide: decideAutoCopy, + MAX_CHARS: AUTO_COPY_MAX_CHARS, + }; window.CodemanTerminalFont = { DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK, resolve: resolveTerminalFontFamily, diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 12b1c57a..dcebdca7 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -335,6 +335,11 @@ 'Extended Keyboard Bar': '扩展键盘栏', 'Gesture Control (beta)': '手势控制(测试版)', 'Wheel Scrolls Local History': '滚轮滚动本地历史', + 'Auto Copy Selection': '自动复制选中内容', + 'Selection & clipboard': '选中与剪贴板', + 'Auto Copy: selection copied': '自动复制:已复制选中内容', + 'Auto Copy failed: the browser blocked clipboard access': '自动复制失败:浏览器阻止了剪贴板访问', + 'Selection too large to copy automatically. Press Ctrl+C.': '选中内容过大,无法自动复制。请按 Ctrl+C。', 'Instant typing feedback with local echo': '通过本地回显即时显示输入', 'Dedicated IME input field for CJK languages': '为中日韩语言提供专用输入法文本框', 'Extra keys: Tab, Esc, arrows, Ctrl+O': '附加按键:Tab、Esc、方向键、Ctrl+O', diff --git a/src/web/public/index.html b/src/web/public/index.html index 480d287f..c5efd91b 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1627,6 +1627,19 @@ +
+

Selection & clipboard

device
+
+
+
+ Auto Copy Selection + Put highlighted terminal text on the clipboard as soon as you finish selecting it, with mouse, double-click or long-press. Ctrl+C still copies on demand, and nothing outside the terminal is copied. +
+ +
+
+
+

Font

device
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index e91a2806..56d7b5f3 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -381,6 +381,10 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; this.loadTunnelStatus(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); + // Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly + // overwrites the system clipboard on a gesture the user may have meant only as + // a way to read, so it is opt-in rather than a default anyone has to discover. + document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true; document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || ''; document.getElementById('appSettingsTerminalWheelLocal').checked = settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false; @@ -2007,6 +2011,7 @@ Object.assign(CodemanApp.prototype, { imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked, tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, + autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked, terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(), terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked, cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked, @@ -2203,6 +2208,11 @@ Object.assign(CodemanApp.prototype, { showFileViewerButton: _fvb, webglRendererEnabled: _wgl, terminalWheelLocalScrollback: _twls, + // Copy-on-select. Per-device (clipboard access differs by device and by + // origin: the plain-HTTP LAN install has no navigator.clipboard at all) + // and absent from SettingsUpdateSchema (.strict()), so sending it would + // 400 the whole settings PUT. + autoCopySelection: _acs, // Per-device by nature (the font must exist on the device) and absent // from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT. terminalFontFamily: _tff, @@ -2883,6 +2893,7 @@ Object.assign(CodemanApp.prototype, { 'terminalFontFamily', 'language', 'terminalWheelLocalScrollback', + 'autoCopySelection', 'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showTabDetachButton', 'mobileOverviewEnabled', diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index ed507935..6fb97b76 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -592,9 +592,35 @@ Object.assign(CodemanApp.prototype, { this._touchSelectionActive = false; this._touchSelectionAnchor = null; this._hideTouchSelectionBar(); + // Nothing highlighted, nothing for Auto Copy to flush. xterm drops the + // selection on any keypress, so without this a keystroke would leave a + // stale flush armed for the next unrelated mouseup. + this._autoCopyPending = false; + return; } + // Auto Copy arms here and fires at the END of the gesture (below). This + // callback runs on EVERY cell the drag crosses, so copying here would be + // one clipboard write per mouse move. + this._autoCopyPending = true; }); + // Auto Copy (copy-on-select, per-device `autoCopySelection`, default OFF). + // + // ⚠️ The flush is SYNCHRONOUS inside the mouseup handler, deliberately: both + // clipboard paths need user activation (Firefox gates navigator.clipboard + // .writeText on it, and Safari requires document.execCommand('copy'), the + // plain-HTTP fallback install.sh's LAN option lands on, to run inside the + // gesture's own task). Deferring to a timer or to onSelectionChange loses it. + // + // ⚠️ document-level, because a drag that ends outside the terminal (the user + // sweeps up past the header) delivers its mouseup to the document, not to + // the container. Unrelated mouseups elsewhere on the page are filtered by + // decideAutoCopy, not by the listener's target. + if (!this._autoCopyListenerInstalled) { + this._autoCopyListenerInstalled = true; + document.addEventListener('mouseup', () => this._flushAutoCopySelection()); + } + // 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 @@ -1892,6 +1918,11 @@ Object.assign(CodemanApp.prototype, { this._suppressTrustedTapMouseEvents(); this._armTouchSelectionFocusGuard(); this._positionTouchSelectionBar(); + // Auto Copy's other entry point. The touchend that ends this gesture is + // preventDefault()ed (that is what stops the compat mouse pair from + // stealing the selection back), so the document mouseup listener never sees + // this surface at all, so a phone would have a dead toggle without this call. + this._flushAutoCopySelection(); }, /** Whole logical line under the anchor — the common case a word selection just missed. */ @@ -1912,6 +1943,9 @@ Object.assign(CodemanApp.prototype, { this._touchSelectionAnchor = { index, length }; this._applyTouchSelection(index, length); this._positionTouchSelectionBar(); + // Widening to the whole line is a new selection the user asked for, and the + // button's click carries the activation both clipboard paths need. + this._flushAutoCopySelection(); }, /** Copy through the shared path: Clipboard API, else execCommand (plain-HTTP installs). */ @@ -3878,6 +3912,98 @@ Object.assign(CodemanApp.prototype, { return ok; }, + /** + * Auto Copy's ON/OFF, read at flush time from the CACHED settings object + * (loadAppSettingsFromStorage memoizes, so this is not a localStorage hit). + * + * Reading it here rather than mirroring it into a field is deliberate: there + * is then no apply-path a future settings save can forget to call, and the + * toggle takes effect on the very next selection instead of the next reload. + */ + _autoCopySelectionEnabled() { + try { + return this.loadAppSettingsFromStorage?.()?.autoCopySelection === true; + } catch { + return false; + } + }, + + /** + * Copy the current terminal selection because the user finished highlighting + * it. Called at the end of a selection GESTURE: the document mouseup + * installed in initTerminal, and the touch-selection gesture end (a touchend + * the touch path preventDefaults, so no mouseup ever arrives there). + * + * ⚠️ This is NOT copyTerminalSelection(): that one clears the selection (so a + * second Ctrl+C is an interrupt) and hands focus back to the terminal. Both + * are wrong here: clearing would make the text vanish from under the cursor + * that just highlighted it, and focusing opens the on-screen keyboard over + * the text on a phone. Focus is instead RESTORED to whatever held it, which + * only matters for the execCommand fallback (it focuses a temp textarea on + * the way through); the Clipboard API path never moves focus at all. + */ + async _flushAutoCopySelection() { + const decide = window.CodemanAutoCopy?.decide; + if (!decide || !this.terminal) return; + const text = this.terminal.hasSelection?.() ? this.terminal.getSelection() : ''; + const verdict = decide({ + enabled: this._autoCopySelectionEnabled(), + text, + lastCopied: this._autoCopyLastText, + pending: !!this._autoCopyPending, + }); + this._autoCopyPending = false; + if (verdict === 'skip') return; + if (verdict === 'too-large') { + this._autoCopyNotify('Selection too large to copy automatically. Press Ctrl+C.', 'warning'); + return; + } + + this._autoCopyLastText = text; + const focusedBefore = document.activeElement; + const ok = await this._copyText(text); + if ( + focusedBefore && + focusedBefore !== document.activeElement && + focusedBefore.isConnected && + typeof focusedBefore.focus === 'function' + ) { + try { + focusedBefore.focus(); + } catch {} + } + if (!ok) { + // Let the next gesture retry the same text rather than dedupe itself into + // silence after a transient clipboard refusal. + this._autoCopyLastText = ''; + this._autoCopyNotify('Auto Copy failed: the browser blocked clipboard access', 'error'); + return; + } + // Silent on success, like every terminal emulator's copy-on-select, except + // ONCE per page load: a feature that works by doing nothing visible needs to + // say so the first time, or the user cannot tell it from a dead toggle. + if (!this._autoCopyHintShown) { + this._autoCopyHintShown = true; + this.showToast('Auto Copy: selection copied', 'success'); + } + }, + + /** + * Failure/refusal toast, throttled. Auto Copy fires per gesture, so an + * install where the clipboard is permanently blocked would otherwise paint a + * toast on every drag, which trains the user to ignore toasts everywhere. + */ + _autoCopyNotify(message, type) { + const now = Date.now(); + const last = this._autoCopyNotifiedAt; + // `last === undefined` is checked rather than falling back to 0: the first + // failure must always be reported, and a `now - 0 < throttle` comparison + // only happens to hold because the wall clock is a big number. + if (last !== undefined && now - last < 10_000) return; + this._autoCopyNotifiedAt = now; + this.showToast(message, type); + }, + _syncMobileHelperTextareaToCursor() { if (!MobileDetection.isTouchDevice() || !this.terminal?.element) return; try { diff --git a/test/terminal-auto-copy.test.ts b/test/terminal-auto-copy.test.ts new file mode 100644 index 00000000..af41240c --- /dev/null +++ b/test/terminal-auto-copy.test.ts @@ -0,0 +1,334 @@ +/** + * Auto Copy (copy-on-select) guards. + * + * The feature is invisible when it works, so every bug in it is silent: a + * clipboard that quietly stops updating, or one that quietly overwrites itself + * on an unrelated click. These tests drive the SHIPPED decision helper + * (constants.js) and the SHIPPED flush (terminal-ui.js), plus the settings + * wiring that decides whether the toggle reaches the code at all. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8'); + +function loadHarness() { + const CodemanApp = function CodemanApp(this: any) {}; + const windowRef: Record = {}; + const documentListeners = new Map void)[]>(); + let activeElement: any = null; + let now = 1_000; + + const RealDate = Date; + const DateStub: any = function DateStub(this: any, ...args: any[]) { + return new (RealDate as any)(...args); + }; + DateStub.now = () => now; + + const context = vm.createContext({ + window: windowRef, + document: { + body: { classList: { contains: () => false } }, + get activeElement() { + return activeElement; + }, + getElementById: () => null, + addEventListener: (type: string, listener: (ev: any) => void) => { + const list = documentListeners.get(type) ?? []; + list.push(listener); + documentListeners.set(type, list); + }, + }, + CodemanApp, + console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() }, + _crashDiag: { log: vi.fn() }, + Date: DateStub, + performance: { now: () => now }, + requestAnimationFrame: (_fn: () => void) => 1, + setTimeout: (_fn: () => void) => 1, + Blob: function Blob() {}, + URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} }, + Worker: function Worker(this: any) { + this.postMessage = () => {}; + }, + MobileDetection: { isTouchDevice: () => false }, + KeyboardHandler: { keyboardVisible: false }, + DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + TERMINAL_CHUNK_SIZE: 32 * 1024, + }); + + vm.runInContext(read('constants.js'), context, { filename: 'constants.js' }); + vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' }); + + const app = new (CodemanApp as any)(); + const toasts: { message: string; type: string }[] = []; + app.showToast = (message: string, type: string) => toasts.push({ message, type }); + app._copyText = vi.fn(async () => true); + app.loadAppSettingsFromStorage = () => ({ autoCopySelection: true }); + app.terminal = { + hasSelection: () => true, + getSelection: () => 'copied text', + clearSelection: vi.fn(), + focus: vi.fn(), + }; + + return { + app, + windowRef, + toasts, + setNow: (value: number) => { + now = value; + }, + setActiveElement: (element: any) => { + activeElement = element; + }, + }; +} + +describe('decideAutoCopy', () => { + const decide = (params: Record) => loadHarness().windowRef.CodemanAutoCopy.decide(params); + + it('does nothing while the setting is off', () => { + expect(decide({ enabled: false, text: 'hello', pending: true })).toBe('skip'); + }); + + it('ignores a blank or whitespace-only selection', () => { + // A drag across empty cells; a wall of spaces is never what the gesture meant. + expect(decide({ enabled: true, text: ' \n ', pending: true })).toBe('skip'); + expect(decide({ enabled: true, text: '', pending: true })).toBe('skip'); + }); + + it('copies a genuine selection change even when the text repeats', () => { + // Re-selecting the same text after copying something else in between is a + // deliberate act, so `pending` outranks the text dedupe. + expect(decide({ enabled: true, text: 'same', lastCopied: 'same', pending: true })).toBe('copy'); + }); + + it('copies changed text when onSelectionChange has not fired yet', () => { + // xterm fires onSelectionChange from its OWN document mouseup handler, and + // listener order between the two is not something the app controls, so the + // first copy of a drag must not depend on `pending`. + expect(decide({ enabled: true, text: 'fresh', lastCopied: 'stale', pending: false })).toBe('copy'); + }); + + it('leaves an unchanged selection alone on an unrelated mouseup', () => { + expect(decide({ enabled: true, text: 'same', lastCopied: 'same', pending: false })).toBe('skip'); + }); + + it('refuses a selection past the size cap instead of truncating it', () => { + const { windowRef } = loadHarness(); + const huge = 'x'.repeat(windowRef.CodemanAutoCopy.MAX_CHARS + 1); + expect(windowRef.CodemanAutoCopy.decide({ enabled: true, text: huge, pending: true })).toBe('too-large'); + }); +}); + +describe('_flushAutoCopySelection', () => { + it('copies the selection without clearing it or stealing focus', async () => { + const { app } = loadHarness(); + app._autoCopyPending = true; + + await app._flushAutoCopySelection(); + + expect(app._copyText).toHaveBeenCalledWith('copied text'); + // The two things copyTerminalSelection does and this path must not: clearing + // makes the text vanish under the cursor that highlighted it, and focusing + // opens the on-screen keyboard over it on a phone. + expect(app.terminal.clearSelection).not.toHaveBeenCalled(); + expect(app.terminal.focus).not.toHaveBeenCalled(); + }); + + it('starts the copy synchronously, inside the gesture that triggered it', () => { + // Both clipboard paths need user activation: Firefox gates + // navigator.clipboard.writeText on it, and execCommand('copy') (the + // plain-HTTP fallback) must run in the gesture's own task. + const { app } = loadHarness(); + app._autoCopyPending = true; + + void app._flushAutoCopySelection(); + + expect(app._copyText).toHaveBeenCalledTimes(1); + }); + + it('stays silent while the setting is off', async () => { + const { app, toasts } = loadHarness(); + app.loadAppSettingsFromStorage = () => ({ autoCopySelection: false }); + app._autoCopyPending = true; + + await app._flushAutoCopySelection(); + + expect(app._copyText).not.toHaveBeenCalled(); + expect(toasts).toEqual([]); + }); + + it('treats a missing setting as off', async () => { + const { app } = loadHarness(); + app.loadAppSettingsFromStorage = () => ({}); + app._autoCopyPending = true; + + await app._flushAutoCopySelection(); + + expect(app._copyText).not.toHaveBeenCalled(); + }); + + it('announces itself once per page load, then goes quiet', async () => { + const { app, toasts } = loadHarness(); + let text = 'first'; + app.terminal.getSelection = () => text; + + app._autoCopyPending = true; + await app._flushAutoCopySelection(); + text = 'second'; + app._autoCopyPending = true; + await app._flushAutoCopySelection(); + + expect(app._copyText).toHaveBeenCalledTimes(2); + expect(toasts).toEqual([{ message: 'Auto Copy: selection copied', type: 'success' }]); + }); + + it('disarms the pending flag so the next unrelated mouseup copies nothing', async () => { + const { app } = loadHarness(); + app._autoCopyPending = true; + + await app._flushAutoCopySelection(); + await app._flushAutoCopySelection(); + + expect(app._autoCopyPending).toBe(false); + expect(app._copyText).toHaveBeenCalledTimes(1); + }); + + it('hands focus back when the execCommand fallback took it', async () => { + const { app, setActiveElement } = loadHarness(); + const terminalTextarea = { focus: vi.fn(), isConnected: true }; + setActiveElement(terminalTextarea); + // The fallback appends a temp textarea, selects it, then removes it, which + // leaves the document with no focused element at all. + app._copyText = vi.fn(async () => { + setActiveElement(null); + return true; + }); + app._autoCopyPending = true; + + await app._flushAutoCopySelection(); + + expect(terminalTextarea.focus).toHaveBeenCalledTimes(1); + }); + + it('leaves focus alone when the clipboard API never moved it', async () => { + const { app, setActiveElement } = loadHarness(); + const terminalTextarea = { focus: vi.fn(), isConnected: true }; + setActiveElement(terminalTextarea); + app._autoCopyPending = true; + + await app._flushAutoCopySelection(); + + expect(terminalTextarea.focus).not.toHaveBeenCalled(); + }); + + it('reports a blocked clipboard and lets the next gesture retry the same text', async () => { + const { app, toasts } = loadHarness(); + app._copyText = vi.fn(async () => false); + app._autoCopyPending = true; + + await app._flushAutoCopySelection(); + expect(toasts).toEqual([{ message: 'Auto Copy failed: the browser blocked clipboard access', type: 'error' }]); + + // Without the reset, the dedupe would swallow every retry of the same text. + app._autoCopyPending = false; + await app._flushAutoCopySelection(); + expect(app._copyText).toHaveBeenCalledTimes(2); + }); + + it('throttles the failure toast so a blocked clipboard cannot spam every drag', async () => { + const { app, toasts, setNow } = loadHarness(); + app._copyText = vi.fn(async () => false); + + app._autoCopyPending = true; + await app._flushAutoCopySelection(); + setNow(2_000); + app._autoCopyPending = true; + await app._flushAutoCopySelection(); + expect(toasts).toHaveLength(1); + + setNow(1_000 + 10_001); + app._autoCopyPending = true; + await app._flushAutoCopySelection(); + expect(toasts).toHaveLength(2); + }); + + it('refuses an oversized selection and says how to copy it anyway', async () => { + const { app, toasts, windowRef } = loadHarness(); + const huge = 'x'.repeat(windowRef.CodemanAutoCopy.MAX_CHARS + 1); + app.terminal.getSelection = () => huge; + app._autoCopyPending = true; + + await app._flushAutoCopySelection(); + + expect(app._copyText).not.toHaveBeenCalled(); + expect(toasts).toEqual([{ message: 'Selection too large to copy automatically. Press Ctrl+C.', type: 'warning' }]); + }); + + it('does nothing when the selection was already dropped', async () => { + const { app } = loadHarness(); + app.terminal.hasSelection = () => false; + app._autoCopyPending = true; + + await app._flushAutoCopySelection(); + + expect(app._copyText).not.toHaveBeenCalled(); + }); +}); + +describe('Auto Copy wiring', () => { + const terminalUi = read('terminal-ui.js'); + const settingsUi = read('settings-ui.js'); + const html = read('index.html'); + const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8'); + + it('arms on a selection change and disarms when the selection is dropped', () => { + const start = terminalUi.indexOf('this.terminal.onSelectionChange?.('); + expect(start).toBeGreaterThan(-1); + const body = terminalUi.slice(start, start + 1200); + expect(body).toContain('this._autoCopyPending = false;'); + expect(body).toContain('this._autoCopyPending = true;'); + }); + + it('flushes from the document mouseup, not from the selection change', () => { + // Copying inside onSelectionChange would be one clipboard write per cell the + // drag crosses. + expect(terminalUi).toContain("document.addEventListener('mouseup', () => this._flushAutoCopySelection());"); + const start = terminalUi.indexOf('this.terminal.onSelectionChange?.('); + const body = terminalUi.slice(start, terminalUi.indexOf('_autoCopyListenerInstalled', start)); + expect(body).not.toContain('_flushAutoCopySelection'); + }); + + it('flushes from the touch gesture end, which never produces a mouseup', () => { + // The touch path preventDefaults its touchend to stop the compat mouse pair + // from stealing the selection back, so phones need their own call. + const start = terminalUi.indexOf('_endTouchSelectionGesture() {'); + expect(start).toBeGreaterThan(-1); + expect(terminalUi.slice(start, start + 900)).toContain('this._flushAutoCopySelection();'); + }); + + it('keeps the toggle per-device: display key, stripped from the PUT, absent from the schema', () => { + const displayKeys = settingsUi.slice( + settingsUi.indexOf('const displayKeys = new Set(['), + settingsUi.indexOf('])', settingsUi.indexOf('const displayKeys = new Set([')) + ); + expect(displayKeys).toContain("'autoCopySelection'"); + // SettingsUpdateSchema is .strict(), so a key it does not declare 400s the + // whole settings PUT if the client sends it. + expect(settingsUi).toContain('autoCopySelection: _acs,'); + expect(schemas).not.toContain('autoCopySelection'); + }); + + it('keeps the control loadable and savable by id', () => { + expect(html).toContain('id="appSettingsAutoCopySelection"'); + expect(settingsUi).toContain( + "document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;" + ); + expect(settingsUi).toContain("autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,"); + }); +});