mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-14 11:19:42 +02:00
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:
co-authored by
Claude Opus 5.5
parent
b620e42ffa
commit
063e6fc076
@@ -276,7 +276,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
|
||||
@@ -543,15 +543,17 @@ client that opens many concurrent waits against one session will still hit the c
|
||||
|
||||
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
|
||||
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
|
||||
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
|
||||
geometry was read. The capture runs synchronous tmux calls on the server; the
|
||||
`truncationReason`, `fullSize`, `historyLinesCut` (a `lines=` bound left older
|
||||
lines in tmux, so the remainder is more than `fullSize - retainedBytes`), and
|
||||
`captureCols`/`captureRows` when the pane's geometry was read. The capture runs synchronous tmux calls on the server; the
|
||||
`Server-Timing` header reports `capture`, `prepare` and `total`.
|
||||
|
||||
| Query | Meaning |
|
||||
|---|---|
|
||||
| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. |
|
||||
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
|
||||
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When the bound left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
|
||||
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
|
||||
| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. |
|
||||
|
||||
## The `codeman agent` CLI (client over these endpoints)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -90,6 +90,25 @@ describe('computeHistoryTruncationNotice (issue #258)', () => {
|
||||
expect(notice.message).not.toMatch(/\d+ (B|KB) more/);
|
||||
});
|
||||
|
||||
it('names no amount when tail also cut bytes off a window tmux cut by lines', () => {
|
||||
// The window was over 1 MiB: `dropped` counts only the bytes tail cut off
|
||||
// it, while tmux still holds more beyond the window, so that number would
|
||||
// understate what is left.
|
||||
const state = {
|
||||
truncated: true,
|
||||
reason: 'tail',
|
||||
source: 'mux-full-history',
|
||||
fullSize: 3 * 1024 * 1024,
|
||||
retainedBytes: 1024 * 1024,
|
||||
};
|
||||
const windowOnly = computeHistoryTruncationNotice({ ...state, historyLinesCut: true });
|
||||
expect(windowOnly.canLoadMore).toBe(true);
|
||||
expect(windowOnly.message).toContain('Earlier output may still be retained');
|
||||
expect(windowOnly.message).not.toMatch(/\d+(\.\d+)? (B|KB|MB) more/);
|
||||
// Without a lines cut the byte remainder IS all that is left, and is named.
|
||||
expect(computeHistoryTruncationNotice(state).message).toContain('2.0 MB more may still be retained');
|
||||
});
|
||||
|
||||
it('promises nothing more once the FULL capture itself hit the ceiling', () => {
|
||||
// This is the case the old boolean could not express: a full-history pull
|
||||
// that was still capped means tmux has already given everything it has.
|
||||
|
||||
@@ -1172,14 +1172,99 @@ describe('session-routes', () => {
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&tail=${1024 * 1024}&lines=${lines}`,
|
||||
});
|
||||
const data = JSON.parse(res.body).data;
|
||||
return { truncated: data.truncated, reason: data.truncationReason };
|
||||
return { truncated: data.truncated, reason: data.truncationReason, linesCut: data.historyLinesCut };
|
||||
};
|
||||
|
||||
expect(await lineCountFor(10000, 25000)).toEqual({ truncated: true, reason: 'tail' });
|
||||
expect(await lineCountFor(10000, 10000)).toEqual({ truncated: false, reason: null });
|
||||
expect(await lineCountFor(10000, 400)).toEqual({ truncated: false, reason: null });
|
||||
expect(await lineCountFor(10000, 25000)).toEqual({ truncated: true, reason: 'tail', linesCut: true });
|
||||
expect(await lineCountFor(10000, 10000)).toEqual({ truncated: false, reason: null, linesCut: false });
|
||||
expect(await lineCountFor(10000, 400)).toEqual({ truncated: false, reason: null, linesCut: false });
|
||||
// No depth reported (an older tmux, a failed query): nothing is claimed.
|
||||
expect(await lineCountFor(10000, undefined)).toEqual({ truncated: false, reason: null });
|
||||
expect(await lineCountFor(10000, undefined)).toEqual({ truncated: false, reason: null, linesCut: false });
|
||||
});
|
||||
|
||||
// Without `lines=` the bound is the configured ceiling. On tmux before 3.7 a
|
||||
// live pane keeps history deeper than a lowered ceiling, which no request
|
||||
// can read, so reporting it as a recoverable cut would offer Load full
|
||||
// history on a pull that can never return more.
|
||||
it('reports no cut for a full capture without `lines=`, nor for `lines=` at the ceiling', async () => {
|
||||
harness.ctx._session.mode = 'shell';
|
||||
const limit = (await harness.ctx.getTerminalHistoryConfig()).tmuxHistoryLimit;
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
(_name: string, opts?: { capturedHistorySize?: number }) => {
|
||||
if (opts) opts.capturedHistorySize = limit * 5;
|
||||
return 'captured rows\r\nlast row';
|
||||
}
|
||||
);
|
||||
for (const query of ['full=1', `full=1&tail=${1024 * 1024}`, `full=1&lines=${limit}`]) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?${query}`,
|
||||
});
|
||||
const data = JSON.parse(res.body).data;
|
||||
expect({ truncated: data.truncated, reason: data.truncationReason }, query).toEqual({
|
||||
truncated: false,
|
||||
reason: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// A nested tmux (remote SSH and Docker cases, or `tmux` run by hand in a
|
||||
// shell) holds the pane in the alternate screen, where tmux keeps no
|
||||
// history: the full capture is one screen. `alt=recording` (a shell's
|
||||
// automatic load) then gets the visible frame over the byte recording,
|
||||
// which still carries the scrollback.
|
||||
describe('alt=recording on a pane in the alternate screen', () => {
|
||||
const install = (alternateOn: boolean | undefined) => {
|
||||
const spy = vi.fn((_name: string, opts?: { fullHistory?: boolean; capturedAlternateOn?: boolean }) => {
|
||||
if (opts?.fullHistory) {
|
||||
if (alternateOn !== undefined) opts.capturedAlternateOn = alternateOn;
|
||||
return 'one screen of full capture';
|
||||
}
|
||||
return 'visible frame';
|
||||
});
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = spy;
|
||||
return spy;
|
||||
};
|
||||
const load = async (query: string) => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?${query}`,
|
||||
});
|
||||
return JSON.parse(res.body).data;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
harness.ctx._session.mode = 'shell';
|
||||
harness.ctx._session.terminalBuffer = 'recorded scrollback\r\n';
|
||||
});
|
||||
|
||||
it('falls back to the visible frame over the tailed byte recording', async () => {
|
||||
const spy = install(true);
|
||||
const data = await load(`full=1&tail=${1024 * 1024}&alt=recording&lines=10000`);
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
expect(spy.mock.calls[1][1]).toEqual({});
|
||||
expect(data.source).toBe('mux-visible');
|
||||
expect(data.terminalBuffer).toContain('recorded scrollback');
|
||||
expect(data.terminalBuffer).toContain('visible frame');
|
||||
expect(data.terminalBuffer).not.toContain('one screen of full capture');
|
||||
});
|
||||
|
||||
it('keeps the full capture when the pane is in the normal screen or tmux did not say', async () => {
|
||||
for (const alternateOn of [false, undefined]) {
|
||||
const spy = install(alternateOn);
|
||||
const data = await load(`full=1&tail=${1024 * 1024}&alt=recording&lines=10000`);
|
||||
expect(spy, String(alternateOn)).toHaveBeenCalledTimes(1);
|
||||
expect(data.source).toBe('mux-full-history');
|
||||
expect(data.terminalBuffer).toContain('one screen of full capture');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the full capture without the opt-in (agents, Load full history, scroll pulls)', async () => {
|
||||
const spy = install(true);
|
||||
const data = await load(`full=1&tail=${1024 * 1024}&lines=10000`);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(data.source).toBe('mux-full-history');
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the byte cap outrank a `lines=` cut: the oldest bytes of the window are gone for good', async () => {
|
||||
|
||||
@@ -159,7 +159,9 @@ describe('terminal flush budget', () => {
|
||||
const first = app._onSessionNeedsRefresh({ id: 'session-1' });
|
||||
const duplicate = app._onSessionNeedsRefresh({ id: 'session-1' });
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/session-1/terminal?full=1&tail=1048576&lines=10000');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/sessions/session-1/terminal?full=1&tail=1048576&alt=recording&lines=10000'
|
||||
);
|
||||
|
||||
releaseFetch();
|
||||
await Promise.all([first, duplicate]);
|
||||
@@ -184,7 +186,7 @@ describe('terminal flush budget', () => {
|
||||
await app._onSessionClearTerminal({ id: 'agent-1' });
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'/api/sessions/shell-1/terminal?full=1&tail=1048576&lines=10000',
|
||||
'/api/sessions/shell-1/terminal?full=1&tail=1048576&alt=recording&lines=10000',
|
||||
'/api/sessions/agent-1/terminal',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024;
|
||||
const TERMINAL_TAIL_SIZE = 1024 * 1024;
|
||||
// constants.js: a shell loads tmux's rendered history, bounded, never the raw byte recording.
|
||||
const SHELL_LOAD_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&lines=10000`;
|
||||
const SHELL_LOAD_BASE_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&alt=recording`;
|
||||
const SHELL_LOAD_QUERY = `${SHELL_LOAD_BASE_QUERY}&lines=10000`;
|
||||
/** The pane's `performance.now()`, so frame arrival vs. capture time is set by hand, not raced. */
|
||||
let clock = 0;
|
||||
|
||||
@@ -110,6 +111,7 @@ function loadTerminalTile() {
|
||||
// The constants.js globals the module reads at call time.
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
TERMINAL_TAIL_SIZE,
|
||||
SHELL_LOAD_BASE_QUERY,
|
||||
SHELL_LOAD_QUERY,
|
||||
});
|
||||
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
||||
@@ -276,10 +278,7 @@ describe('TerminalTile server-refresh single-flight', () => {
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/api/sessions/s1/terminal?full=1&tail=${1024 * 1024}&lines=10000`,
|
||||
expect.anything()
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?${SHELL_LOAD_QUERY}`, expect.anything());
|
||||
});
|
||||
|
||||
it('refreshes arriving mid-fetch neither reset nor fetch again, and run ONCE after the replay lands', async () => {
|
||||
|
||||
@@ -216,14 +216,14 @@ describe('initial loads', () => {
|
||||
expect(states.filter(([id]) => id === 'b').map(([, s]) => s)).toEqual(['queued', 'running', 'idle']);
|
||||
});
|
||||
|
||||
it("loads a bounded window of tmux's rendered history: the tile's lines for a TUI, the shell bound for a shell", async () => {
|
||||
it("loads a bounded window of tmux's rendered history, bounded by the tile's own lines for a TUI and a shell", async () => {
|
||||
const { tiles } = makeGrid(['tui', 'sh'], { modes: { sh: 'shell' } });
|
||||
const connecting = tiles.map((t) => t.connect());
|
||||
await drain();
|
||||
await Promise.all(connecting);
|
||||
expect(captures.map((c) => c.url)).toEqual([
|
||||
`/api/sessions/tui/terminal?full=1&tail=${TAIL}${LINES}`,
|
||||
`/api/sessions/sh/terminal?full=1&tail=${TAIL}&lines=10000`,
|
||||
`/api/sessions/sh/terminal?full=1&tail=${TAIL}&alt=recording${LINES}`,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -190,8 +190,26 @@ describe('queryPaneCursor history depth', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('asks tmux for the history depth in the same display-message as the cursor', () => {
|
||||
it('reads tmux alternate_on as an optional sixth field', () => {
|
||||
expect(queryPaneCursor(() => '4 22 133 24 29 1\n')).toEqual({
|
||||
cols: 133,
|
||||
rows: 24,
|
||||
cursorX: 4,
|
||||
cursorY: 22,
|
||||
historySize: 29,
|
||||
alternateOn: true,
|
||||
});
|
||||
expect(queryPaneCursor(() => '4 22 133 24 29 0')?.alternateOn).toBe(false);
|
||||
for (const raw of ['4 22 133 24 29', '4 22 133 24 29 2', '4 22 133 24 29 x']) {
|
||||
expect(
|
||||
queryPaneCursor(() => raw),
|
||||
raw
|
||||
).not.toHaveProperty('alternateOn');
|
||||
}
|
||||
});
|
||||
|
||||
it('asks tmux for the history depth and alternate screen in the same display-message as the cursor', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||||
expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'");
|
||||
expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user