mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
fix(terminal): stop the scroll-to-top re-pull from deleting history, page the CLI when local scrollback is hollow (#205)
The 1.12.0 retest on #205 reported it still broken in two shapes: a wheel that did nothing at all on Firefox/macOS (while Fn+Up paged back through intact text), and iPhone history that went back a little, repeated blocks and got worse the further up it went. Both come from a Claude pane's LOCAL buffer being hollow: tmux keeps no history for a repaint-mode pane (history_size 0), so xterm holds only replayed repaint frames. 1. The scroll-to-top full=1 re-pull now refuses a DOWNGRADE. It resets the terminal and rewrites it from the capture, which is a win when tmux holds more than the browser, but for a repaint-mode pane that capture is roughly ONE frame and the rewrite deleted history mid-scroll. Measured A/B on a live pane, same gesture: guard off collapses 341 rows to 42, guard on preserves all 341. _replayWouldShrinkBuffer() estimates the capture's rendered rows (escapes stripped, capture-pane -J re-wrapping accounted for) and skips the rewrite when it is more than one screen short; a refused session's cooldown goes from 4s to 60s so a hollow pane stops re-fetching megabytes. 2. A false forwarding gate on a Claude session no longer means a dead gesture. Under a triple guard (claude mode, gate false, baseY 0), wheel and touch travel becomes coalesced PageUp/PageDown through the same 40ms queue as the SGR reports, at half a screen of travel per page key. Shift is excluded: it keeps meaning "local scrollback". 3. getClaudeCliVersion() no longer caches FAILURE. It stored null on any exception and guarded on !== undefined, 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, which fits a report of breakage on phone, tablet and laptop at once. Success is still cached for the process lifetime; failures retry with a 1/2/4 up to 15min backoff, and the policy is a pure function so the semantics are testable without spawning claude. 4. The terminalWheelLocalScrollback footgun is handled by pairing rather than scoping: the setting keeps meaning exactly what it says, and fix 2 catches the case where "local" is empty. The App Settings tooltip now says to leave it off for Claude/Codex sessions. 5. _logScrollRouting() prints one line per session per distinct decision: forward-sgr / page-keys / local-scrollback / repull-refused-downgrade, with mode, cliVersion, the opt-out state, mouse tracking and local scrollback depth. #205 ran two rounds of remote guesswork over questions that line answers directly. Verified end to end against a real isolated instance (own data dir and tmux socket) with real wheel events: forwarding still sends SGR reports, the opt-out now sends real PageUp/PageDown where the wheel was dead, a tab-switch collapse (401 rows to 44) is still fully recovered by the re-pull (back to 401), and a seeded 341-row Claude buffer survives the same gesture that destroys it with the guard disabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -108,12 +108,100 @@ export function getAugmentedPath(): string {
|
||||
return _augmentedPath;
|
||||
}
|
||||
|
||||
/** Cached `claude --version` result: string = version, null = probed but unavailable, undefined = not probed */
|
||||
let _claudeVersion: string | null | undefined = undefined;
|
||||
/**
|
||||
* Cache state for the `claude --version` probe.
|
||||
*
|
||||
* `version` is only ever set from a SUCCESSFUL probe and then kept for the
|
||||
* process lifetime (the binary can't change under a running server without a
|
||||
* restart). Failures are tracked separately so they expire.
|
||||
*/
|
||||
export interface ClaudeVersionProbeState {
|
||||
/** Successful probe result; `undefined` until one succeeds. */
|
||||
version?: string;
|
||||
/** Consecutive failed probes (drives the retry backoff). */
|
||||
failures: number;
|
||||
/** Timestamp of the most recent failed probe. */
|
||||
lastFailureAt: number;
|
||||
}
|
||||
|
||||
/** First retry window after a failed probe. */
|
||||
const VERSION_PROBE_BASE_RETRY_MS = 60_000;
|
||||
/** Ceiling for the doubling backoff, so a permanently missing binary settles down. */
|
||||
const VERSION_PROBE_MAX_RETRY_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* How long to wait before re-probing after `failures` consecutive failures:
|
||||
* 1min, 2min, 4min… capped at 15min. Exported for tests.
|
||||
*/
|
||||
export function claudeVersionRetryDelayMs(failures: number): number {
|
||||
if (failures <= 0) return 0;
|
||||
return Math.min(VERSION_PROBE_BASE_RETRY_MS * 2 ** (failures - 1), VERSION_PROBE_MAX_RETRY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache policy for the version probe, pure apart from the `state` it mutates
|
||||
* and the injected `probe` (exported so tests can drive it with a fake clock).
|
||||
*
|
||||
* Success is cached forever; FAILURE is not. That asymmetry is the fix for a
|
||||
* real shipped bug: the old cache stored `null` on any exception and guarded on
|
||||
* `!== undefined`, so a single failed probe — a 5s `EXEC_TIMEOUT_MS` timeout, a
|
||||
* PATH-starved systemd/launchd environment, a transient fs hiccup — at the FIRST
|
||||
* Claude session start left `cliVersion` undefined for EVERY Claude session
|
||||
* until the server restarted. An undefined `cliVersion` silently disables
|
||||
* wheel-forwarding to Claude's own transcript (`_shouldForwardWheelToApp`),
|
||||
* which is the only route to history in repaint mode: a dead wheel on every
|
||||
* device at once, matching the issue #205 retest reports.
|
||||
*
|
||||
* Retries back off so a genuinely absent binary still can't spawn a probe per
|
||||
* session start.
|
||||
*/
|
||||
export function resolveClaudeCliVersion(
|
||||
state: ClaudeVersionProbeState,
|
||||
now: number,
|
||||
probe: () => string | null
|
||||
): string | null {
|
||||
if (state.version !== undefined) return state.version;
|
||||
if (state.failures > 0 && now - state.lastFailureAt < claudeVersionRetryDelayMs(state.failures)) return null;
|
||||
|
||||
let version: string | null = null;
|
||||
try {
|
||||
version = probe();
|
||||
} catch {
|
||||
version = null;
|
||||
}
|
||||
|
||||
if (version) {
|
||||
state.version = version;
|
||||
state.failures = 0;
|
||||
state.lastFailureAt = 0;
|
||||
return version;
|
||||
}
|
||||
state.failures += 1;
|
||||
state.lastFailureAt = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
const _claudeVersionState: ClaudeVersionProbeState = { failures: 0, lastFailureAt: 0 };
|
||||
|
||||
/** One `claude --version` run. Throws on spawn/timeout failure. */
|
||||
function probeClaudeCliVersion(): string | null {
|
||||
const dir = findClaudeDir();
|
||||
const bin = dir ? join(dir, 'claude') : 'claude';
|
||||
// execFileSync (no shell) — the resolved path may contain spaces, and there
|
||||
// is no untrusted input, but avoid a shell either way.
|
||||
const out = execFileSync(bin, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
env: { ...process.env, PATH: getAugmentedPath() },
|
||||
});
|
||||
const match = out.match(/(\d+\.\d+\.\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it
|
||||
* can't be determined. Runs `claude --version` once and caches the result.
|
||||
* can't be determined. Runs `claude --version` at most once per successful
|
||||
* resolution; failed probes retry with backoff (see `resolveClaudeCliVersion`).
|
||||
*
|
||||
* This is a deterministic alternative to scraping the interactive startup
|
||||
* banner (`parseClaudeCodeInfo` in session.ts): newer Claude Code builds don't
|
||||
@@ -122,28 +210,10 @@ let _claudeVersion: string | null | undefined = undefined;
|
||||
* gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154).
|
||||
*/
|
||||
export function getClaudeCliVersion(): string | null {
|
||||
if (_claudeVersion !== undefined) return _claudeVersion;
|
||||
// Keep the test suite hermetic — never spawn a real `claude` subprocess under
|
||||
// vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set
|
||||
// it on the session directly.
|
||||
if (process.env.VITEST) {
|
||||
_claudeVersion = null;
|
||||
return _claudeVersion;
|
||||
}
|
||||
try {
|
||||
const dir = findClaudeDir();
|
||||
const bin = dir ? join(dir, 'claude') : 'claude';
|
||||
// execFileSync (no shell) — the resolved path may contain spaces, and there
|
||||
// is no untrusted input, but avoid a shell either way.
|
||||
const out = execFileSync(bin, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
env: { ...process.env, PATH: getAugmentedPath() },
|
||||
});
|
||||
const match = out.match(/(\d+\.\d+\.\d+)/);
|
||||
_claudeVersion = match ? match[1] : null;
|
||||
} catch {
|
||||
_claudeVersion = null;
|
||||
}
|
||||
return _claudeVersion;
|
||||
// it on the session directly. Deliberately does NOT touch the cache state:
|
||||
// recording a phantom failure here would be the very poisoning this fixes.
|
||||
if (process.env.VITEST) return null;
|
||||
return resolveClaudeCliVersion(_claudeVersionState, Date.now(), probeClaudeCliVersion);
|
||||
}
|
||||
|
||||
+19
-1
@@ -519,6 +519,10 @@ class CodemanApp {
|
||||
// Cooldown per session for the scroll-to-top "load more history" re-pull.
|
||||
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
|
||||
this._fullHistoryRepullInFlight = false;
|
||||
// Sessions whose last re-pull came back THINNER than the live buffer (a
|
||||
// repaint-mode CLI pane, where tmux keeps no history of its own). The pull is
|
||||
// refused for those and retried far more slowly — see _maybeRefetchFullHistory.
|
||||
this._fullHistoryRepullUseless = new Set();
|
||||
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
|
||||
this.respawnStatus = {};
|
||||
this.respawnTimers = {}; // Track timed respawn timers
|
||||
@@ -4121,6 +4125,13 @@ class CodemanApp {
|
||||
* On demand rather than automatic because that capture is unbounded-ish work: at
|
||||
* the default history limit it can be megabytes, which is fine to pay when the
|
||||
* user is explicitly reaching for history and not fine on every tab switch.
|
||||
*
|
||||
* NEVER a downgrade: for a repaint-mode CLI pane tmux keeps no history of its
|
||||
* own, so the capture can be THINNER than what xterm already holds and the
|
||||
* reset+rewrite below would delete history mid-scroll. `_replayWouldShrinkBuffer`
|
||||
* (terminal-ui.js) is the guard, and a session that produced one useless re-pull
|
||||
* gets a much longer cooldown so a hollow pane stops re-fetching megabytes on
|
||||
* every scroll-up (issue #205, round 2).
|
||||
*/
|
||||
async _maybeRefetchFullHistory() {
|
||||
const sessionId = this.activeSessionId;
|
||||
@@ -4129,7 +4140,8 @@ class CodemanApp {
|
||||
const now = Date.now();
|
||||
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
||||
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
||||
if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < 4000) return;
|
||||
const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
|
||||
if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return;
|
||||
this._fullHistoryRepullAt.set(sessionId, now);
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
@@ -4138,6 +4150,12 @@ class CodemanApp {
|
||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||
// history into the terminal the user is now looking at.
|
||||
if (!buffer || this.activeSessionId !== sessionId) return;
|
||||
if (this._replayWouldShrinkBuffer(buffer)) {
|
||||
(this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
|
||||
this._logScrollRouting?.('repull-refused-downgrade');
|
||||
return;
|
||||
}
|
||||
this._fullHistoryRepullUseless?.delete(sessionId);
|
||||
const rowsBefore = this.terminal.buffer.active.length;
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
|
||||
@@ -1322,7 +1322,7 @@
|
||||
</div>
|
||||
<!-- Input Section -->
|
||||
<div class="settings-section-header">Input</div>
|
||||
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Turn on if scrolling back through history doesn't work (e.g. macOS trackpad in Claude sessions). Shift+wheel always reaches local scrollback regardless.">
|
||||
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Leave OFF for Claude/Codex sessions: those CLIs redraw the screen in place and keep no local scrollback, so the wheel would have almost nothing to scroll (Codeman falls back to paging the CLI's own transcript). Shift+wheel always reaches local scrollback regardless.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Wheel Scrolls Local History</span>
|
||||
<span class="settings-item-desc">Plain wheel/trackpad pages the terminal scrollback</span>
|
||||
|
||||
@@ -23,6 +23,27 @@
|
||||
// short window, only the app's synthetic tap-to-position mouse event should
|
||||
// reach xterm.
|
||||
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
|
||||
// Escape sequences occupy no terminal cells, so they must come out before a
|
||||
// captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC,
|
||||
// CSI, charset designators and the short escapes tmux emits; deliberately
|
||||
// approximate — this feeds a size comparison, not a renderer.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
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
|
||||
// (_maybePageCliTranscript).
|
||||
const KEY_PAGE_UP = '\x1b[5~';
|
||||
const KEY_PAGE_DOWN = '\x1b[6~';
|
||||
// Wheel/touch travel (in lines) that adds up to one PageUp/PageDown. Half a
|
||||
// screen rather than a full one: the page key always jumps a whole screen, so
|
||||
// a 1:1 mapping made the fallback feel unreachably slow with a discrete mouse
|
||||
// wheel (Firefox reports 3 lines a notch → 12 notches per page). Overshooting
|
||||
// the finger is the right trade against a gesture that otherwise does nothing.
|
||||
const PAGE_KEY_SCREEN_FRACTION = 0.5;
|
||||
// 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;
|
||||
|
||||
function isTerminalQueryResponse(data) {
|
||||
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||
@@ -62,6 +83,11 @@
|
||||
shouldSuppressTerminalQueryResponse,
|
||||
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
|
||||
REPLAY_ESCAPE_RE,
|
||||
KEY_PAGE_UP,
|
||||
KEY_PAGE_DOWN,
|
||||
PAGE_KEY_SCREEN_FRACTION,
|
||||
PAGE_KEY_MAX_PER_BATCH,
|
||||
};
|
||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||
@@ -452,6 +478,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (this._shouldForwardWheelToApp(ev)) {
|
||||
this._logScrollRouting('forward-sgr');
|
||||
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev));
|
||||
return;
|
||||
}
|
||||
@@ -459,6 +486,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// a stream of tiny pixel deltas, and rounding each one to a whole line
|
||||
// (the ±1 fallback) made slow drags scroll faster than the finger.
|
||||
const lines = this._wheelScrollLinesFloat(ev);
|
||||
// …unless there is no local scrollback to scroll, in which case page the
|
||||
// CLI's own transcript instead of doing nothing (_maybePageCliTranscript).
|
||||
if (this._maybePageCliTranscript(ev, lines)) return;
|
||||
this._logScrollRouting('local-scrollback');
|
||||
this._noteTerminalUserScroll(lines);
|
||||
this._smoothScrollBy(lines);
|
||||
},
|
||||
@@ -476,7 +507,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// drags the CLI's pinned input box off the screen (issue #205's mobile
|
||||
// half). Same gate, so Shift has no touch analog but the local-scrollback
|
||||
// opt-out setting and the CLI-version gate apply to touch exactly as they
|
||||
// do to the wheel.
|
||||
// do to the wheel — including the PageUp/PageDown fallback the wheel uses
|
||||
// when that gate is false and there is no local scrollback to scroll
|
||||
// (_maybePageCliTranscript), which is what keeps a swipe from being a
|
||||
// complete no-op on a phone.
|
||||
{
|
||||
const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
|
||||
let touchLastX = 0;
|
||||
@@ -498,7 +532,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Flick momentum keeps feeding the CLI's transcript from the last
|
||||
// touch point; the 40ms coalescer batches the per-frame reports.
|
||||
this._forwardScrollToApp(touchLastX, touchLastY, lines);
|
||||
} else {
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
|
||||
this.terminal.scrollLines(lines);
|
||||
this._maybeLoadMoreHistoryOnScroll(lines);
|
||||
}
|
||||
@@ -566,8 +600,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const lines = Math.trunc(pixelAccum / ch);
|
||||
if (lines !== 0) {
|
||||
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
|
||||
this._logScrollRouting('forward-sgr');
|
||||
this._forwardScrollToApp(touchLastX, touchLastY, lines);
|
||||
} else {
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
|
||||
this._logScrollRouting('local-scrollback');
|
||||
this._noteTerminalUserScroll(lines);
|
||||
this.terminal.scrollLines(lines);
|
||||
this._maybeLoadMoreHistoryOnScroll(lines);
|
||||
@@ -2079,6 +2115,54 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.();
|
||||
},
|
||||
|
||||
/**
|
||||
* Rows a `?full=1` capture will occupy once written into xterm.
|
||||
*
|
||||
* tmux joins wrapped rows in that capture (`capture-pane -J`), so a long
|
||||
* logical line re-wraps into several xterm rows on write and a bare newline
|
||||
* count would undershoot; escape sequences occupy no cells and come out
|
||||
* first. Approximate by construction (it ignores double-width glyphs), which
|
||||
* is fine: the only consumer is a coarse size comparison
|
||||
* (_replayWouldShrinkBuffer), and it runs once per cooldown-guarded re-pull.
|
||||
*/
|
||||
_estimateReplayRows(text, cols) {
|
||||
if (typeof text !== 'string' || !text) return 0;
|
||||
const width = cols > 0 ? cols : 80;
|
||||
const plain = text.replace(window.CodemanTerminalInput.REPLAY_ESCAPE_RE, '');
|
||||
let rows = 0;
|
||||
for (const line of plain.split('\n')) {
|
||||
const cells = line.endsWith('\r') ? line.length - 1 : line.length;
|
||||
rows += cells > width ? Math.ceil(cells / width) : 1;
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
|
||||
/**
|
||||
* DOWNGRADE GUARD for the scroll-to-top re-pull (issue #205, round 2).
|
||||
*
|
||||
* `_maybeRefetchFullHistory` resets the terminal and rewrites it from the
|
||||
* capture, which is a straight win when tmux holds more than the browser —
|
||||
* the burst-repaint and tab-switch losses it was built for. But a repaint-mode
|
||||
* CLI pane keeps NO tmux history of its own (`history_size≈0` measured for a
|
||||
* Claude pane), so there the capture is roughly ONE frame while xterm may hold
|
||||
* hundreds of rows of replayed frames. Rewriting then DESTROYS history
|
||||
* mid-scroll: exactly the "goes back a limited amount, repeats blocks, gets
|
||||
* worse when I reach the top" report from the 1.12.0 retest.
|
||||
*
|
||||
* So refuse when the capture is smaller, with a one-screen tolerance because
|
||||
* both sides are estimates: `buffer.active.length` includes the blank rows
|
||||
* below the last line, and _estimateReplayRows can only approximate wrapping.
|
||||
* Only a capture that is worse by more than a full screen counts as a
|
||||
* downgrade, which leaves every genuine recovery case untouched.
|
||||
*/
|
||||
_replayWouldShrinkBuffer(capture) {
|
||||
const term = this.terminal;
|
||||
const rowsNow = term?.buffer?.active?.length || 0;
|
||||
if (!rowsNow) return false;
|
||||
const screen = term?.rows || 24;
|
||||
return this._estimateReplayRows(capture, term?.cols) + screen < rowsNow;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ease-out smooth scrolling for the local wheel path. The capture-phase
|
||||
* wheel handler owns local scrolling (xterm's own smooth scroller is
|
||||
@@ -2969,6 +3053,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// plain wheel to xterm's own scrollback like pre-#144, for users who prefer
|
||||
// it over forwarding the wheel to the CLI's transcript (issue #154). Cheap —
|
||||
// loadAppSettingsFromStorage() is cache-backed.
|
||||
//
|
||||
// FOOTGUN, and why it is handled downstream rather than here: for a
|
||||
// repaint-mode CLI that local scrollback is EMPTY (tmux keeps no history for
|
||||
// the pane), so this setting can silently convert a working wheel into a
|
||||
// dead one — a plausible reading of the #205 retest, where a user whose
|
||||
// scrolling was broken on 1.11.x may well have flipped it while hunting for
|
||||
// a fix. Scoping the setting away from those modes would be the other
|
||||
// option, but it would override an explicit user choice; instead the caller
|
||||
// falls through to _maybePageCliTranscript, so the gesture still pages the
|
||||
// CLI's transcript and the setting keeps meaning exactly what it says.
|
||||
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
|
||||
const mode = this.terminal?.modes?.mouseTrackingMode;
|
||||
if (mode && mode !== 'none') return false;
|
||||
@@ -3012,13 +3106,105 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!pos) return;
|
||||
const btn = lines < 0 ? 64 : 65;
|
||||
const ticks = Math.min(Math.abs(lines), 5);
|
||||
this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks));
|
||||
},
|
||||
|
||||
/**
|
||||
* Shared 40ms coalescer for every byte a scroll gesture sends to the PTY (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; the queue is bounded so a wild scroll can't build a
|
||||
* backlog that keeps scrolling after the finger stops.
|
||||
*/
|
||||
_queueScrollBytes(data) {
|
||||
if (!data || !this.activeSessionId) return;
|
||||
const queued = this._wheelSgrQueue || '';
|
||||
if (queued.length > 512) return;
|
||||
this._wheelSgrQueue = queued + `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
|
||||
this._wheelSgrQueue = queued + data;
|
||||
if (this._wheelSgrFlushTimer) return;
|
||||
this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
_localScrollbackIsHollow() {
|
||||
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
|
||||
if (mode !== 'claude') return false;
|
||||
const buf = this.terminal?.buffer?.active;
|
||||
if (!buf || buf.type === 'alternate') return false;
|
||||
return (buf.baseY || 0) === 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* LAST-RESORT scroll for a hollow local buffer: translate gesture lines into
|
||||
* 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
|
||||
* version probe failed or is genuinely older than 2.1.187, 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @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;
|
||||
// Leftover travel belongs to the tab it was made on.
|
||||
if (this._pageKeySession !== this.activeSessionId) {
|
||||
this._pageKeySession = this.activeSessionId;
|
||||
this._pageKeyPending = 0;
|
||||
}
|
||||
const tuning = window.CodemanTerminalInput;
|
||||
const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION));
|
||||
const pending = (this._pageKeyPending || 0) + lines;
|
||||
const pages = Math.trunc(pending / perPage);
|
||||
this._pageKeyPending = pending - pages * perPage;
|
||||
if (pages) {
|
||||
const key = pages < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN;
|
||||
this._queueScrollBytes(key.repeat(Math.min(Math.abs(pages), tuning.PAGE_KEY_MAX_PER_BATCH)));
|
||||
}
|
||||
this._logScrollRouting('page-keys');
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* One line in the console saying WHY a scroll gesture went where it went.
|
||||
*
|
||||
* Issue #205 ran two rounds of remote guesswork — is the CLI version probe
|
||||
* empty, is the opt-out setting on, did a mouse DECSET leak past the strip? —
|
||||
* that this single log answers directly. Logged once per session per distinct
|
||||
* decision, so a steady gesture stays silent and a CHANGE (e.g. the version
|
||||
* arriving late and flipping the route) still prints.
|
||||
*/
|
||||
_logScrollRouting(decision) {
|
||||
const sessionId = this.activeSessionId || '(none)';
|
||||
const session = this.sessions?.get(sessionId);
|
||||
const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback;
|
||||
const tracking = this.terminal?.modes?.mouseTrackingMode || 'none';
|
||||
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
|
||||
const signature = `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${baseY > 0}`;
|
||||
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
|
||||
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
|
||||
this._scrollRoutingLogged.set(sessionId, signature);
|
||||
console.log(
|
||||
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
|
||||
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, localScrollbackRows=${baseY})`
|
||||
);
|
||||
},
|
||||
|
||||
_flushWheelSgrQueue() {
|
||||
this._wheelSgrFlushTimer = null;
|
||||
const data = this._wheelSgrQueue;
|
||||
|
||||
Reference in New Issue
Block a user