fix(terminal): keep the byte-recording load for a shell in the alternate screen, report a lines cut only below the ceiling

Review follow-ups for loading a shell from tmux's rendered history.

tmux adds no history while a pane is in the alternate screen, and a
nested tmux client (remote SSH and Docker cases, or tmux run by hand)
holds its pane there, so a full capture of it is one screen. The
display-message query that reads #{history_size} now also reads
#{alternate_on} (PaneCaptureOptions.capturedAlternateOn), and a request
carrying `alt=recording` (only SHELL_LOAD_QUERY sends it) is answered
the old way on such a pane: the visible frame over the tailed byte
recording. Agents, scroll pulls and Load full history are unchanged.
alternate_on cannot tell a nested tmux from vim or less, so a local
shell with one of those open takes the same fallback while it is open.

A full capture without `lines=` was reported as a 'tail' cut whenever
the pane held more than the configured ceiling (possible on tmux before
3.7), offering a Load full history that could never return more. The
cut is now reported only for a client bound below the ceiling.

The response carries `historyLinesCut` when that bound left lines in
tmux, and the banner then says earlier output may be retained instead
of naming the bytes a later tail cut, which undercount what tmux holds.
A bounded grid tile's shell load sends its own `lines=` (via
SHELL_LOAD_BASE_QUERY). CLAUDE.md, docs/architecture-invariants.md and
docs/api-reference.md describe the shell load, the cut report and the
fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
timkjrandClaude Opus 5.5 committed 2026-10-10 23:29:19 -05:00
1 parent b620e42ffa
commit 063e6fc076
15 files changed
+226 -41

No files matched your search

+7
View File
@@ -211,6 +211,13 @@ export interface PaneCaptureOptions {
* behind in the multiplexer, which the caller must report as recoverable.
*/
capturedHistorySize?: number;
/**
* Filled in by the implementation with whether the pane was in the alternate
* screen at capture time, when it can tell. tmux keeps no history for such a
* pane (a nested tmux client puts it there), so a full-history capture of it
* holds one screen plus whatever the pane held before.
*/
capturedAlternateOn?: boolean;
}
/**
+11 -4
View File
@@ -702,6 +702,8 @@ interface PaneCursorGeometry {
cursorY: number;
/** Lines of scrollback above the screen (`#{history_size}`), when tmux reported it. */
historySize?: number;
/** Whether the pane is in the alternate screen (`#{alternate_on}`), when tmux reported it. */
alternateOn?: boolean;
}
/**
@@ -709,8 +711,9 @@ interface PaneCursorGeometry {
*
* The four geometry fields are validated together: a caller that gets a value
* back can place a caret with it, and one that gets null must not try. The
* optional fifth field, the history depth, is only informational, so a bad one
* is dropped rather than discarding a good caret.
* optional fifth and sixth fields, the history depth and the alternate-screen
* flag, are only informational, so a bad one is dropped rather than
* discarding a good caret.
*/
export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
let raw: string;
@@ -720,7 +723,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
return null;
}
const [cursorX, cursorY, cols, rows, historySize] = raw.split(/\s+/).map((value) => parseInt(value, 10));
const [cursorX, cursorY, cols, rows, historySize, alternateOn] = raw.split(/\s+/).map((value) => parseInt(value, 10));
if (
!Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) ||
@@ -735,6 +738,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
}
const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY };
if (Number.isFinite(historySize) && historySize >= 0) geometry.historySize = historySize;
if (alternateOn === 0 || alternateOn === 1) geometry.alternateOn = alternateOn === 1;
return geometry;
}
@@ -3896,7 +3900,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() =>
execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'`,
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
);
@@ -3911,6 +3915,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// How deep tmux's history really is, so a caller that bounded the capture
// with `historyLimitLines` can tell whether the bound cut anything.
if (opts && geometry?.historySize !== undefined) opts.capturedHistorySize = geometry.historySize;
// A pane in the alternate screen keeps no history (a nested tmux client
// holds it there), so its capture is one screen however deep it reads.
if (opts && geometry?.alternateOn !== undefined) opts.capturedAlternateOn = geometry.alternateOn;
if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim.
+2
View File
@@ -8880,6 +8880,8 @@ class CodemanApp {
source: payload.source ?? null,
fullSize: payload.fullSize ?? 0,
retainedBytes: payload.retainedBytes ?? 0,
// tmux holds lines beyond a `lines=` window, so fullSize undercounts.
historyLinesCut: !!payload.historyLinesCut,
// Set once a full-history pull has been refused as a downgrade: the
// browser holds more than the server can return, so there is no more.
exhausted: !!payload.exhausted,
+14 -5
View File
@@ -123,9 +123,14 @@ const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more s
// scrolls its top row into scrollback, so one line shows up dozens of times.
// `lines=` bounds the synchronous capture-pane (~8 ms per 1000 lines measured,
// so ~0.1 s here); the server reports a cut as 'tail', and scroll-to-top pulls
// the older lines as before.
// the older lines as before. `alt=recording`: a pane in the alternate screen (a
// nested tmux, as in remote SSH and Docker cases, but also vim or less while
// open) gains no tmux history, so the server loads it the old way, visible
// frame over the tailed byte recording.
// A grid tile appends its own `lines=` to SHELL_LOAD_BASE_QUERY.
const SHELL_LOAD_HISTORY_LINES = 10000;
const SHELL_LOAD_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&lines=${SHELL_LOAD_HISTORY_LINES}`;
const SHELL_LOAD_BASE_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&alt=recording`;
const SHELL_LOAD_QUERY = `${SHELL_LOAD_BASE_QUERY}&lines=${SHELL_LOAD_HISTORY_LINES}`;
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer
@@ -1885,7 +1890,8 @@ function formatHistoryBytes(bytes) {
* - exhausted: a full pull was refused as a downgrade, so this is all there is
*
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
* fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
* historyLinesCut?: boolean}} state
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
*/
function computeHistoryTruncationNotice(state = {}) {
@@ -1914,8 +1920,11 @@ function computeHistoryTruncationNotice(state = {}) {
}
// A `lines=`-bounded capture is cut by tmux before any bytes are counted, so
// its fullSize is the window itself and the remainder's size is unknown:
// say that more exists without inventing an amount.
const remainder = dropped > 0 ? `${formatHistoryBytes(dropped)} more` : 'Earlier output';
// say that more exists without inventing an amount. That holds even when
// `tail` also cut bytes off the window (`dropped`), since tmux still holds
// more beyond it (`historyLinesCut`, set by the server).
const remainder =
dropped > 0 && !state.historyLinesCut ? `${formatHistoryBytes(dropped)} more` : 'Earlier output';
return {
visible: true,
message: `Showing the most recent ${shown} of this session. ${remainder} may still be retained.`,
+7 -2
View File
@@ -771,9 +771,14 @@
if (this._destroyed) return;
const shell = this.sessionMode === 'shell';
// A shell loads tmux's rendered history, bounded (SHELL_LOAD_QUERY,
// constants.js), never the raw byte recording.
// constants.js), never the raw byte recording; a grid tile bounds it
// by its own scrollback instead.
let query = shell ? SHELL_LOAD_QUERY : 'full=1';
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
if (this.boundedLoad) {
query = shell
? `${SHELL_LOAD_BASE_QUERY}${this._historyLinesQuery()}`
: `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
}
this._loadAbort = controller;
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000);
let payload;
+38 -8
View File
@@ -3080,10 +3080,12 @@ export function registerSessionRoutes(
// Query params:
// tail=<bytes> - Only return last N bytes (faster initial load)
// full=1 - Explicitly request the entire tmux scrollback (COD-47)
// alt=recording - With full=1: if the pane is in the alternate screen, load
// the visible frame over the byte recording instead
app.get('/api/sessions/:id/terminal', async (req, reply) => {
const routeStartedAt = performance.now();
const { id } = req.params as { id: string };
const query = req.query as { tail?: string; full?: string; lines?: string };
const query = req.query as { tail?: string; full?: string; lines?: string; alt?: string };
const session = findSessionOrFail(ctx, id, req);
// `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE
@@ -3091,7 +3093,7 @@ export function registerSessionRoutes(
// Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
// no-param callers — keep the fast visible-frame capture.
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const isFullReload = query.full === '1' || query.full === 'true';
let 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
@@ -3109,17 +3111,32 @@ export function registerSessionRoutes(
// for. See PaneCaptureOptions.capturedGeometry.
// `lines` bounds only the history a FULL capture reads; the visible-frame
// path reads no history and is untouched by it.
const captureOpts: PaneCaptureOptions = isFullReload
let captureOpts: PaneCaptureOptions = isFullReload
? {
fullHistory: true,
historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
maxCaptureBytes: terminalBufferMaxBytes,
}
: {};
const liveMuxBuffer =
const capture = (opts: PaneCaptureOptions): string | null =>
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
? ctx.mux.captureActivePaneBuffer(muxName, captureOpts)
? ctx.mux.captureActivePaneBuffer(muxName, opts)
: null;
let liveMuxBuffer = capture(captureOpts);
// tmux adds no history while a pane is in the alternate screen, and a
// nested tmux client (a remote SSH or Docker case, or `tmux` run by hand
// in a shell) holds its pane there for good, so the full capture above is
// one screen plus whatever came before. The byte recording still carries
// the scrollback, so a caller that opts in (a shell's automatic load,
// `alt=recording`) gets the old visible-frame load over it, tailed, rather
// than losing everything above the screen. `alternate_on` cannot tell a
// nested tmux from vim or less, so those take this path too while open:
// the load the shell had before tmux-rendered loads existed.
if (isFullReload && query.alt === 'recording' && captureOpts.capturedAlternateOn === true) {
isFullReload = false;
captureOpts = {};
liveMuxBuffer = capture(captureOpts);
}
const captureFinishedAt = performance.now();
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
@@ -3164,13 +3181,23 @@ export function registerSessionRoutes(
// tmux: an intentional partial read, so 'tail', and a later pull with a
// deeper (or no) bound recovers them. Without this a bounded window looks
// like the whole history to the client. The byte cap below still wins.
// Only a bound the CLIENT set and below the configured ceiling counts:
// without `lines=` the bound is the ceiling itself, and on tmux before 3.7
// a live pane can hold more than a lowered ceiling, which no request can
// read, so calling that a recoverable cut would offer a button that can
// never return more.
// `historyLinesCut` tells the client this happened, apart from any byte
// cut below: `fullSize` counts only what tmux returned, so the bytes a
// later `tail` cut are not all that is left, and the client must not name
// them as the remainder.
const capturedHistorySize = captureOpts.capturedHistorySize;
if (
const historyLinesCut =
isFullCapture &&
captureOpts.historyLimitLines !== undefined &&
captureOpts.historyLimitLines < tmuxHistoryLimit &&
capturedHistorySize !== undefined &&
capturedHistorySize > captureOpts.historyLimitLines
) {
capturedHistorySize > captureOpts.historyLimitLines;
if (historyLinesCut) {
truncated = true;
truncationReason = 'tail';
}
@@ -3263,6 +3290,9 @@ export function registerSessionRoutes(
fullSize,
truncated,
truncationReason,
// The `lines=` bound left older lines in tmux (see above): the remainder
// is more than `fullSize - retainedBytes`, by an amount unknown here.
historyLinesCut,
// `retainedBytes` is what this response actually carries; `fullSize` is
// what existed before the cut. The gap is what the indicator reports.
retainedBytes: cleanBuffer.length,