Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`

**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 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). 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). `_wheelScrollLines()` 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`.

**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`.
**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (the `transcriptPageKeys` capability + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still); a >150 ms pause, a direction change or a tab switch starts a new gesture. Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the active tab's alert is `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector. ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys` (claude and codex), published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`.

**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.

Expand Down
4 changes: 4 additions & 0 deletions src/config/cli-registry/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ const capabilitiesSchema = z
wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
.strict(),
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
// Optional: absent means no paging, the safe default for an unmeasured CLI.
transcriptPageKeys: z.boolean().optional(),
keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']),
Expand Down
9 changes: 9 additions & 0 deletions src/config/cli-registry/stock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ const CLAUDE: CliEntry = {
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version
// AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
// A repaint-mode Claude pane keeps no local history, so when the wheel is not
// forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is
// turned into PageUp/PageDown, which Claude pages its transcript on (#205).
transcriptPageKeys: true,
keyboardAccessory: 'agent',
privilegedCommandGate: false,
startMode: 'interactive',
Expand Down Expand Up @@ -632,6 +636,11 @@ const CODEX: CliEntry = {
altScreen: 'strip-full',
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward
// ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local,
// alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp
// moved the visible transcript while SGR wheel reports and local scrolling did nothing.
// This is the page-key fallback for a hollow pane, not wheel forwarding.
transcriptPageKeys: true,
maxFrameBytes: 32 * 1024,
// codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so
// the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off.
Expand Down
14 changes: 14 additions & 0 deletions src/config/cli-registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,20 @@ export interface CliCapabilities {
};
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
/**
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
* a pane with no local history can be turned into those keys instead of doing nothing
* (`_maybePageCliTranscript` in terminal-ui.js). Read by the frontend through
* `window.__codemanTranscriptPageKeys`, which the server builds from this field.
*
* ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends
* plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`).
* A CLI can ignore one and honour the other: codex ignores SGR wheel reports (so its
* `wheelForward` stays 'never') yet pages on PageUp/PageDown.
*
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
*/
transcriptPageKeys?: boolean;
keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean;
Expand Down
95 changes: 83 additions & 12 deletions src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
const REPLAY_ESCAPE_RE =
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g;
// PageUp / PageDown as xterm.js encodes them. Used as the LAST-RESORT scroll
// gesture for a repaint-mode CLI whose local buffer holds no scrollback
// gesture for a CLI whose local buffer holds no scrollback
// (_maybePageCliTranscript).
const KEY_PAGE_UP = '\x1b[5~';
const KEY_PAGE_DOWN = '\x1b[6~';
Expand All @@ -92,6 +92,16 @@
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick
// cap: a fling must not build a backlog that keeps paging after it stops.
const PAGE_KEY_MAX_PER_BATCH = 3;
// A page-key scroll event after this much quiet, or in the opposite direction,
// starts a NEW gesture, and the first event of a gesture pages at once rather
// than waiting for half a screen of travel (a trackpad flick never got there).
const PAGE_KEY_GESTURE_GAP_MS = 150;
// ...but not for sub-row jitter (0.1 row, ~2px)...
const PAGE_KEY_MIN_START_ROWS = 0.1;
// ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px,
// 4 rows) accumulates toward half a screen as before; paging at once on it made
// slow notches, each its own gesture past the gap, page a full screen apiece.
const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2;
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
// Composer navigation keys as xterm.js encodes user keystrokes: plain and
// modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3
Expand Down Expand Up @@ -229,6 +239,9 @@
KEY_PAGE_DOWN,
PAGE_KEY_SCREEN_FRACTION,
PAGE_KEY_MAX_PER_BATCH,
PAGE_KEY_GESTURE_GAP_MS,
PAGE_KEY_MIN_START_ROWS,
PAGE_KEY_IMMEDIATE_MAX_ROWS,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
Expand Down Expand Up @@ -5485,15 +5498,22 @@ Object.assign(CodemanApp.prototype, {
},

/**
* True when this session's LOCAL scrollback is structurally empty: a Claude
* pane in repaint mode, where tmux reports `history_size≈0` and every frame
* overwrites the last, so xterm's normal buffer never grows past one screen
* (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture
* is routed — the "wheel does nothing at all" half of the #205 retest.
* True when this session's LOCAL scrollback is structurally empty and its CLI
* can page its own transcript. A Claude pane in repaint mode reports tmux
* `history_size≈0` and every frame overwrites the last; Codex draws on tmux's
* alternate screen, which the strip hides from xterm. Either way xterm's
* normal buffer never grows past one screen (`baseY === 0`), so scrolling it
* is a no-op no matter how the gesture is routed (the "wheel does nothing at
* all" half of the #205 retest).
*
* ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the
* list the server builds from the `transcriptPageKeys` CAPABILITY, never an id
* literal here. A missing list means no mode pages.
*/
_localScrollbackIsHollow() {
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
if (mode !== 'claude') return false;
const pagingModes = window.__codemanTranscriptPageKeys;
if (!Array.isArray(pagingModes) || !pagingModes.includes(mode)) return false;
const buf = this.terminal?.buffer?.active;
if (!buf || buf.type === 'alternate') return false;
return (buf.baseY || 0) === 0;
Expand All @@ -5504,32 +5524,83 @@ Object.assign(CodemanApp.prototype, {
* coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript.
*
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
* on a Claude session that has no local history to fall back on: the CLI
* on a session that has no local history to fall back on. For Claude: the CLI
* version probe failed or is genuinely older than 2.1.187, the CLI's mouse
* tracking flag is unset (the inline renderer, or fullscreen right after a
* server restart), or the user turned on "Wheel scrolls local history" (which
* pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the
* setting's footgun). Before this, all of those produced a completely dead
* gesture; the #205 reporter proved the keyboard route works by paging back
* through intact text with Fn+Up.
* through intact text with Fn+Up. For Codex the gate is ALWAYS false, because
* codex ignores SGR wheel reports (#227) and is never forwarded the wheel;
* measured on codex 0.157.1, PageUp moves its alternate-screen transcript
* while wheel reports and local scrolling do nothing. So this is not wheel
* forwarding: it sends plain keys, and only over a hollow buffer.
*
* Triple-guarded (`transcriptPageKeys` capability + gate false + `baseY === 0`),
* so a session with real local scrollback is never touched. Shift is excluded
* on purpose: it is the explicit "give me local scrollback" gesture and must
* keep that meaning.
*
* The FIRST event of a trackpad-sized gesture (opening under
* PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of
* travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick
* never sent a key at all. That page is pre-paid: the travel it skipped is
* owed back by the rest of the gesture, so the rate stays one page per
* `perPage` rows. A pause (PAGE_KEY_GESTURE_GAP_MS), a direction change or a
* tab switch starts a new gesture. A larger opening event is a wheel notch and
* accumulates as before, so slow notches still page once per `perPage` rows.
*
* Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with
* real local scrollback is never touched. Shift is excluded on purpose: it is
* the explicit "give me local scrollback" gesture and must keep that meaning.
* Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad
* pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|;
* the touch path locks its own axis before it gets here), and any gesture while
* the session has an open dialog (tab alert 'action', set by a pending
* permission_prompt or elicitation_dialog), so a page key never reaches its
* selector.
*
* @returns true when the gesture was consumed here (the caller must not also
* scroll locally).
*/
_maybePageCliTranscript(ev, lines) {
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
if (!this._localScrollbackIsHollow()) return false;
if (ev?.ctrlKey) return true; // pinch
const dx = Math.abs(ev?.deltaX || 0);
const dy = Math.abs(ev?.deltaY || 0);
if (dx && dy && dx > dy) return true; // mostly horizontal swipe
if (this.tabAlerts?.get(this.activeSessionId) === 'action') return true; // dialog up
// Leftover travel belongs to the tab it was made on.
if (this._pageKeySession !== this.activeSessionId) {
this._pageKeySession = this.activeSessionId;
this._pageKeyPending = 0;
this._pageKeyLastAt = undefined;
this._pageKeyPrepaid = false;
}
const tuning = window.CodemanTerminalInput;
const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION));
const now = performance.now();
const dir = lines < 0 ? -1 : 1;
const gestureStart =
typeof this._pageKeyLastAt !== 'number' ||
now - this._pageKeyLastAt > tuning.PAGE_KEY_GESTURE_GAP_MS ||
dir !== this._pageKeyDir;
this._pageKeyLastAt = now;
this._pageKeyDir = dir;
if (gestureStart) {
// A debt left by an earlier pre-paid page dies with its gesture; plain
// wheel travel keeps accumulating across notches.
if (this._pageKeyPrepaid) this._pageKeyPending = 0;
this._pageKeyPrepaid = false;
const size = Math.abs(lines);
if (size >= tuning.PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, tuning.PAGE_KEY_IMMEDIATE_MAX_ROWS)) {
// Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign).
this._pageKeyPrepaid = true;
this._pageKeyPending = lines - dir * perPage;
this._queueScrollBytes(dir < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN);
this._logScrollRouting('page-keys');
return true;
}
}
const pending = (this._pageKeyPending || 0) + lines;
const pages = Math.trunc(pending / perPage);
this._pageKeyPending = pending - pages * perPage;
Expand Down
Loading
Loading