mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(terminal): let a Shell pane's scroll-up reach tmux history
A burst of output leaves a Shell pane with about one screen of browser scrollback, because tmux repaints the burst instead of scrolling it, while tmux itself keeps every line. Shell declined the scroll-to-top re-pull other modes use, and the Load full history button renders only once a replay was truncated, so a Shell tab under 1 MiB could not scroll back at all. The scroll gesture now pulls ?full=1&tail=TERMINAL_TAIL_SIZE, the same bound a tab switch loads; the route's existing tail cut marks longer histories 'tail', so the banner still offers the unbounded pull. A window no longer than the browser's buffer is not rewritten. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5ae574374f
commit
9676e90133
File diff suppressed because one or more lines are too long
@@ -151,8 +151,9 @@ Worth knowing:
|
|||||||
|
|
||||||
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
||||||
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
||||||
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
|
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
|
||||||
and automatic output recovery stay within the bounded browser buffer.
|
history; press **Load full history** to pull the rest explicitly. Automatic output
|
||||||
|
recovery stays within the bounded browser buffer.
|
||||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
|
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
|
||||||
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
|
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
|
||||||
always local scrollback. Other CLIs scroll locally.
|
always local scrollback. Other CLIs scroll locally.
|
||||||
|
|||||||
+29
-5
@@ -6320,10 +6320,15 @@ class CodemanApp {
|
|||||||
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
||||||
if (this.detachedSessions?.has(sessionId)) return;
|
if (this.detachedSessions?.has(sessionId)) return;
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
// A shell's full capture can be many megabytes. Replaying it from an
|
// A shell's full capture can be many megabytes, and replaying all of it from
|
||||||
// ordinary scroll gesture blocks xterm's main thread, so keep that cost
|
// an ordinary scroll gesture blocks xterm's main thread. So a shell scroll
|
||||||
// behind the explicit "Load full history" button.
|
// pulls a BOUNDED window of tmux's full history (the same 1 MiB a tab switch
|
||||||
if (!force && session?.mode === 'shell') return;
|
// loads, but of the scrollback rather than the visible frame) and the
|
||||||
|
// unbounded pull stays behind the "Load full history" button. Declining
|
||||||
|
// outright left a shell pane about one screen of browser scrollback after any
|
||||||
|
// burst, and the button only renders once a replay was truncated, so a young
|
||||||
|
// shell tab had no way back to output tmux was still holding.
|
||||||
|
const boundedShellPull = !force && session?.mode === 'shell';
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
// 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.
|
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
||||||
@@ -6336,7 +6341,12 @@ class CodemanApp {
|
|||||||
this._fullHistoryRepullInFlight = true;
|
this._fullHistoryRepullInFlight = true;
|
||||||
try {
|
try {
|
||||||
const requestStartedAt = performance.now();
|
const requestStartedAt = performance.now();
|
||||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true });
|
const capture = await this._fetchTerminalCapture(
|
||||||
|
boundedShellPull
|
||||||
|
? `/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`
|
||||||
|
: `/api/sessions/${sessionId}/terminal?full=1`,
|
||||||
|
{ full: true }
|
||||||
|
);
|
||||||
const headersReceivedAt = capture.headersAt;
|
const headersReceivedAt = capture.headersAt;
|
||||||
const payload = capture.json?.data ?? {};
|
const payload = capture.json?.data ?? {};
|
||||||
const bodyParsedAt = performance.now();
|
const bodyParsedAt = performance.now();
|
||||||
@@ -6368,6 +6378,20 @@ class CodemanApp {
|
|||||||
this._setHistoryTruncation(sessionId, { ...payload, exhausted: true });
|
this._setHistoryTruncation(sessionId, { ...payload, exhausted: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A bounded window no longer than the browser's buffer buys nothing, and
|
||||||
|
// resetting to rewrite it would jump the viewport on every scroll that
|
||||||
|
// outlasts the cooldown at the top. An untruncated window IS all of tmux's
|
||||||
|
// history, so nothing is missing; a window cut at the tail size would trade
|
||||||
|
// more old rows than it recovers, and its 'tail' truncation keeps the
|
||||||
|
// banner offering the unbounded pull. Not latched as useless: the next
|
||||||
|
// burst of output can put more history in tmux than the browser has.
|
||||||
|
if (
|
||||||
|
boundedShellPull &&
|
||||||
|
this._estimateReplayRows(buffer, this.terminal.cols) <= this.terminal.buffer.active.length
|
||||||
|
) {
|
||||||
|
this._setHistoryTruncation(sessionId, payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._setHistoryTruncation(sessionId, payload);
|
this._setHistoryTruncation(sessionId, payload);
|
||||||
this._fullHistoryRepullUseless?.delete(sessionId);
|
this._fullHistoryRepullUseless?.delete(sessionId);
|
||||||
const rowsBefore = this.terminal.buffer.active.length;
|
const rowsBefore = this.terminal.buffer.active.length;
|
||||||
|
|||||||
@@ -3304,9 +3304,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/**
|
/**
|
||||||
* Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the
|
* Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the
|
||||||
* buffer while scrolling up gives the app a chance to pull the rest of tmux's
|
* buffer while scrolling up gives the app a chance to pull the rest of tmux's
|
||||||
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions decline
|
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions pull a
|
||||||
* automatic pulls because their captures can be large; their banner button is
|
* bounded window because their captures can be large; their banner button is
|
||||||
* the explicit path. Must be called AFTER scrollLines(), since the check is on
|
* the unbounded path. Must be called AFTER scrollLines(), since the check is on
|
||||||
* the resulting position, and it is deliberately not folded into
|
* the resulting position, and it is deliberately not folded into
|
||||||
* _noteTerminalUserScroll for exactly that reason.
|
* _noteTerminalUserScroll for exactly that reason.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
|||||||
expect(app).not.toContain('earlier output truncated for performance');
|
expect(app).not.toContain('earlier output truncated for performance');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads a bounded shell tail first and keeps full history user-triggered', () => {
|
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
|
||||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
|
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
|
||||||
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
|
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
|
||||||
@@ -131,10 +131,13 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
|||||||
// an abort deadline (a `?full=1` body can be megabytes and used to hang
|
// an abort deadline (a `?full=1` body can be megabytes and used to hang
|
||||||
// indefinitely on a stalled mobile link). The URL and the full-vs-tail
|
// indefinitely on a stalled mobile link). The URL and the full-vs-tail
|
||||||
// decision this guard exists to pin are unchanged.
|
// decision this guard exists to pin are unchanged.
|
||||||
expect(app).toContain('this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true })');
|
expect(app).toContain(': `/api/sessions/${sessionId}/terminal?full=1`,\n { full: true }');
|
||||||
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
|
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
|
||||||
expect(app).toContain("if (session?.mode === 'shell')");
|
expect(app).toContain("if (session?.mode === 'shell')");
|
||||||
expect(app).toContain("if (!force && session?.mode === 'shell') return;");
|
// A shell scroll gesture pulls a BOUNDED window of full history; only the
|
||||||
|
// button pulls all of it (behaviour pinned in shell-scroll-history-pull.test.ts).
|
||||||
|
expect(app).toContain("const boundedShellPull = !force && session?.mode === 'shell';");
|
||||||
|
expect(app).toContain('`/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`');
|
||||||
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
|
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview A shell pane's scroll-up must reach the history tmux still holds.
|
||||||
|
*
|
||||||
|
* tmux repaints a burst of output instead of scrolling it, so after `cat` of a
|
||||||
|
* file longer than the screen the browser holds about one screen of scrollback
|
||||||
|
* while tmux holds all of it. Other modes recover it by re-pulling `?full=1`
|
||||||
|
* when the wheel reaches the top (`_maybeRefetchFullHistory`, issue #205). Shell
|
||||||
|
* declined that gesture outright to keep a multi-megabyte capture off xterm's
|
||||||
|
* main thread, leaving only the "Load full history" button, and that button
|
||||||
|
* renders only once a replay was truncated. A young shell tab therefore had no
|
||||||
|
* way to scroll back at all.
|
||||||
|
*
|
||||||
|
* The gesture now pulls a BOUNDED window (`?full=1&tail=TERMINAL_TAIL_SIZE`),
|
||||||
|
* the button stays the unbounded path, and a window the browser already holds
|
||||||
|
* in full is not rewritten.
|
||||||
|
*
|
||||||
|
* The method is extracted from app.js and run in a `vm` against stubs (no jsdom
|
||||||
|
* on this box; see connection-indicator.test.ts), with the REAL row estimators
|
||||||
|
* from terminal-ui.js, which decide both the downgrade and the no-gain skip.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const TERMINAL_TAIL_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
|
function methodSource(source: string, method: string): string {
|
||||||
|
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
|
||||||
|
expect(start, `${method} not found`).toBeGreaterThan(-1);
|
||||||
|
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
|
||||||
|
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real terminal-ui.js mixin, for `_estimateReplayRows` / `_replayWouldShrinkBuffer`. */
|
||||||
|
function loadTerminalMixin(): Record<string, unknown> {
|
||||||
|
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
|
||||||
|
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
performance,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
CodemanApp: FakeCodemanApp,
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
document: { addEventListener: vi.fn() },
|
||||||
|
});
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
return FakeCodemanApp.prototype;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRefetch(): (this: unknown, opts?: { force?: boolean }) => Promise<void> {
|
||||||
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
|
const body = methodSource(app, '_maybeRefetchFullHistory');
|
||||||
|
const context = vm.createContext({ performance, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE: 32 * 1024 });
|
||||||
|
return vm.runInContext(`({ ${body} })._maybeRefetchFullHistory`, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mixin = loadTerminalMixin();
|
||||||
|
const refetch = loadRefetch();
|
||||||
|
const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\r\n');
|
||||||
|
|
||||||
|
function makeApp(mode: string, { bufferRows, capture }: { bufferRows: number; capture: string }) {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const app = {
|
||||||
|
activeSessionId: 's1',
|
||||||
|
sessions: new Map([['s1', { mode }]]),
|
||||||
|
detachedSessions: new Set<string>(),
|
||||||
|
_fullHistoryRepullInFlight: false,
|
||||||
|
_isLoadingBuffer: false,
|
||||||
|
_fullHistoryRepullAt: new Map<string, number>(),
|
||||||
|
_fullHistoryRepullUseless: new Set<string>(),
|
||||||
|
terminalBufferCache: new Map<string, string>(),
|
||||||
|
terminal: {
|
||||||
|
cols: 80,
|
||||||
|
rows: 30,
|
||||||
|
buffer: { active: { length: bufferRows } },
|
||||||
|
scrollToLine: vi.fn(),
|
||||||
|
scrollToTop: vi.fn(),
|
||||||
|
},
|
||||||
|
_estimateReplayRows: mixin._estimateReplayRows,
|
||||||
|
_replayWouldShrinkBuffer: mixin._replayWouldShrinkBuffer,
|
||||||
|
_fetchTerminalCapture: vi.fn(async (url: string) => {
|
||||||
|
urls.push(url);
|
||||||
|
return {
|
||||||
|
headersAt: performance.now(),
|
||||||
|
headers: { get: () => '' },
|
||||||
|
json: { data: { terminalBuffer: capture, source: 'mux-full-history' } },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
_recordTerminalLoadTiming: vi.fn(),
|
||||||
|
_logScrollRouting: vi.fn(),
|
||||||
|
_setHistoryTruncation: vi.fn(),
|
||||||
|
_resetTerminalForReplay: vi.fn(),
|
||||||
|
_bufferLoadFinishOpts: vi.fn(() => ({})),
|
||||||
|
chunkedTerminalWrite: vi.fn(async () => ({ parsedAt: performance.now(), bufferLength: 400, completed: true })),
|
||||||
|
_syncStickyScrollBaseline: vi.fn(),
|
||||||
|
};
|
||||||
|
return { app, urls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('shell scroll-up pulls a bounded window of tmux history', () => {
|
||||||
|
it('a shell scroll gesture requests full history bounded by the tail size', async () => {
|
||||||
|
const { app, urls } = makeApp('shell', { bufferRows: 40, capture: lines(300) });
|
||||||
|
await refetch.call(app);
|
||||||
|
expect(urls).toEqual([`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`]);
|
||||||
|
// It then actually replays the recovered history.
|
||||||
|
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the Load full history button stays unbounded for a shell', async () => {
|
||||||
|
const { app, urls } = makeApp('shell', { bufferRows: 40, capture: lines(300) });
|
||||||
|
await refetch.call(app, { force: true });
|
||||||
|
expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('other modes keep the unbounded scroll pull', async () => {
|
||||||
|
const { app, urls } = makeApp('claude', { bufferRows: 40, capture: lines(300) });
|
||||||
|
await refetch.call(app);
|
||||||
|
expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a bounded window the browser already holds is not rewritten', async () => {
|
||||||
|
// Browser already has every row the window carries: resetting to rewrite
|
||||||
|
// it would jump the viewport on every scroll that outlasts the cooldown.
|
||||||
|
const { app } = makeApp('shell', { bufferRows: 320, capture: lines(300) });
|
||||||
|
await refetch.call(app);
|
||||||
|
expect(app._resetTerminalForReplay).not.toHaveBeenCalled();
|
||||||
|
expect(app.chunkedTerminalWrite).not.toHaveBeenCalled();
|
||||||
|
// Not latched as useless: more output can put more history in tmux.
|
||||||
|
expect(app._fullHistoryRepullUseless.has('s1')).toBe(false);
|
||||||
|
// The truncation state is still recorded, so a window capped at the tail
|
||||||
|
// size keeps offering the button.
|
||||||
|
expect(app._setHistoryTruncation).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user