mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(terminal): address scrolling review and preserve upstream speed policies
Fix transport-timer regression coverage, zero-delta tile handling and capability documentation. Record isolated Codex 0.163.0 transcript and approval evidence; defer event/batch rate and coarse paging policy changes. Refs: https://github.com/johoja12/Codeman/issues/7
This commit is contained in:
1 parent
39d7c01b7b
commit
8cb3441dd9
11 files changed
+158
-139
No files matched your search
@@ -295,7 +295,7 @@ const capabilitiesSchema = z
|
||||
altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']),
|
||||
echo: echoSchema,
|
||||
wheelForward: z
|
||||
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
|
||||
.object({ mode: z.enum(['never', 'version-gated', 'mouse-tracking']), 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).
|
||||
|
||||
@@ -733,8 +733,8 @@ const CODEX: CliEntry = {
|
||||
transcript: 'codex-rollout',
|
||||
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,
|
||||
wheelForward: { mode: 'mouse-tracking' }, // 0.163.0 fullscreen: measured SGR scrolling; require observed tracking
|
||||
// Without observed mouse tracking, retain PageUp/PageDown fallback. 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.
|
||||
|
||||
@@ -513,8 +513,8 @@ export interface CliCapabilities {
|
||||
/** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */
|
||||
predictProfile?: string;
|
||||
};
|
||||
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
|
||||
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
|
||||
/** Wheel forwarding policy. Every enabled policy also requires observed CLI mouse tracking. */
|
||||
wheelForward: { mode: 'never' | 'version-gated' | 'mouse-tracking'; 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
|
||||
@@ -523,8 +523,8 @@ export interface CliCapabilities {
|
||||
*
|
||||
* ⚠ 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.
|
||||
* A CLI can ignore one and honour the other: Codex without observed mouse tracking
|
||||
* uses this fallback, while fullscreen Codex 0.163.0 accepts SGR wheel reports.
|
||||
*
|
||||
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
|
||||
*/
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelTicksForGesture/coalesceScrollBytes/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
|
||||
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
|
||||
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
|
||||
*/
|
||||
@@ -1101,7 +1101,8 @@
|
||||
// SGR coordinates address the live screen, so a report from a scrolled-up
|
||||
// viewport would hit-test another row: snap home first (_forwardScrollToApp).
|
||||
if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.();
|
||||
const lines = input.wheelDeltaNativeLines(ev, this.terminal.rows);
|
||||
const lines = input.wheelDeltaLines(ev, this.terminal.rows);
|
||||
if (!lines) return true;
|
||||
if (this._wheelTickGesture?.dir && this._wheelTickGesture.dir !== Math.sign(lines)) this._scrollBytes = '';
|
||||
const step = input.wheelTicksForGesture(this._wheelTickGesture, lines, performance.now());
|
||||
this._wheelTickGesture = step.state;
|
||||
@@ -1215,8 +1216,8 @@
|
||||
}
|
||||
|
||||
// Coalesces the scroll bytes (SGR wheel reports and page keys alike) into
|
||||
// one send per 40 ms, bounded by report count so a fling cannot build a backlog
|
||||
// that keeps scrolling after it stops. A narrow
|
||||
// one send per 40 ms after the leading native report, preserving upstream
|
||||
// report density and cancelling only unsent movement on reversal. A narrow
|
||||
// twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue
|
||||
// (terminal-ui.js; keep the two in step), which flushes to the active
|
||||
// session only. Sent ephemeral (no seq, never persisted) to THIS pane's
|
||||
|
||||
@@ -98,6 +98,10 @@
|
||||
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;
|
||||
// A pre-paid page's debt outlives the gesture gap for this long when the next
|
||||
// event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a
|
||||
// Firefox 1-line notch) pay it back instead of each pre-paying a page of its own.
|
||||
@@ -117,25 +121,7 @@
|
||||
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
|
||||
}
|
||||
|
||||
// A wheel event is at most one native tick: a common mouse notch is
|
||||
// deltaY=100px (or Firefox's 3 lines), not four/three separate notches.
|
||||
// Small touchpad events retain their fractional distance and accumulate in
|
||||
// wheelTicksForGesture. Touch drags bypass this wheel-only normalization.
|
||||
function wheelDeltaNativeLines(ev, rows) {
|
||||
return Math.max(-1, Math.min(1, wheelDeltaLines(ev, rows)));
|
||||
}
|
||||
|
||||
// The same travel rounded to whole lines for the SGR wheel reports: a pure
|
||||
// horizontal swipe is 0 (nothing to send), and anything else moves at least
|
||||
// one line, so the small pixel deltas of a precision touchpad still scroll.
|
||||
// The body of the primary pane's _wheelScrollLines.
|
||||
function wheelDeltaWholeLines(ev, rows) {
|
||||
const lines = wheelDeltaLines(ev, rows);
|
||||
if (!lines) return 0;
|
||||
return Math.round(lines) || (lines > 0 ? 1 : -1);
|
||||
}
|
||||
|
||||
// Ticks one gesture batch may report: Claude applies its own scroll-speed
|
||||
// Ticks one input event may report: Claude applies its own scroll-speed
|
||||
// multiplier and acceleration on top, so a bigger batch only overshoots.
|
||||
const SGR_WHEEL_MAX_TICKS = 5;
|
||||
|
||||
@@ -167,17 +153,16 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Only unsent scroll input can be cancelled. Keep at most five SGR reports
|
||||
// or three page keys per flush, and replace an obsolete direction immediately.
|
||||
// Local and remote transports share the same bounded gesture queue.
|
||||
// Only unsent scroll input can be cancelled. Preserve upstream report density
|
||||
// within each batch, and replace an obsolete direction immediately.
|
||||
// Local and remote transports share the same direction-aware gesture queue.
|
||||
function coalesceScrollBytes(queued, data) {
|
||||
const direction = (bytes) => bytes?.match(/\x1b\[<(64|65);|\x1b\[(5|6)~/)?.[0];
|
||||
const previous = direction(queued);
|
||||
const next = direction(data);
|
||||
const combined = previous && previous === next ? queued + data : data;
|
||||
const reports = combined.match(/\x1b\[<(?:64|65);\d+;\d+M|\x1b\[[56]~/g) || [];
|
||||
const limit = combined.startsWith('\x1b[<') ? SGR_WHEEL_MAX_TICKS : PAGE_KEY_MAX_PER_BATCH;
|
||||
return reports.slice(-limit).join('');
|
||||
return reports.join('');
|
||||
}
|
||||
|
||||
// Rows of travel per page key for a terminal `rows` tall (half a screen).
|
||||
@@ -214,15 +199,15 @@
|
||||
// PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS while a pre-paid
|
||||
// page is still owed in the same direction. One contact is never split by
|
||||
// time, so a slow drag (one line per 170 ms) is one gesture;
|
||||
// - a new gesture's ordinary wheel/touch opening event (at least
|
||||
// PAGE_KEY_MIN_START_ROWS, under one page of travel) pages AT ONCE,
|
||||
// - a new gesture's trackpad-sized opening event (at least
|
||||
// PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE,
|
||||
// pre-paid: the half screen it skipped is owed back by the rest of the
|
||||
// gesture, so the rate stays one page per half screen. A debt left by a
|
||||
// pre-paid page dies with its gesture;
|
||||
// - a direction change inside a gesture (a reversal within the gap, or
|
||||
// within the same contact) drops a pre-paid debt and accumulates, never
|
||||
// pre-paying, so a +/-3 px trackpad wobble cannot page back and forth;
|
||||
// - larger openings and subsequent events accumulate through
|
||||
// - everything else, a wheel notch included, accumulates through
|
||||
// pageKeysForTravel, carried across notches.
|
||||
// Returns { state, keys } with keys '' when nothing is due.
|
||||
function pageKeysForGesture(state, ev, lines, rows, now) {
|
||||
@@ -248,14 +233,13 @@
|
||||
gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS);
|
||||
}
|
||||
if (gestureStart || reversed) {
|
||||
// Travel and debt belong to their direction; never make a reversal pay
|
||||
// off movement accumulated going the other way.
|
||||
if (prepaid || reversed) pending = 0;
|
||||
// A pre-paid debt belongs to its gesture and its direction.
|
||||
if (prepaid) pending = 0;
|
||||
prepaid = false;
|
||||
}
|
||||
if (gestureStart) {
|
||||
const size = Math.abs(lines);
|
||||
if (size >= PAGE_KEY_MIN_START_ROWS && size < perPage) {
|
||||
if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) {
|
||||
// Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign).
|
||||
return {
|
||||
state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId },
|
||||
@@ -422,8 +406,6 @@
|
||||
PAGE_KEY_SCREEN_FRACTION,
|
||||
PAGE_KEY_MAX_PER_BATCH,
|
||||
wheelDeltaLines,
|
||||
wheelDeltaWholeLines,
|
||||
wheelDeltaNativeLines,
|
||||
SGR_WHEEL_MAX_TICKS,
|
||||
sgrWheelReports,
|
||||
wheelTicksForGesture,
|
||||
@@ -1149,7 +1131,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (ev.ctrlKey || (!ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY))) return;
|
||||
if (this._shouldForwardWheelToApp(ev)) {
|
||||
this._logScrollRouting('forward-sgr');
|
||||
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollNativeLines(ev));
|
||||
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLinesFloat(ev));
|
||||
return;
|
||||
}
|
||||
// Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits
|
||||
@@ -5817,32 +5799,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
},
|
||||
|
||||
// Forward only to CLIs that explicitly enable mouse tracking. Fullscreen
|
||||
// Codex 0.163.0 was verified against canonical tmux captures: one SGR report
|
||||
// scrolls one transcript row. Inline Codex (tracking off) must retain local
|
||||
// history/fallback behavior; unconditional forwarding caused the old #227 bug.
|
||||
// Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger
|
||||
// scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and
|
||||
// Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns
|
||||
// false on Shift). So under Shift, read whichever axis dominates — otherwise
|
||||
// deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through
|
||||
// history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are
|
||||
// unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving.
|
||||
//
|
||||
// `deltaMode` says what UNIT the delta is in, and ignoring it made every
|
||||
// non-pixel browser scroll ~4x too slowly: Firefox reports DOM_DELTA_LINE (1)
|
||||
// with deltaY≈3 per notch, so the pixel math rounded to 0 and fell through to
|
||||
// the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In
|
||||
// Claude mode the same value also capped the forwarded SGR report at one tick.
|
||||
_wheelScrollLines(ev) {
|
||||
// Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines).
|
||||
return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows);
|
||||
},
|
||||
|
||||
_wheelScrollNativeLines(ev) {
|
||||
return window.CodemanTerminalInput.wheelDeltaNativeLines(ev, this.terminal?.rows);
|
||||
},
|
||||
|
||||
/** Unrounded variant for the smooth local-scroll path, which accumulates
|
||||
* sub-line fractions across events instead of forcing every tiny trackpad
|
||||
* delta to a whole ±1 line. Same unit handling and Shift-axis trap. The
|
||||
@@ -5877,7 +5833,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
|
||||
const sessionMode = session?.mode || 'claude';
|
||||
if (sessionMode !== 'claude' && sessionMode !== 'codex') return false;
|
||||
if (sessionMode === 'codex' && this.tabAlerts?.get(target.sessionId || this.activeSessionId) === 'action') 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
|
||||
@@ -5934,8 +5889,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Coalesces scroll input into 40ms batches after the first native report (SGR
|
||||
* wheel reports and the PageUp/PageDown fallback alike). Each flush becomes a
|
||||
* tmux send-keys server-side, so per-event writes would spawn a process storm
|
||||
* on a single flick; report counts are bounded so a wild scroll can't build a
|
||||
* backlog that keeps scrolling after the finger stops.
|
||||
* on a single flick. Per-event report limits preserve the upstream wheel/touch
|
||||
* scaling; a change of direction discards only movement that is still unsent.
|
||||
*
|
||||
* A TerminalTile keeps its own narrow twin (TerminalTile._queueScrollBytes,
|
||||
* terminal-tile.js: same 40ms window and report limits) because this queue
|
||||
@@ -6023,7 +5978,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* 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 deliberate sub-page gesture pages at once. Waiting for half a screen of
|
||||
* 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
|
||||
@@ -6031,8 +5987,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is
|
||||
* still owed), a reversal after the gap or a tab switch starts a new gesture;
|
||||
* one contact is never split by time, and a quick reversal never pre-pays. A
|
||||
* wheel opening below the page threshold also pages immediately. Subsequent events
|
||||
* repay the skipped travel rather than paging once per notch.
|
||||
* larger opening event is a wheel notch and accumulates as before, so slow
|
||||
* notches still page once per `perPage` rows.
|
||||
*
|
||||
* Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad
|
||||
* pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|;
|
||||
|
||||
Reference in new issue
Block a user