diff --git a/CLAUDE.md b/CLAUDE.md index 902e1a80..a253cf3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -228,7 +228,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **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) +**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 504ecfa1..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. 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/terminal-ui.js b/src/web/public/terminal-ui.js index 6fb97b76..f1100f18 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -4044,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'; @@ -4344,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); } @@ -4414,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 @@ -4682,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, @@ -4696,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 f52a91b8..721fa0aa 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; @@ -342,6 +343,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) */ @@ -453,6 +468,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); @@ -487,6 +503,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-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([]);