mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
fix(review): wire full-scrollback replay to an explicit ?full=1, dedup + bound the capture (PR #148)
- Replace the 'missing ?tail means reload' overload with an explicit ?full=1 query param: the frontend's first buffer load after a page load (selectSession) now requests full=1, tab switches keep ?tail=, and the legacy no-param callers (response-viewer fallback, clearTerminal refresh) keep the cheap visible-frame path — the COD-47 feature was previously unreachable from a real reload. - When the full-history capture succeeds, return it ALONE instead of prepending the byte buffer + \x1b[H\x1b[2J: the capture is the rendered superset of the byte history, and ED2 clears only the viewport so the concat replayed the whole conversation twice in xterm scrollback. The history+clear+frame concat stays for the visible-frame/tab-switch path. - Pass an explicit execSync maxBuffer for the full-history capture (configured terminalBufferMaxBytes + slack) — the 1MB Node default ENOBUFS-killed exactly the multi-MB captures the feature exists for; log ENOBUFS concisely instead of dumping the truncated stdout. - Bound the capture itself via -S -<N> derived from the configured tmux history limit (was unbounded -S -), and add -J so lines hard-wrapped at the capture-time pane width reflow in the browser xterm. - Cap the concatenated buffer to terminalBufferMaxBytes EARLY (before the regex normalization passes) so multi-MB captures don't stall the event loop normalizing bytes that get sliced away. - Tests: route tests updated for ?full=1 semantics (capture-alone response, config-forwarded capture bounds, byte-history fallback, no-param requests stay on the visible-frame path); source-scan tests cover the bounded -J -S -<N> flags and explicit maxBuffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+11
-1
@@ -324,6 +324,7 @@ class CodemanApp {
|
||||
this._initGeneration = 0; // dedup concurrent handleInit calls
|
||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
||||
this._initialFullBufferLoad = true; // first buffer load after a page load fetches full tmux scrollback (COD-47)
|
||||
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
|
||||
this.respawnStatus = {};
|
||||
this.respawnTimers = {}; // Track timed respawn timers
|
||||
@@ -3705,7 +3706,16 @@ class CodemanApp {
|
||||
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
||||
_crashDiag.log('FETCH_START');
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
// The FIRST buffer load after a page load requests the full tmux scrollback
|
||||
// (?full=1, COD-47) so history that scrolled off the server's byte buffer
|
||||
// comes back after a reload. Tab switches keep the fast ?tail= frame path.
|
||||
const useFullHistory = this._initialFullBufferLoad === true;
|
||||
this._initialFullBufferLoad = false;
|
||||
const res = await fetch(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
);
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
|
||||
@@ -980,19 +980,21 @@ export function registerSessionRoutes(
|
||||
|
||||
// Query params:
|
||||
// tail=<bytes> - Only return last N bytes (faster initial load)
|
||||
// full=1 - Full page reload: replay the entire tmux scrollback (COD-47)
|
||||
app.get('/api/sessions/:id/terminal', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { tail?: string };
|
||||
const query = req.query as { tail?: string; full?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// A request WITHOUT a `tail` param is a FULL RELOAD (the browser reloaded the
|
||||
// page and needs the whole scroll history back). A request WITH `tail` is a
|
||||
// tab switch — only the recent tail matters and speed wins. On a full reload
|
||||
// we capture the ENTIRE tmux scrollback (`-S -`, COD-47) so the user gets
|
||||
// back history that scrolled off Codeman's byte buffer; on a tab switch we
|
||||
// capture only the visible frame, which stays fast.
|
||||
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
|
||||
// the page and wants the whole scroll history back, so we capture the ENTIRE
|
||||
// tmux scrollback and the user gets back history that scrolled off Codeman's
|
||||
// byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy
|
||||
// no-param callers (response-viewer fallback, clearTerminal refresh) — keep
|
||||
// the fast visible-frame capture.
|
||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||
const isFullReload = tailBytes <= 0;
|
||||
const isFullReload = query.full === '1' || query.full === 'true';
|
||||
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
|
||||
|
||||
// Prepend the live tmux pane buffer so tab-switch replay shows the current
|
||||
// on-screen frame, not just the accumulated byte history. This matters for
|
||||
@@ -1004,24 +1006,52 @@ export function registerSessionRoutes(
|
||||
const muxName = session.muxName;
|
||||
const liveMuxBuffer =
|
||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||
? ctx.mux.captureActivePaneBuffer(muxName, isFullReload ? { fullHistory: true } : undefined)
|
||||
? ctx.mux.captureActivePaneBuffer(
|
||||
muxName,
|
||||
isFullReload
|
||||
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
|
||||
: undefined
|
||||
)
|
||||
: null;
|
||||
const source: 'history' | 'mux-visible' | 'mux-full-history' =
|
||||
liveMuxBuffer !== null && liveMuxBuffer.length > 0
|
||||
? isFullReload
|
||||
? 'mux-full-history'
|
||||
: 'mux-visible'
|
||||
: 'history';
|
||||
const rawBuffer =
|
||||
liveMuxBuffer !== null && liveMuxBuffer.length > 0
|
||||
? session.terminalBufferLength > 0
|
||||
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
||||
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
|
||||
? isFullReload
|
||||
? 'mux-full-history'
|
||||
: 'mux-visible'
|
||||
: 'history';
|
||||
let rawBuffer: string;
|
||||
if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) {
|
||||
// Full-history capture is the RENDERED form of everything already in the
|
||||
// byte buffer (up to tmux eviction) — return it alone. Prepending the byte
|
||||
// history would replay the whole conversation twice: `\x1b[2J` clears only
|
||||
// the viewport, not xterm scrollback. The history+clear+frame concat stays
|
||||
// for the visible-frame path, where the single pane frame lacks history.
|
||||
rawBuffer = isFullReload
|
||||
? liveMuxBuffer
|
||||
: session.terminalBufferLength > 0
|
||||
? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
|
||||
: liveMuxBuffer
|
||||
: session.terminalBuffer;
|
||||
: liveMuxBuffer;
|
||||
} else {
|
||||
rawBuffer = session.terminalBuffer;
|
||||
}
|
||||
const fullSize = rawBuffer.length;
|
||||
let truncated = false;
|
||||
let cleanBuffer: string;
|
||||
|
||||
// Cap the payload EARLY — before the regex normalization passes below run
|
||||
// over it. A full-history tmux capture can be tens of MB of scrollback;
|
||||
// normalizing all of it would stall the event loop only to discard most
|
||||
// bytes anyway. Keep the most RECENT bytes (slice from the end) and align
|
||||
// to a line boundary so we never start mid-ANSI-escape.
|
||||
if (terminalBufferMaxBytes > 0 && rawBuffer.length > terminalBufferMaxBytes) {
|
||||
rawBuffer = rawBuffer.slice(-terminalBufferMaxBytes);
|
||||
truncated = true;
|
||||
const capNewline = rawBuffer.indexOf('\n');
|
||||
if (capNewline > 0 && capNewline < 4096) {
|
||||
rawBuffer = rawBuffer.slice(capNewline + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip redundant Ink spinner/status redraws BEFORE tailing.
|
||||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||||
@@ -1069,20 +1099,6 @@ export function registerSessionRoutes(
|
||||
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
||||
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
||||
|
||||
// Cap the payload at the configured terminal buffer limit. Full-history
|
||||
// tmux capture (`-S -`) can be tens of MB of scrollback; shipping all of it
|
||||
// would freeze the browser xterm. Keep the most RECENT bytes (slice from the
|
||||
// end) and align to a line boundary so we never start mid-ANSI-escape.
|
||||
const { terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
|
||||
if (terminalBufferMaxBytes > 0 && cleanBuffer.length > terminalBufferMaxBytes) {
|
||||
cleanBuffer = cleanBuffer.slice(-terminalBufferMaxBytes);
|
||||
truncated = true;
|
||||
const firstNewline = cleanBuffer.indexOf('\n');
|
||||
if (firstNewline > 0 && firstNewline < 4096) {
|
||||
cleanBuffer = cleanBuffer.slice(firstNewline + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
terminalBuffer: cleanBuffer,
|
||||
status: session.status,
|
||||
|
||||
Reference in New Issue
Block a user