mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
fix(terminal): load a shell from tmux's rendered history, not the byte recording
A shell's automatic loads (tab select, dropped-output recovery, clear
terminal, a split/grid tile's load) replayed `session.terminalBuffer`,
the raw PTY recording, ahead of the visible frame. That recording holds
full-screen repaints (an ssh'd tmux, less, any TUI: cursor home, then the
rows joined by newlines, prompt on the last row) drawn for the pane
height of the moment. Replayed into a terminal with fewer rows, each
repaint's last newline scrolls and pushes its top row into scrollback,
so one line tmux holds once shows up dozens of times (26 copies in the
case that found this, after the pane went from 24 to 23 rows). Remote
SSH shell sessions hit it constantly: their inner tmux repaints often.
Shells now load a bounded window of tmux's rendered history instead
(`SHELL_LOAD_QUERY`: full=1&tail=1MiB&lines=10000), the same source the
scroll-to-top pull already used. `lines=10000` bounds the synchronous
capture-pane, measured at ~8 ms per 1000 lines (~0.1 s here vs ~0.8 s
for a 100k-line history). Because that bound is shallower than xterm's
scrollback, the server now reports a cut: the capture's existing
display-message query also reads `#{history_size}`
(`PaneCaptureOptions.capturedHistorySize`), and the route marks a full
capture `truncated` / 'tail' when tmux holds more than it read, so the
banner keeps offering Load full history and scroll-to-top keeps pulling
older lines. The byte cap still reports 'capped'. The banner no longer
names an amount it cannot know ("less than 1 KB more") for such a cut.
Agent sessions are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa3730aace
commit
b620e42ffa
14 files changed
+204
-30
No files matched your search
@@ -204,6 +204,13 @@ export interface PaneCaptureOptions {
|
||||
* rendering it needs the real height to know the frame fits.
|
||||
*/
|
||||
capturedGeometry?: { cols: number; rows: number };
|
||||
/**
|
||||
* Filled in by the implementation with the pane's scrollback depth (lines
|
||||
* above the screen) at capture time, when it can tell. A full-history
|
||||
* capture bounded by `historyLimitLines` below this depth left older lines
|
||||
* behind in the multiplexer, which the caller must report as recoverable.
|
||||
*/
|
||||
capturedHistorySize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-5
@@ -700,13 +700,17 @@ interface PaneCursorGeometry {
|
||||
rows: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
/** Lines of scrollback above the screen (`#{history_size}`), when tmux reported it. */
|
||||
historySize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pane's cursor and size, or null when tmux cannot say.
|
||||
*
|
||||
* Every field is validated together: a caller that gets a value back can place
|
||||
* a caret with it, and one that gets null must not try.
|
||||
* 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.
|
||||
*/
|
||||
export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
|
||||
let raw: string;
|
||||
@@ -716,7 +720,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] = raw.split(/\s+/).map((value) => parseInt(value, 10));
|
||||
const [cursorX, cursorY, cols, rows, historySize] = raw.split(/\s+/).map((value) => parseInt(value, 10));
|
||||
if (
|
||||
!Number.isFinite(cursorX) ||
|
||||
!Number.isFinite(cursorY) ||
|
||||
@@ -729,7 +733,9 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { cols, rows, cursorX, cursorY };
|
||||
const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY };
|
||||
if (Number.isFinite(historySize) && historySize >= 0) geometry.historySize = historySize;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** SGR attributes, which is all `capture-pane -e` emits. */
|
||||
@@ -3890,7 +3896,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}'`,
|
||||
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
)
|
||||
);
|
||||
@@ -3902,6 +3908,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// geometry is reported there for diagnosis rather than for repair. Only
|
||||
// the caller can see both sizes, so hand it this one.
|
||||
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows };
|
||||
// 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;
|
||||
|
||||
if (fullHistory) {
|
||||
// Without geometry there is no cursor move, so fall back to the old trim.
|
||||
|
||||
+22
-9
@@ -3203,15 +3203,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;
|
||||
@@ -3308,8 +3309,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 ?? {};
|
||||
|
||||
@@ -9317,16 +9323,23 @@ 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.
|
||||
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
|
||||
// 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.
|
||||
const shellSession = session?.mode === 'shell';
|
||||
const useFullHistory = !shellSession && !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,16 @@ 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.
|
||||
const SHELL_LOAD_HISTORY_LINES = 10000;
|
||||
const SHELL_LOAD_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&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
|
||||
@@ -1902,9 +1912,13 @@ function computeHistoryTruncationNotice(state = {}) {
|
||||
canLoadMore: false,
|
||||
};
|
||||
}
|
||||
// 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';
|
||||
return {
|
||||
visible: true,
|
||||
message: `Showing the most recent ${shown} of this session. ${formatHistoryBytes(dropped)} more may still be retained.`,
|
||||
message: `Showing the most recent ${shown} of this session. ${remainder} may still be retained.`,
|
||||
canLoadMore: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,7 +770,9 @@
|
||||
try {
|
||||
if (this._destroyed) return;
|
||||
const shell = this.sessionMode === 'shell';
|
||||
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||
// A shell loads tmux's rendered history, bounded (SHELL_LOAD_QUERY,
|
||||
// constants.js), never the raw byte recording.
|
||||
let query = shell ? SHELL_LOAD_QUERY : 'full=1';
|
||||
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
|
||||
this._loadAbort = controller;
|
||||
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000);
|
||||
|
||||
@@ -3160,6 +3160,21 @@ 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.
|
||||
const capturedHistorySize = captureOpts.capturedHistorySize;
|
||||
if (
|
||||
isFullCapture &&
|
||||
captureOpts.historyLimitLines !== undefined &&
|
||||
capturedHistorySize !== undefined &&
|
||||
capturedHistorySize > captureOpts.historyLimitLines
|
||||
) {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user