mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(remote): keep the remote screen rows above the local frame in a remote history load
The remote history and the local frame are read a round trip apart, so two seams dropped rows at the join: rows that scrolled off the screen during the round trip, and the top of a remote screen taller than this pane (a shared session sized by a taller client under window-size latest). Both are rows of the remote screen above where the local frame begins. The remote script now also captures the screen, right after the history, in the same round trip. joinScrollbackAndFrame() places the local frame among the screen rows (findFrameInScreen(): text compare with escape sequences and trailing blanks ignored, the screen's trailing blank rows and last content row left out, at least three rows of overlap with two of them non-blank, highest match wins) and keeps the screen rows above it between the history and the frame. When the frame cannot be placed, the join is history over frame as before. The read's buffer slack grows to 1 MiB, since the screen is outside the tail -c bound. Left: the history and screen are two tmux commands, so a row that scrolls between them (milliseconds) is lost, and a screen that repeats a whole block can place the frame at the higher copy, losing the rows between the copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
136f11549b
commit
28b41efa58
7 files changed
+267
-42
No files matched your search
@@ -196,6 +196,14 @@ export interface PaneCaptureOptions {
|
||||
* session's real history lives in the REMOTE tmux (`remote-pane-history.ts`).
|
||||
*/
|
||||
scrollbackOverride?: string;
|
||||
/**
|
||||
* With `scrollbackOverride`: the screen rows of the pane that history came
|
||||
* from, read right after it. The frame this pane captures later can have
|
||||
* scrolled past some of them, or show only part of a taller screen, so the
|
||||
* rows above where the frame's top overlaps them are kept between the
|
||||
* scrollback and the frame (`joinScrollbackAndFrame`).
|
||||
*/
|
||||
scrollbackOverrideScreen?: string;
|
||||
/**
|
||||
* Byte cap the consumer will keep from the capture. Sizes the child-process
|
||||
* stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by
|
||||
|
||||
+40
-15
@@ -11,9 +11,14 @@
|
||||
* the local pane is what the live stream keeps drawing into, so its frame and caret
|
||||
* position are the ones the browser must end up with.
|
||||
*
|
||||
* ONE ssh round trip prints the remote pane's `#{history_size}` and its scrollback
|
||||
* rows (`capture-pane -S -<N> -E -1`). The size is load-bearing: with no history at
|
||||
* all, tmux clamps `-E -1` to the first VISIBLE row and would return it as history.
|
||||
* ONE ssh round trip prints the remote pane's `#{history_size}`, its scrollback
|
||||
* rows (`capture-pane -S -<N> -E -1`) and then its screen. The size is load-bearing:
|
||||
* with no history at all, tmux clamps `-E -1` to the first VISIBLE row and would
|
||||
* return it as history. The screen closes the seam between the two captures: the
|
||||
* local frame is read only after the round trip, so rows that scrolled in between,
|
||||
* and the top of a remote screen taller than the local pane, are on that screen
|
||||
* alone, and the caller keeps the screen rows above where the local frame overlaps
|
||||
* it (`joinScrollbackAndFrame`).
|
||||
*
|
||||
* Bounded and fail-soft. Any failure (unreachable host, auth, missing session, odd
|
||||
* output) returns null and the caller keeps the local capture, so a remote load is
|
||||
@@ -53,8 +58,12 @@ const REMOTE_HISTORY_SLOT_WAIT_MS = 1_500;
|
||||
/** After a failed read, skip that host for this long and use the local capture. */
|
||||
const REMOTE_HISTORY_BACKOFF_MS = 30_000;
|
||||
|
||||
/** Headroom over the remote `tail -c` bound for the size line and markers. */
|
||||
const REMOTE_HISTORY_SLACK_BYTES = 64 * 1024;
|
||||
/**
|
||||
* Headroom over the remote `tail -c` bound for the size line, the markers and
|
||||
* the screen capture, which the bound does not cover (a wide, tall screen of
|
||||
* colored rows runs to a few hundred KB).
|
||||
*/
|
||||
const REMOTE_HISTORY_SLACK_BYTES = 1024 * 1024;
|
||||
|
||||
/** Read ceiling when the configured byte cap is 0 (unbounded) and no `tail -c` runs. */
|
||||
const UNBOUNDED_READ_MAX_BYTES = 64 * 1024 * 1024;
|
||||
@@ -86,9 +95,13 @@ export function buildRemoteScrollbackScript(
|
||||
// the remote side (`tail -c`) rather than by killing the read at maxBuffer.
|
||||
// A cap of 0 means unbounded, as in the route.
|
||||
const bound = maxBytes > 0 ? ` | tail -c ${Math.trunc(maxBytes)}` : '';
|
||||
// The screen comes right after the history, in the same round trip: the
|
||||
// local frame is captured only once this returns, so rows that scroll off
|
||||
// in between are on this screen and nowhere else (joinScrollbackAndFrame).
|
||||
return (
|
||||
`printf '\\0' && ${tmux} display-message -p -t ${target} '#{history_size}' && printf '\\0' && ` +
|
||||
`${tmux} capture-pane -p -e -J -S -${lines} -E -1 -t ${target}${bound}`
|
||||
`${tmux} capture-pane -p -e -J -S -${lines} -E -1 -t ${target}${bound} && printf '\\0' && ` +
|
||||
`${tmux} capture-pane -p -e -J -t ${target}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,19 +128,31 @@ export function dropPartialFirstRow(scrollback: string, maxBytes: number): strin
|
||||
return newline === -1 ? '' : scrollback.slice(newline + 1);
|
||||
}
|
||||
|
||||
/** What one round trip read from the remote pane: its scrollback rows, then its screen. */
|
||||
export interface RemoteScrollback {
|
||||
/** History rows above the screen, oldest first; '' when the pane has none. */
|
||||
scrollback: string;
|
||||
/** The screen's rows, read right after the scrollback. */
|
||||
screen: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scrollback rows from the script's stdout, '' when the pane has no history,
|
||||
* or null when the output is not the script's.
|
||||
* The scrollback and screen rows from the script's stdout, or null when the
|
||||
* output is not the script's.
|
||||
*/
|
||||
export function parseRemoteScrollbackOutput(stdout: string): string | null {
|
||||
export function parseRemoteScrollbackOutput(stdout: string): RemoteScrollback | null {
|
||||
const start = stdout.indexOf('\0');
|
||||
if (start === -1) return null;
|
||||
const sizeEnd = stdout.indexOf('\0', start + 1);
|
||||
if (sizeEnd === -1) return null;
|
||||
const historyEnd = stdout.indexOf('\0', sizeEnd + 1);
|
||||
if (historyEnd === -1) return null;
|
||||
const sizeText = stdout.slice(start + 1, sizeEnd).trim();
|
||||
if (!/^\d+$/.test(sizeText)) return null;
|
||||
if (Number(sizeText) === 0) return '';
|
||||
return stdout.slice(sizeEnd + 1);
|
||||
const screen = stdout.slice(historyEnd + 1);
|
||||
// With no history at all, tmux clamps `-E -1` to the first visible row.
|
||||
if (Number(sizeText) === 0) return { scrollback: '', screen };
|
||||
return { scrollback: stdout.slice(sizeEnd + 1, historyEnd), screen };
|
||||
}
|
||||
|
||||
/** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */
|
||||
@@ -157,7 +182,7 @@ export async function fetchRemoteScrollback(
|
||||
historyLines: number,
|
||||
maxBytes: number,
|
||||
run?: RemoteScrollbackRunner
|
||||
): Promise<string | null> {
|
||||
): Promise<RemoteScrollback | null> {
|
||||
if (!run && process.env.VITEST) return null;
|
||||
const runner = run ?? execRunner;
|
||||
const until = backoffUntil.get(remote.hostId);
|
||||
@@ -177,8 +202,8 @@ export async function fetchRemoteScrollback(
|
||||
// is the better answer than waiting behind them.
|
||||
{ maxWaitMs: REMOTE_HISTORY_SLOT_WAIT_MS }
|
||||
);
|
||||
const scrollback = parseRemoteScrollbackOutput(stdout);
|
||||
if (scrollback === null) {
|
||||
const parsed = parseRemoteScrollbackOutput(stdout);
|
||||
if (parsed === null) {
|
||||
// Both markers arrived, so the script ran on the host and only tmux had
|
||||
// nothing to say: no such session. The `| tail -c` pipe hides tmux's
|
||||
// exit status, so this comes back as exit 0 rather than exit 1, and it
|
||||
@@ -190,7 +215,7 @@ export async function fetchRemoteScrollback(
|
||||
console.warn(`[remote-history] ${remote.label}: ${what}; using the local capture`);
|
||||
return null;
|
||||
}
|
||||
return dropPartialFirstRow(scrollback, maxBytes);
|
||||
return { scrollback: dropPartialFirstRow(parsed.scrollback, maxBytes), screen: parsed.screen };
|
||||
} catch (err) {
|
||||
// A busy pool says nothing about the host.
|
||||
if (err instanceof RemoteSshSlotTimeoutError) return null;
|
||||
|
||||
+76
-4
@@ -694,14 +694,85 @@ export function normalizeScrollbackEol(buffer: string): string {
|
||||
return buffer.replace(/\r?\n/g, '\r\n');
|
||||
}
|
||||
|
||||
/** `capture-pane -p` text as rows: tmux ends it with one `\n`, and '' has none. */
|
||||
function captureRows(text: string): string[] {
|
||||
return text === '' ? [] : text.replace(/\n$/, '').split('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Least overlap that places a frame in a screen: rows compared, and how many
|
||||
* of them hold text. A placement too LOW repeats the rows between the true one
|
||||
* and it, so a short overlap (one `$` or `❯` row, a rule line) is not trusted:
|
||||
* past this, the plain join, which can only lose rows, is the safer answer.
|
||||
*/
|
||||
const FRAME_MATCH_MIN_ROWS = 3;
|
||||
const FRAME_MATCH_MIN_CONTENT_ROWS = 2;
|
||||
|
||||
/**
|
||||
* A captured row as plain text: escape sequences (SGR, OSC 8 links) and
|
||||
* trailing blanks dropped. Two tmuxes render the same cell content, but need
|
||||
* not encode its attributes the same way.
|
||||
*/
|
||||
function capturedRowText(row: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return row.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g, '').trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a later frame of the same pane begins among the rows of an earlier
|
||||
* screen capture, or -1 when that cannot be told.
|
||||
*
|
||||
* The frame's top rows must equal the screen's rows from that index on, as
|
||||
* text. Two rows are left out of the comparison: the screen's trailing blank
|
||||
* rows (output after the screen read fills them in the frame) and its last
|
||||
* content row (output in flight may have finished it since). Every compared
|
||||
* row must match, the overlap must reach FRAME_MATCH_MIN_ROWS rows with
|
||||
* FRAME_MATCH_MIN_CONTENT_ROWS of them non-blank, and the SMALLEST index wins,
|
||||
* so a block that merely repeats further down is only chosen when nothing
|
||||
* above it matches. A row that changed between the two reads (a spinner, a
|
||||
* clock) fails the true placement; the minimum keeps a lower one from being
|
||||
* taken on a row or two that happen to be equal.
|
||||
*/
|
||||
export function findFrameInScreen(screenRows: string[], frameRows: string[]): number {
|
||||
const screen = screenRows.map(capturedRowText);
|
||||
const frame = frameRows.map(capturedRowText);
|
||||
let lastContent = screen.length - 1;
|
||||
while (lastContent >= 0 && screen[lastContent] === '') lastContent--;
|
||||
for (let start = 0; start < lastContent; start++) {
|
||||
const overlap = Math.min(lastContent - start, frame.length);
|
||||
if (overlap < FRAME_MATCH_MIN_ROWS) break; // only shrinks from here on
|
||||
let matches = true;
|
||||
let contentRows = 0;
|
||||
for (let k = 0; k < overlap && matches; k++) {
|
||||
if (screen[start + k] !== frame[k]) matches = false;
|
||||
else if (frame[k] !== '') contentRows++;
|
||||
}
|
||||
if (matches && contentRows >= FRAME_MATCH_MIN_CONTENT_ROWS) return start;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put scrollback rows read elsewhere (`PaneCaptureOptions.scrollbackOverride`)
|
||||
* above a visible-frame capture, as one capture in tmux's own shape: rows joined
|
||||
* by a bare `\n`. Undefined or empty scrollback leaves the frame as it is.
|
||||
*
|
||||
* `screen` is that other pane's screen, read right after its scrollback. The
|
||||
* frame is captured later, so rows can have scrolled out of it in between, and
|
||||
* a frame showing part of a taller screen starts below that screen's top.
|
||||
* Either way the screen rows above the frame's first row are in neither the
|
||||
* scrollback nor the frame, so they go between the two. When the frame cannot
|
||||
* be placed in the screen ({@link findFrameInScreen}), the join is the plain
|
||||
* scrollback-over-frame one.
|
||||
*/
|
||||
export function joinScrollbackAndFrame(scrollback: string | undefined, frame: string): string {
|
||||
if (!scrollback) return frame;
|
||||
return `${scrollback.replace(/\n$/, '')}\n${frame}`;
|
||||
export function joinScrollbackAndFrame(scrollback: string | undefined, frame: string, screen?: string): string {
|
||||
let above = scrollback ? captureRows(scrollback) : [];
|
||||
if (screen) {
|
||||
const screenRows = captureRows(screen);
|
||||
const frameStart = findFrameInScreen(screenRows, captureRows(frame));
|
||||
if (frameStart > 0) above = above.concat(screenRows.slice(0, frameStart));
|
||||
}
|
||||
return above.length > 0 ? `${above.join('\n')}\n${frame}` : frame;
|
||||
}
|
||||
|
||||
/** Pane geometry and caret position, as `display-message` reports them. */
|
||||
@@ -3915,7 +3986,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
const rawCapture = joinScrollbackAndFrame(
|
||||
scrollbackOverride,
|
||||
execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts)
|
||||
execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts),
|
||||
scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined
|
||||
);
|
||||
// Query the cursor BEFORE deciding anything else. On the full-history path
|
||||
// it settles both how the capture is trimmed and whether a cursor move is
|
||||
|
||||
@@ -3128,7 +3128,10 @@ export function registerSessionRoutes(
|
||||
captureOpts.historyLimitLines,
|
||||
terminalBufferMaxBytes
|
||||
);
|
||||
if (remoteScrollback !== null) captureOpts.scrollbackOverride = remoteScrollback;
|
||||
if (remoteScrollback !== null) {
|
||||
captureOpts.scrollbackOverride = remoteScrollback.scrollback;
|
||||
captureOpts.scrollbackOverrideScreen = remoteScrollback.screen;
|
||||
}
|
||||
}
|
||||
const liveMuxBuffer =
|
||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||
|
||||
Reference in new issue
Block a user