diff --git a/config/test-suites.ts b/config/test-suites.ts index 2148a467..eb10e9a9 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -37,6 +37,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/codex-predictive-echo.test.ts', // also needs a real codex binary 'test/split-pane-terminal.browser.test.ts', 'test/terminal-tile-scroll.browser.test.ts', + 'test/codex-wheel-routing.browser.test.ts', 'test/terminal-touch-paging.browser.test.ts', 'test/shift-enter-keypress.browser.test.ts', 'test/key-tester.browser.test.ts', diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 90174cca..debd7fba 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,13 +225,13 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` ⚠️ **What a mouse-DECSET strip (full or mouse) 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 in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), 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. -**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including the maintainer's 0.162.0 measurement with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. +**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. **Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codex has no Claude hook alert, so a `tabAlerts === 'action'` test cannot detect this prompt and is not a native-wheel safety gate. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence. -Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) pre-pays one tick when its opening travel is under a row, so an isolated 4px mouse click responds. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. +Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (Claude ≥ 2.1.187 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLinesFloat()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 3dea3bde..3a68aa29 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -89,6 +89,14 @@ export function remoteLoginShellCommand(command: string): string { return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`; } +/** Standard CLI locations missing from non-interactive SSH shells (notably Homebrew). */ +export const REMOTE_PATH_BOOTSTRAP = + 'export PATH=$HOME/.opencode/bin:$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH'; + +export function withRemotePathBootstrap(command: string): string { + return `${REMOTE_PATH_BOOTSTRAP}; ${command}`; +} + /** * The CLI text a location overlay should launch for `mode`, or null when this build has no * entry for it. `overlays.<location>.command` when the entry names one, otherwise the bare diff --git a/src/remote-pane-history.ts b/src/remote-pane-history.ts index f4a12e48..b3eab6b0 100644 --- a/src/remote-pane-history.ts +++ b/src/remote-pane-history.ts @@ -48,7 +48,13 @@ import { exec } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { promisify } from 'node:util'; import type { SessionRemote } from './types/session.js'; -import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js'; +import { + buildSshConnectionArgs, + classifyRemoteAliveExit, + remoteSshTarget, + shellescape, + withRemotePathBootstrap, +} from './remote-hosts.js'; import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js'; import { remoteTmuxLocation } from './tmux-manager.js'; @@ -153,7 +159,10 @@ export function buildRemoteScrollbackCommand( marker: string ): string { const { socket, sessionName } = remoteTmuxLocation(remote, sessionId); - const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker); + // Non-interactive macOS shells omit Homebrew and other per-user CLI paths. + const script = withRemotePathBootstrap( + buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker) + ); // `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`. return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' '); } diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index e1ecf319..0c188d36 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -1074,11 +1074,11 @@ // SGR wheel forwarding, the twin of the primary pane's capture-phase wheel // handler and _forwardScrollToApp (terminal-ui.js; keep them in step). - // Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is - // on, cliMouseTracking) scrolls its own transcript on SGR wheel reports, + // Fullscreen Claude (2.1.187+) and measured tracked Codex scroll their own + // transcripts on SGR wheel reports while cliMouseTracking is on, // while this xterm holds only Codeman's replayed repaint frames (tmux keeps // no history for such a pane). Left to xterm, the wheel dragged those stale - // frames, Claude's pinned input box with them, up the tile, or scrolled + // frames, including the CLI's pinned input box, up the tile, or scrolled // nothing at all. The gate is the primary pane's own, asked for THIS pane // (its terminal, its session, never the active one), so the CLI rules stay // in terminal-ui.js and this file names no CLI; the reports go to this diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 6ab39f55..45afb289 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -137,9 +137,9 @@ return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks); } - // A deliberate opening event pre-pays a tick, so a lone small mouse click - // responds immediately. The signed remainder is debt repaid by subsequent - // same-direction travel: ten 0.1-line events still total one tick. Debt and + // A deliberate opening event preserves upstream rounding and pre-pays at + // least one tick, so isolated clicks retain their travel. The signed remainder + // is repaid by subsequent same-direction travel: ten 0.1-line events still total one tick. Debt and // fractions expire with a 150ms gesture gap or reversal; a reversal within // the gesture accumulates without another pre-payment (like page gestures). function wheelTicksForGesture(state, lines, now) { @@ -147,8 +147,12 @@ const dir = Math.sign(lines); const fresh = !state || typeof state.lastAt !== 'number' || now - state.lastAt > PAGE_KEY_GESTURE_GAP_MS; const reversed = state?.dir && state.dir !== dir; - if (fresh && Math.abs(lines) >= PAGE_KEY_MIN_START_ROWS && Math.abs(lines) < 1) { - return { state: { dir, lastAt: now, pending: lines - dir }, ticks: dir }; + if (fresh && Math.abs(lines) >= PAGE_KEY_MIN_START_ROWS) { + const rounded = dir * Math.max(1, Math.round(Math.abs(lines))); + return { + state: { dir, lastAt: now, pending: lines - rounded }, + ticks: dir * Math.min(Math.abs(rounded), SGR_WHEEL_MAX_TICKS), + }; } const total = (fresh || reversed ? 0 : state.pending || 0) + lines; const whole = Math.trunc(total + Math.sign(total) * 1e-9); @@ -5852,9 +5856,9 @@ Object.assign(CodemanApp.prototype, { if (sessionMode === 'copilot') return session?.cliMouseTracking === true; if (sessionMode !== 'claude' && sessionMode !== 'codex') return false; if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; - // Only while Claude is actually listening for the mouse. In its default - // inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the - // transcript lives in real scrollback, like codex, and SGR wheel reports are + // Only while Claude or Codex is actually listening for the mouse. Claude's + // inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0), like + // older inline Codex, keeps its transcript in real scrollback; SGR reports are // ignored, so forwarding made every swipe and wheel tick dead. Fullscreen // (CLAUDE_CODE_NO_FLICKER=1, or "tui": "fullscreen" in ~/.claude/settings.json) // turns on alt-screen + mode 1003/1006, which the server records as diff --git a/test/codex-wheel-routing.browser.test.ts b/test/codex-wheel-routing.browser.test.ts new file mode 100644 index 00000000..0cf76b8c --- /dev/null +++ b/test/codex-wheel-routing.browser.test.ts @@ -0,0 +1,82 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +// Adversarial browser layout fixture, not evidence of native Codex history: +// authentic CLI behavior is measured separately in an isolated live pane. +describe('tracked remote Codex wheel routing (#13)', () => { + let server: WebServer; + let browser: Browser; + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + }, 60000); + afterAll(async () => { + await browser?.close(); + await server?.stop(); + }); + + it('sends plain wheel to the CLI rather than scrolling stale repaint frames', async () => { + const page = await browser.newPage({ viewport: { width: 1200, height: 800 } }); + try { + await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal); + await page.waitForTimeout(3500); + await page.evaluate(async () => { + const app = (window as any).app; + app.activeSessionId = 'codex-wheel-probe'; + app.sessions.set(app.activeSessionId, { id: app.activeSessionId, mode: 'codex', cliMouseTracking: true }); + (window as any).scrollInputs = []; + // Cell hit testing is irrelevant to routing; the empty dashboard covers the test terminal. + app._clientPointToCell = () => ({ col: 1, row: 1 }); + app._sendInputEphemeral = (id: string, data: string) => (window as any).scrollInputs.push({ id, data }); + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + await new Promise<void>((resolve) => app.terminal.write('\r\nold repaint row'.repeat(100), resolve)); + app.terminal.scrollToTop(); + }); + const fire = (shiftKey = false) => + page.evaluate((shiftKey) => { + const t = (window as any).app.terminal; + t.element.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, shiftKey, bubbles: true, cancelable: true })); + }, shiftKey); + await fire(); + await page.waitForTimeout(150); + const forwarded = await page.evaluate(() => { + const app = (window as any).app; + return { + inputs: (window as any).scrollInputs, + baseY: app.terminal.buffer.active.baseY, + viewportY: app.terminal.buffer.active.viewportY, + }; + }); + expect(forwarded.inputs.length).toBeGreaterThan(0); + expect(forwarded.inputs.every((i: { id: string }) => i.id === 'codex-wheel-probe')).toBe(true); + expect( + forwarded.inputs + .map((i: { data: string }) => i.data) + .join('') + .match(/\x1b\[<64;/g) + ).toHaveLength(4); + expect(forwarded.viewportY).toBe(forwarded.baseY); + for (const shift of [false, true]) { + await page.evaluate((tracked) => { + const app = (window as any).app; + app.sessions.get(app.activeSessionId).cliMouseTracking = tracked; + app.terminal.scrollToBottom(); + (window as any).scrollInputs = []; + }, shift); + await fire(shift); + await page.waitForTimeout(150); + const local = await page.evaluate(() => { + const b = (window as any).app.terminal.buffer.active; + return { inputs: (window as any).scrollInputs, baseY: b.baseY, viewportY: b.viewportY }; + }); + expect(local.inputs).toEqual([]); + expect(local.viewportY).toBeLessThan(local.baseY); + } + } finally { + await page.close(); + } + }); +}); diff --git a/test/remote-pane-history.test.ts b/test/remote-pane-history.test.ts index 014e7f48..03f9aa77 100644 --- a/test/remote-pane-history.test.ts +++ b/test/remote-pane-history.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { @@ -79,6 +79,40 @@ describe('buildRemoteScrollbackCommand', () => { }); }); +describe('remote history PATH bootstrap', () => { + it('finds tmux installed under the remote home when the login shell omits it', () => { + const root = mkdtempSync(join(tmpdir(), 'remote-history-path-')); + const home = join(root, 'remote home'); + const sshBin = join(root, 'local-bin'); + const remoteBin = join(home, '.local', 'bin'); + mkdirSync(sshBin, { recursive: true }); + mkdirSync(remoteBin, { recursive: true }); + writeFileSync(join(sshBin, 'ssh'), '#!/bin/sh\nfor arg do command="$arg"; done\nexec /bin/sh -c "$command"\n', { + mode: 0o755, + }); + writeFileSync( + join(remoteBin, 'tmux'), + `#!/bin/sh +printf 'older row\\n${MARKER}\\n1\\ncurrent screen\\n' +`, + { mode: 0o755 } + ); + try { + const command = buildRemoteScrollbackCommand(remote, SESSION_ID, 100, 0, MARKER); + const stdout = execFileSync('/bin/sh', ['-c', command], { + encoding: 'utf-8', + env: { ...process.env, HOME: home, PATH: `${sshBin}:/usr/bin:/bin` }, + }); + expect(parseRemoteScrollbackOutput(stdout, MARKER, 0)).toEqual({ + scrollback: 'older row\n', + screen: 'current screen\n', + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('remoteTailBytes', () => { it('adds room for the marker, size and screen to the cap, so a cut lands in the history', () => { expect(remoteTailBytes(2048)).toBe(2048 + 1024 * 1024); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index bc0dc4cd..2694c012 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -909,6 +909,25 @@ describe('consistent scroll gesture adapters', () => { expect(loadTerminalUiHarness().windowRef.CodemanTerminalInput.PAGE_KEY_IMMEDIATE_MAX_ROWS).toBe(2); }); + it('preserves rounded travel for isolated coarse clicks without accumulating a capped backlog', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + for (const direction of [-1, 1]) { + expect(wheelTicksForGesture(null, direction * 4.8, 0).ticks).toBe(direction * 5); // 120px + expect(wheelTicksForGesture(null, direction * 1.6, 0).ticks).toBe(direction * 2); // 40px + let state = null; + let ticks = 0; + for (let i = 0; i < 5; i++) { + const step = wheelTicksForGesture(state, direction * 4.8, i * 300); + state = step.state; + ticks += step.ticks; + } + expect(ticks).toBe(direction * 25); + const capped = wheelTicksForGesture(null, direction * 40, 0); + expect(capped.ticks).toBe(direction * 5); + expect(capped.state.pending).toBe(0); // don't retain travel discarded by the event cap + } + }); + it('pre-pays an isolated deliberate opening tick and repays its signed debt', () => { const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; const opening = wheelTicksForGesture(null, -0.16, 0); // a 4px mouse click