mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge pull request #601: fix(terminal): load a shell from tmux's rendered history, not the byte recording
Conflicts resolved against #583 and #588: - One history-depth field: #583's `historyLines` / `capturedHistoryLines` (reported as `paneHistoryLines`) is kept, and #601's lines cut now reads it instead of a second `capturedHistorySize`. #601's `alternateOn` / `capturedAlternateOn` joins it, and the one display-message format string ends `#{history_size} #{alternate_on}`. - selectSession keeps #588's full=1 switch for panes with no scrollback, gated off remote and Docker sessions, and #601's SHELL_LOAD_QUERY for shells. - computeHistoryTruncationNotice names a line count when paneHistoryLines is known (#583) and otherwise uses #601's remainder wording. - docs: api-reference lists both historyLinesCut and paneHistoryLines; the notice invariant keys on history_size while the shell's automatic load is the one place alternate_on decides. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
36f8afdaba
15 files changed
+402
-50
No files matched your search
+24
-10
@@ -3209,15 +3209,16 @@ class CodemanApp {
|
||||
this._terminalRefreshOwner = refreshOwner;
|
||||
try {
|
||||
// A shell can retain a multi-megabyte/100k-line tmux history. Automatic
|
||||
// recovery stays bounded just like normal shell selection; only the
|
||||
// explicit "Load full history" action is allowed to pay for a full replay.
|
||||
// recovery stays bounded just like normal shell selection (tmux's rendered
|
||||
// history, SHELL_LOAD_QUERY); only the explicit "Load full history" action
|
||||
// is allowed to pay for a full replay.
|
||||
// TUI modes still recover the whole picture, with the downgrade guard for
|
||||
// repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
|
||||
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
|
||||
let capture = await this._fetchTerminalCapture(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`,
|
||||
: `/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`,
|
||||
{ full: useFullHistory }
|
||||
);
|
||||
let headersReceivedAt = capture.headersAt;
|
||||
@@ -3315,8 +3316,13 @@ class CodemanApp {
|
||||
try {
|
||||
// No-param capture: `terminalBufferMaxBytes` (32MB) is its only ceiling,
|
||||
// so it needs the full-history budget. Defaulting to the tail budget
|
||||
// gave the largest payload the smallest deadline.
|
||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`, { full: true });
|
||||
// gave the largest payload the smallest deadline. A shell reloads tmux's
|
||||
// rendered history instead (SHELL_LOAD_QUERY), never the byte recording.
|
||||
const shellSession = this.sessions.get(data.id)?.mode === 'shell';
|
||||
const capture = await this._fetchTerminalCapture(
|
||||
shellSession ? `/api/sessions/${data.id}/terminal?${SHELL_LOAD_QUERY}` : `/api/sessions/${data.id}/terminal`,
|
||||
{ full: !shellSession }
|
||||
);
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const termData = capture.json?.data ?? {};
|
||||
|
||||
@@ -8882,6 +8888,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,
|
||||
@@ -9378,8 +9386,10 @@ class CodemanApp {
|
||||
// TUI sessions still get one canonical full replay per page (COD-47/#205).
|
||||
// A shell can retain hundreds of thousands of plain scrollback lines, so
|
||||
// automatically replaying all of them makes tab selection scale with the
|
||||
// entire session. Load its bounded 1MB tail first; the existing truncation
|
||||
// banner action fetches ?full=1 when the user explicitly asks for it.
|
||||
// entire session. Load a bounded window of tmux's RENDERED history first
|
||||
// (SHELL_LOAD_QUERY: never the raw byte recording, whose repaints replay
|
||||
// wrong at another pane height); the existing truncation banner action
|
||||
// fetches ?full=1 when the user explicitly asks for it.
|
||||
//
|
||||
// A TUI pane whose last capture reported NO tmux scrollback (fullscreen
|
||||
// claude: it lives in the alternate screen and keeps its transcript itself)
|
||||
@@ -9396,17 +9406,21 @@ class CodemanApp {
|
||||
// switch.
|
||||
// A remote (ssh) or Docker session's local pane hosts a nested tmux client in the
|
||||
// alternate screen, so it reports 0 while its byte tail is real scrollback.
|
||||
const shellSession = session?.mode === 'shell';
|
||||
const paneKeepsNoHistory =
|
||||
!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;
|
||||
const useFullHistory =
|
||||
session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));
|
||||
const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const fetchStartedAt = performance.now();
|
||||
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
|
||||
let capture;
|
||||
try {
|
||||
capture = await this._fetchTerminalCapture(
|
||||
useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` : tailUrl,
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: shellSession
|
||||
? `/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`
|
||||
: tailUrl,
|
||||
{ full: useFullHistory }
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
@@ -116,6 +116,21 @@ const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / bro
|
||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
||||
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
||||
// A shell's automatic loads (tab select, drop recovery, a tile's load) read
|
||||
// tmux's RENDERED history, never the raw byte recording: that recording holds
|
||||
// full-screen repaints (an ssh'd tmux, less, a TUI) drawn for the pane size of
|
||||
// the moment, and replayed into a terminal of another height each repaint
|
||||
// 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. `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_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
|
||||
@@ -1888,7 +1903,7 @@ function formatHistoryBytes(bytes) {
|
||||
*
|
||||
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
|
||||
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
|
||||
* paneHistoryLines?: number|null}} state
|
||||
* paneHistoryLines?: number|null, historyLinesCut?: boolean}} state
|
||||
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
|
||||
*/
|
||||
function computeHistoryTruncationNotice(state = {}) {
|
||||
@@ -1922,10 +1937,17 @@ function computeHistoryTruncationNotice(state = {}) {
|
||||
}
|
||||
// Name what a pull can actually return when the server said: the byte gap
|
||||
// counts repaints and redraw bloat, and overstates it many times over.
|
||||
// Without that count, 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. 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';
|
||||
const more =
|
||||
historyLines !== null
|
||||
? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.`
|
||||
: `${formatHistoryBytes(dropped)} more may still be retained.`;
|
||||
: `${remainder} may still be retained.`;
|
||||
return {
|
||||
visible: true,
|
||||
message: `Showing the most recent ${shown} of this session. ${more}`,
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
* Android tablet, or a large foldable unfolded in landscape, reaches.
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
|
||||
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
|
||||
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
|
||||
@@ -770,8 +770,15 @@
|
||||
try {
|
||||
if (this._destroyed) return;
|
||||
const shell = this.sessionMode === 'shell';
|
||||
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
|
||||
// A shell loads tmux's rendered history, bounded (SHELL_LOAD_QUERY,
|
||||
// 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) {
|
||||
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;
|
||||
|
||||
@@ -3088,10 +3088,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
|
||||
@@ -3099,7 +3101,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
|
||||
@@ -3117,17 +3119,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
|
||||
@@ -3168,6 +3185,31 @@ export function registerSessionRoutes(
|
||||
let truncationReason: 'capped' | 'tail' | null = null;
|
||||
let cleanBuffer: string;
|
||||
|
||||
// A `lines=` bound shallower than tmux's history left the older lines in
|
||||
// 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 capturedHistoryLines = captureOpts.capturedHistoryLines;
|
||||
const historyLinesCut =
|
||||
isFullCapture &&
|
||||
captureOpts.historyLimitLines !== undefined &&
|
||||
captureOpts.historyLimitLines < tmuxHistoryLimit &&
|
||||
capturedHistoryLines !== undefined &&
|
||||
capturedHistoryLines > captureOpts.historyLimitLines;
|
||||
if (historyLinesCut) {
|
||||
truncated = true;
|
||||
truncationReason = 'tail';
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -3256,6 +3298,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,
|
||||
|
||||
Reference in new issue
Block a user