diff --git a/CLAUDE.md b/CLAUDE.md index 9867dfdd..f0aa4268 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -226,7 +226,9 @@ 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`. -**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) +**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). ⚠️ **A click is hand-reported to the CLI only while the CLI actually has mouse tracking on.** The full strip removes the mouse DECSETs, so xterm's `mouseTrackingMode` is permanently `none` there and the browser hand-encodes SGR reports (`_sendSyntheticSgrTap`); without state it did that on EVERY click, so a stripped-mode pane running a plain shell (CLI exited, or a shell started inside a claude-mode session) received reports it never asked for and printed them as literal text (`[<0;88;20M`), garbling the next typed line. `_recordStrippedMouseMode()` (session.ts) records what the strip removes, `toState()` publishes `cliMouseTracking`, and `_shouldReportMouseToCli()` gates all three report sites on it. Only 1000/1001/1002/1003 count (1005/1006 are encodings, 1007 is alt-scroll), and the change broadcasts UNdebounced since a dialog can be clicked inside the 500ms window. `_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..c0b3cc06 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -96,7 +96,9 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ### Terminal scrollback: strip flavors and wheel/touch forwarding -**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend `_sessionUsesServerMouseStrip()` mirror stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`. +**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend mirror (`_shouldReportMouseToCli()`) stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`. + +⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously (so clicks keep being reported exactly as before), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. **Only claude ≥ 2.1.187 forwards the wheel; every other mode scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS stay enabled for codex (`_sessionUsesServerMouseStrip`); measured, they are no-ops that insert nothing, so click-to-position is simply unavailable there rather than harmful. @@ -258,6 +260,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/session.ts b/src/session.ts index 42dcac62..bd361e82 100644 --- a/src/session.ts +++ b/src/session.ts @@ -415,6 +415,15 @@ export class Session extends EventEmitter { // sequences split across PTY chunks can't slip past the alt-screen/scrollback // strip (see _handleTerminalOutput / isAltScreenStripMode) private _altScreenSeqCarry: string = ''; + + /** + * Mouse-tracking DECSET modes the CLI currently has ON, as observed while + * STRIPPING them out of the stream below. Kept as a set rather than a boolean + * because a TUI may enable 1002 and later disable 1000 (a mode it never + * enabled); tracking is on while any of them is. + */ + private _cliMouseModes = new Set(); + private _cliMouseTracking = false; private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null; private rejectPromise: ((reason: Error) => void) | null = null; private _promptResolved: boolean = false; // Guard against race conditions in runPrompt @@ -1285,6 +1294,7 @@ export class Session extends EventEmitter { niceValue: this._niceConfig.niceValue, color: this._color, flickerFilterEnabled: this._flickerFilterEnabled, + cliMouseTracking: this._cliMouseTracking || undefined, cliVersion: this._cliVersion || undefined, cliModel: this._cliModel || undefined, cliAccountType: this._cliAccountType || undefined, @@ -1546,6 +1556,46 @@ export class Session extends EventEmitter { }; } + /** + * Remember whether the CLI currently wants to be told about mouse clicks. + * + * The strip in {@link _handleTerminalOutput} is the ONLY place these sequences + * exist. After it, neither the browser nor xterm can ever learn that the CLI + * asked for mouse tracking, so `terminal.modes.mouseTrackingMode` is + * permanently 'none' for a stripped mode. The browser hand-encodes SGR reports + * to compensate (`_sendSyntheticSgrTap` in terminal-ui.js), and with no state + * to consult it had to do that on EVERY click, delivering mouse reports to a + * CLI that never asked for them. Publishing this through `toState()` is what + * lets the browser report a click only when the CLI is listening. + * + * Only the TRACKING modes count. 1005/1006 select an encoding and 1007 is + * alt-scroll; a CLI that picks SGR encoding without turning a tracking mode on + * is not asking about clicks, and counting those would put the stray reports + * straight back. + * + * This must stay in lockstep with the strip regex that calls it: a sequence + * removed from the stream but not recorded here is one the browser can neither + * see nor be told about. + */ + private _recordStrippedMouseMode(seq: string): void { + // eslint-disable-next-line no-control-regex + const match = /\x1b\[\?(\d+)([hl])$/.exec(seq); + if (!match) return; + const mode = Number(match[1]); + if (mode !== 1000 && mode !== 1001 && mode !== 1002 && mode !== 1003) return; + if (match[2] === 'h') this._cliMouseModes.add(mode); + else this._cliMouseModes.delete(mode); + this._syncCliMouseTracking(); + } + + /** Emit only on a real transition: a TUI re-emitting its enable on every repaint costs nothing. */ + private _syncCliMouseTracking(): void { + const active = this._cliMouseModes.size > 0; + if (active === this._cliMouseTracking) return; + this._cliMouseTracking = active; + this.emit('mouseTrackingChanged', active); + } + private _handleTerminalOutput(data: string): void { // Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus // mouse-tracking enables that hijack the scroll wheel so the user can't reach @@ -1598,7 +1648,10 @@ export class Session extends EventEmitter { // eslint-disable-next-line no-control-regex .replace(/\x1b\[3J/g, '') // eslint-disable-next-line no-control-regex - .replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, ''); + .replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => { + this._recordStrippedMouseMode(seq); + return ''; + }); } } @@ -2525,6 +2578,11 @@ export class Session extends EventEmitter { this._messages = []; this._lineBuffer = ''; this._altScreenSeqCarry = ''; + // A restarted pane starts with no mouse mode: the new program has not asked + // for one yet, and carrying the old CLI's state over would report clicks + // into a program that never enabled tracking. + this._cliMouseModes.clear(); + this._syncCliMouseTracking(); this._markActivity(true); } diff --git a/src/types/session.ts b/src/types/session.ts index 1ffa6dd8..21a7faa2 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -500,6 +500,14 @@ export interface SessionState { color?: SessionColor; /** Flicker filter enabled (buffers output after screen clears) */ flickerFilterEnabled?: boolean; + /** + * True while the CLI in the pane has a mouse-tracking DECSET on, as observed + * by the server on its way out of the stream (those sequences are stripped for + * claude/codex/gemini, so the browser can never see them itself). The browser + * hand-encodes a click report ONLY when this is true; without it, every click + * sent mouse reports to a CLI that never asked for them. + */ + cliMouseTracking?: boolean; /** Claude Code CLI version (parsed from terminal, e.g., "2.1.27") */ cliVersion?: string; /** Claude model in use (parsed from terminal, e.g., "Opus 4.5") */ 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 2190263d..12331f9f 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -390,6 +390,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; @@ -2016,6 +2020,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, @@ -2212,6 +2217,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, @@ -2892,6 +2902,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..f1100f18 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 { @@ -3918,7 +4044,7 @@ Object.assign(CodemanApp.prototype, { const mouseMode = this.terminal.modes?.mouseTrackingMode; const mouseTrackingOn = !!mouseMode && mouseMode !== 'none'; - if (!mouseTrackingOn && !this._sessionUsesServerMouseStrip()) return 'input'; + if (!mouseTrackingOn && !this._shouldReportMouseToCli()) return 'input'; const buffer = this.terminal.buffer?.active; if (!buffer?.getLine) return 'input'; @@ -4218,9 +4344,10 @@ Object.assign(CodemanApp.prototype, { // xterm's mouse encoder owns live DECSET modes. The synthetic DOM click // follows the same path as a desktop click. this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); - } else if (shouldActivate && this._sessionUsesServerMouseStrip()) { + } else if (shouldActivate && this._shouldReportMouseToCli()) { // Claude/Codex/Gemini DECSETs are stripped from the browser stream, so - // report directly to the PTY while retaining local touch scrollback. + // report directly to the PTY while retaining local touch scrollback. Only + // while the CLI actually has tracking on (see _shouldReportMouseToCli). this._sendSyntheticSgrTap(touch.clientX, touch.clientY); } @@ -4288,9 +4415,32 @@ Object.assign(CodemanApp.prototype, { // output stream has mouse-tracking DECSET sequences stripped before reaching the // browser. For these, xterm's live mouseTrackingMode is useless as a gate — the // PTY-side TUI keeps tracking enabled, we just never see the enable sequence. - _sessionUsesServerMouseStrip() { - const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; - return mode === 'claude' || mode === 'codex' || mode === 'gemini'; + /** + * True when the browser has to hand-encode a click report for the CLI. + * + * Two conditions, and dropping either one is a bug that has already happened: + * + * 1. The session's mode is one whose mouse DECSETs the server STRIPS out of + * the stream (claude/codex/gemini, `isAltScreenStripMode`), which is why + * xterm's own encoder is permanently idle here and something has to stand + * in for it. + * 2. The CLI actually has a mouse-tracking mode on right now. The server + * records that as it strips (`_recordStrippedMouseMode` in session.ts) and + * publishes it as `cliMouseTracking`. Without this half the browser + * reported EVERY click, so a CLI sitting at its composer with no dialog + * open, or a pane that has fallen back to a shell prompt, received mouse + * reports it never asked for. A shell prints those as literal text + * (`[<0;88;20M`) and they garble the next line typed. + * + * Fails toward silence: an unknown or stale flag reports nothing rather than + * injecting bytes. After a server restart the flag is false until the CLI + * re-emits its DECSET, which closing and reopening a dialog does. + */ + _shouldReportMouseToCli() { + const session = this.sessions?.get(this.activeSessionId); + const mode = session?.mode || 'claude'; + if (mode !== 'claude' && mode !== 'codex' && mode !== 'gemini') return false; + return session?.cliMouseTracking === true; }, // True when xterm's viewport shows the live PTY screen (not scrolled up into @@ -4556,7 +4706,7 @@ Object.assign(CodemanApp.prototype, { // Desktop counterpart of the touchend tap branch: hand-encode an SGR report // for a plain left-click when the server strips mouse DECSETs (see - // _sessionUsesServerMouseStrip). Every skip below is a click that already has + // _shouldReportMouseToCli). Every skip below is a click that already has // a meaning elsewhere: synthetic/compat clicks after a touch tap (touchend // reported already), modified clicks (shift keeps xterm's selection // override), double/triple clicks (word/line selection), drag-selections, @@ -4570,7 +4720,7 @@ Object.assign(CodemanApp.prototype, { if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return; const mode = this.terminal.modes?.mouseTrackingMode; if (mode && mode !== 'none') return; - if (!this._sessionUsesServerMouseStrip()) return; + if (!this._shouldReportMouseToCli()) return; if (this.terminal.hasSelection?.()) return; if (this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider) if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return; diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index 1d619d2b..33724557 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -52,6 +52,7 @@ export interface SessionListenerRefs { limitResumeCancelled: (data: { reason: string }) => void; respawnBreakerTripped: (data: { count: number }) => void; cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void; + mouseTrackingChanged: (active: boolean) => void; ralphLoopUpdate: (state: RalphTrackerState) => void; ralphTodoUpdate: (todos: RalphTodoItem[]) => void; ralphCompletionDetected: (phrase: string) => void; @@ -350,6 +351,20 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe deps.broadcastSessionStateDebounced(session.id); }, + /** + * The CLI turned mouse tracking on or off (observed while stripping the + * DECSETs out of the stream). Rides the full session state so the browser + * learns it through the session object it already merges, with no new SSE + * event to keep in sync across the two registries. + * + * Broadcast IMMEDIATELY, not debounced: this flips when a dialog opens, and + * a user can click that dialog inside the 500ms debounce window, which is + * exactly the click that has to be reported. + */ + mouseTrackingChanged: () => { + deps.broadcast(SseEvent.SessionUpdated, { session: deps.getSessionStateWithRespawn(session) }); + }, + // ─── Ralph Tracking Events ────────────────────────────── /** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */ @@ -461,6 +476,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe session.on('limitResumeCancelled', refs.limitResumeCancelled); session.on('respawnBreakerTripped', refs.respawnBreakerTripped); session.on('cliInfoUpdated', refs.cliInfoUpdated); + session.on('mouseTrackingChanged', refs.mouseTrackingChanged); session.on('ralphLoopUpdate', refs.ralphLoopUpdate); session.on('ralphTodoUpdate', refs.ralphTodoUpdate); session.on('ralphCompletionDetected', refs.ralphCompletionDetected); @@ -495,6 +511,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe session.off('limitResumeCancelled', refs.limitResumeCancelled); session.off('respawnBreakerTripped', refs.respawnBreakerTripped); session.off('cliInfoUpdated', refs.cliInfoUpdated); + session.off('mouseTrackingChanged', refs.mouseTrackingChanged); session.off('ralphLoopUpdate', refs.ralphLoopUpdate); session.off('ralphTodoUpdate', refs.ralphTodoUpdate); session.off('ralphCompletionDetected', refs.ralphCompletionDetected); diff --git a/test/claude-scrollback-strip.test.ts b/test/claude-scrollback-strip.test.ts index 513b036e..80336109 100644 --- a/test/claude-scrollback-strip.test.ts +++ b/test/claude-scrollback-strip.test.ts @@ -147,3 +147,94 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el } }); }); + +/** + * Whatever the strip removes, the server has to remember, because after it runs + * nothing downstream can ever see it. The browser hand-encodes click reports for + * these modes (`_sendSyntheticSgrTap`), and with no state to consult it did that + * on EVERY click, delivering mouse reports to a CLI that never asked for them. + */ +describe('stripped mouse-tracking state', () => { + const trackingOf = (session: Session) => session.toState().cliMouseTracking; + + it('starts off, and stays off for output that never enables tracking', () => { + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + expect(trackingOf(session)).toBeUndefined(); + + handleOutput(session, 'plain output\x1b[?1049h\x1b[3J'); + + expect(trackingOf(session)).toBeUndefined(); + }); + + it('follows the CLI enabling and disabling a tracking mode', () => { + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + const changes: boolean[] = []; + session.on('mouseTrackingChanged', (active: boolean) => changes.push(active)); + + handleOutput(session, '\x1b[?1002hdialog'); + expect(trackingOf(session)).toBe(true); + + handleOutput(session, '\x1b[?1002ldismissed'); + expect(trackingOf(session)).toBeUndefined(); + expect(changes).toEqual([true, false]); + }); + + it('ignores encoding and alt-scroll modes, which do not ask about clicks', () => { + // 1005/1006 pick an ENCODING and 1007 is alt-scroll. Counting them would put + // the stray reports straight back: a CLI can select SGR encoding without ever + // asking to be told where the user clicked. + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + + handleOutput(session, '\x1b[?1006h\x1b[?1005h\x1b[?1007h'); + + expect(trackingOf(session)).toBeUndefined(); + }); + + it('stays on until the LAST tracking mode goes away', () => { + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + + handleOutput(session, '\x1b[?1000h\x1b[?1002h\x1b[?1006h'); + expect(trackingOf(session)).toBe(true); + + // A TUI may disable a mode it never enabled; that must not clear the rest. + handleOutput(session, '\x1b[?1003l'); + expect(trackingOf(session)).toBe(true); + + handleOutput(session, '\x1b[?1000l'); + expect(trackingOf(session)).toBe(true); + + handleOutput(session, '\x1b[?1002l'); + expect(trackingOf(session)).toBeUndefined(); + }); + + it('emits only on a real transition, so a repainting TUI costs nothing', () => { + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + const changes: boolean[] = []; + session.on('mouseTrackingChanged', (active: boolean) => changes.push(active)); + + handleOutput(session, '\x1b[?1002h\x1b[?1002h\x1b[?1002h'); + + expect(changes).toEqual([true]); + }); + + it('sees a sequence split across PTY chunks, like the strip that carries it', () => { + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + + handleOutput(session, 'before\x1b[?100'); + handleOutput(session, '2h after'); + + expect(session.terminalBuffer).toBe('before after'); + expect(trackingOf(session)).toBe(true); + }); + + it('tracks nothing for a mode whose DECSETs are never stripped', () => { + // shell keeps its mouse DECSETs, so xterm sees them and owns the reporting. + // A flag set here would mean a SECOND, hand-encoded report on every click. + const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); + + handleOutput(session, '\x1b[?1002hhtop'); + + expect(trackingOf(session)).toBeUndefined(); + expect(session.terminalBuffer).toBe('\x1b[?1002hhtop'); + }); +}); 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,"); + }); +}); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index f048c82a..28705687 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -140,7 +140,7 @@ describe('terminal touch tap mouse guard', () => { it('routes a readback row to the TUI while keeping the prompt row as keyboard input', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'codex' }]]); + app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]); app.terminal = createTerminalGrid( ['Agent readback mentions › inline', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'], 4 @@ -155,7 +155,7 @@ describe('terminal touch tap mouse guard', () => { it('classifies Claude background-agent status as content rather than keyboard input', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]); + app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220', cliMouseTracking: true }]]); app.terminal = createTerminalGrid( ['', '', '', '• Working (1m 50s • esc to ', 'interrupt) · 1 background teammate', ''], 4, @@ -168,7 +168,7 @@ describe('terminal touch tap mouse guard', () => { it('keeps the live cursor focusable when Claude temporarily omits its prompt glyph', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]); app.terminal = createTerminalGrid(['Prior response', '', 'ready for input', '', 'status footer', ''], 2); expect(app._classifyMobileTerminalTap(9, 33)).toBe('input'); @@ -178,7 +178,7 @@ describe('terminal touch tap mouse guard', () => { it('treats a highlighted numbered choice as TUI content, not an input prompt', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]); app.terminal = createTerminalGrid(['Would you like to proceed?', '', '❯ 1. Yes', ' 2. No', '', ''], 2); expect(app._classifyMobileTerminalTap(9, 33)).toBe('content'); @@ -193,7 +193,7 @@ describe('terminal touch tap mouse guard', () => { // commits an answer) as the only thing a phone could do. const { app, setActiveElement } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]); app.terminal = createTerminalGrid( ['Do you want to proceed?', '', '❯ 1. Yes', ' 2. No, tell Claude what to do', '', ''], 2 @@ -219,7 +219,7 @@ describe('terminal touch tap mouse guard', () => { it('collapses TUI readback content without opening or retaining the keyboard', () => { const { app, setActiveElement } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'codex' }]]); + app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]); app.terminal = createTerminalGrid( ['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'], 4 @@ -252,7 +252,7 @@ describe('terminal touch tap mouse guard', () => { it('closes the keyboard on a second tap of INERT transcript content', () => { const { app, setActiveElement } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]); app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4); app._sendInputAsync = vi.fn(); @@ -328,7 +328,7 @@ describe('terminal touch tap mouse guard', () => { const { app } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]); app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); app.terminal = { cols: 80, @@ -339,7 +339,7 @@ describe('terminal touch tap mouse guard', () => { _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, }; - expect(app._sessionUsesServerMouseStrip()).toBe(true); + expect(app._shouldReportMouseToCli()).toBe(true); // touch at x=10+8*20+1, y=20+16*5+1 → col 21, row 6 (1-based) app._sendSyntheticSgrTap(171, 101); @@ -366,19 +366,60 @@ describe('terminal touch tap mouse guard', () => { expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']); }); - it('does not treat shell sessions as server-mouse-strip mode', () => { + it('never hand-reports for a shell session, even with tracking somehow set', () => { + // Shell DECSETs are NOT stripped (narrow strip), so xterm's own encoder owns + // the mouse there and a second, hand-encoded report would double-report. const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'shell' }]]); + app.sessions = new Map([['sess-1', { mode: 'shell', cliMouseTracking: true }]]); - expect(app._sessionUsesServerMouseStrip()).toBe(false); + expect(app._shouldReportMouseToCli()).toBe(false); + }); + + it('hand-reports only while the CLI actually has mouse tracking on', () => { + // The server strips the DECSETs, so xterm can never see them and the browser + // reported EVERY click. A claude pane sitting at its composer, or one that + // fell back to a shell prompt, was receiving mouse reports it never asked + // for; a shell prints those as literal text and they garble the next line. + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + expect(app._shouldReportMouseToCli()).toBe(false); + + app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: false }]]); + expect(app._shouldReportMouseToCli()).toBe(false); + + app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]); + expect(app._shouldReportMouseToCli()).toBe(true); + }); + + it('desktop click: sends nothing while the CLI has no mouse mode on', () => { + const { app } = loadTerminalUiHarness(); + const sent: string[] = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputAsync = (_id: string, data: string) => sent.push(data); + app.terminal = createTerminalGrid(['some output', '', '\u276f '], 2); + app._linkHovered = false; + + app._handleDesktopTerminalClick({ + isTrusted: true, + button: 0, + detail: 1, + clientX: 40, + clientY: 10, + target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) }, + }); + + expect(sent).toEqual([]); }); it('desktop click: encodes SGR press+release for a plain left-click in strip mode', () => { const { app } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]); app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); app.terminal = { cols: 80, @@ -453,7 +494,7 @@ describe('terminal touch tap mouse guard', () => { const { app } = loadTerminalUiHarness(); const sent: string[] = []; app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]); app._sendInputAsync = (_id: string, data: string) => sent.push(data); app.terminal = { cols: 80, @@ -487,7 +528,7 @@ describe('terminal touch tap mouse guard', () => { const { app, setNow } = loadTerminalUiHarness(); const sent: string[] = []; app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]); app._sendInputAsync = (_id: string, data: string) => sent.push(data); app.terminal = { cols: 80, @@ -771,7 +812,7 @@ describe('terminal link tap', () => { const { app, windowRef } = harness; const sent: string[] = []; app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]); app._sendInputAsync = (_id: string, data: string) => sent.push(data); app.terminal = createTerminalGrid(lines, cursorY); app.terminal.registerLinkProvider = vi.fn(); @@ -802,7 +843,7 @@ describe('terminal link tap', () => { const line = 'see https://example.com/x for more'; const { app, sent } = linkHarness([line, '', '❯ ']); - expect(app._sessionUsesServerMouseStrip()).toBe(true); + expect(app._shouldReportMouseToCli()).toBe(true); app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'content'); expect(sent).toEqual([]);