fix(terminal): preserve styling in snapshots and scroll-back (#2654)

The terminal broker's attach snapshot and scroll-back window now request tmux captures with escape sequences, so colour, bold and dim survive the first paint. The painter keeps tmux's carried styling across rows instead of resetting each row, resets once before clearing the screen and once after the last painted row, and replays the styling of rows above the first painted row when a height crop or the scroll-back window drops them. The captures feed only the websocket painter; activity, input-box and prompt detection keep their own plain captures. Reported by niklasbaldi in #2598.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-11-07 02:07:43 -08:00
1 parent 9b1d94d6f0
commit 0c57bdb38c
2 files changed
+93 -23

No files matched your search

@@ -71,11 +71,11 @@ export interface BrokerTmux {
stopPipePane(name: string): Promise<TmuxResult>;
sendKeys(name: string, keys: string[]): Promise<TmuxResult>;
sendText(name: string, text: string): Promise<TmuxResult>;
capturePaneScreen(name: string): Promise<string | null>;
capturePaneScreen(name: string, opts?: { escapeSequences?: boolean }): Promise<string | null>;
getPaneCursorPosition(name: string): Promise<TmuxCursorPosition | null>;
/** Capture the last `lines` lines INCLUDING scrollback history (tmux
* capture-pane -S -lines). Used for the per-subscriber scroll-back window. */
capturePaneContent(name: string, lines: number): Promise<string | null>;
capturePaneContent(name: string, lines: number, opts?: { escapeSequences?: boolean }): Promise<string | null>;
}
export interface BrokerOptions {
@@ -109,26 +109,25 @@ export function cursorPositionEscape(x: number, y: number): string {
* exact same rows tmux has it - never a relative append that drifts as content
* scrolls. Normalizes CRLF, drops one trailing print newline, and (when a pane
* height is known and the capture is taller) keeps only the last `height` rows.
* Lifted from the FR-4 seed work.
* SGR carries across captured rows. Replay skipped rows' SGR, and reset only at the paint boundaries.
*/
export function screenSnapshotEscape(
snapshot: string,
cursor: { x: number; y: number; height?: number } | null,
priorRows = "",
): string {
const normalized = snapshot.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const withoutTrailingPrintNewline = normalized.endsWith("\n")
? normalized.slice(0, -1)
: normalized;
const rows = withoutTrailingPrintNewline.split("\n");
const visibleRows = cursor?.height && rows.length > cursor.height
? rows.slice(rows.length - cursor.height)
: rows;
const paintedRows = visibleRows
const firstRow = cursor?.height ? Math.max(0, rows.length - cursor.height) : 0;
const priorStyle = `${priorRows}\n${rows.slice(0, firstRow).join("\n")}`.match(/\x1b\[[0-9;:]*m/g)?.join("") ?? "";
const paintedRows = rows.slice(firstRow)
.map((row, index) => `\x1b[${index + 1};1H${row}`)
.join("");
return `\x1b[2J${paintedRows}${cursor ? cursorPositionEscape(cursor.x, cursor.y) : "\x1b[H"}`;
return `\x1b[0m\x1b[2J${priorStyle}${paintedRows}\x1b[0m${cursor ? cursorPositionEscape(cursor.x, cursor.y) : "\x1b[H"}`;
}
/**
@@ -340,7 +339,7 @@ export class TerminalSessionBroker {
// the TOP `rows` would jump a whole extra screen up on the first wheel notch.
let content: string | null = null;
try {
content = await this.tmux.capturePaneContent(this.sessionName, clamped + this.rows);
content = await this.tmux.capturePaneContent(this.sessionName, clamped + this.rows, { escapeSequences: true });
} catch {
content = null;
}
@@ -350,11 +349,10 @@ export class TerminalSessionBroker {
// element is the true live-bottom row and the offset stays honest.
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
const bottom = lines.length - clamped; // exclusive end: the window sits `offset` up
const window = bottom <= this.rows
? lines.slice(0, this.rows) // scrolled at/past the top of history: oldest screenful
: lines.slice(bottom - this.rows, bottom);
const firstRow = Math.max(0, bottom - this.rows);
const window = lines.slice(firstRow, firstRow + this.rows);
try {
sub.send(screenSnapshotEscape(window.join("\n"), null));
sub.send(screenSnapshotEscape(window.join("\n"), null, lines.slice(0, firstRow).join("\n")));
} catch { /* dead subscriber */ }
}
@@ -362,7 +360,7 @@ export class TerminalSessionBroker {
private async repaintScreen(sub: TerminalSubscriber): Promise<void> {
let snapshot: string | null = null;
let cursor: TmuxCursorPosition | null = null;
try { snapshot = await this.tmux.capturePaneScreen(this.sessionName); } catch { snapshot = null; }
try { snapshot = await this.tmux.capturePaneScreen(this.sessionName, { escapeSequences: true }); } catch { snapshot = null; }
try { cursor = await this.tmux.getPaneCursorPosition(this.sessionName); } catch { cursor = null; }
if (snapshot !== null) {
try { sub.send(screenSnapshotEscape(snapshot, cursor)); } catch { /* dead */ }
@@ -475,7 +473,7 @@ export class TerminalSessionBroker {
let snapshot: string | null = null;
let cursor: TmuxCursorPosition | null = null;
try {
snapshot = await this.tmux.capturePaneScreen(this.sessionName);
snapshot = await this.tmux.capturePaneScreen(this.sessionName, { escapeSequences: true });
} catch {
snapshot = null;
}
@@ -64,7 +64,7 @@ describe("cursor-safe seed helpers", () => {
it("screenSnapshotEscape paints each row with an ABSOLUTE move (no row drift)", () => {
const out = screenSnapshotEscape("alpha\nbeta\ngamma", { x: 2, y: 1, height: 24 });
expect(out.startsWith("\x1b[2J")).toBe(true);
expect(out.startsWith("\x1b[0m\x1b[2J")).toBe(true);
expect(out).toContain("\x1b[1;1Halpha");
expect(out).toContain("\x1b[2;1Hbeta");
expect(out).toContain("\x1b[3;1Hgamma");
@@ -79,8 +79,25 @@ describe("cursor-safe seed helpers", () => {
expect(out).toContain("\x1b[2;1Hr5");
});
it("preserves dim, bold and colour across rows and resets only at paint boundaries", () => {
const out = screenSnapshotEscape("\x1b[2msuggestion\n\x1b[1;31mwarning\nplain", null);
expect(out).toBe("\x1b[0m\x1b[2J\x1b[1;1H\x1b[2msuggestion\x1b[2;1H\x1b[1;31mwarning\x1b[3;1Hplain\x1b[0m\x1b[H");
});
it.each(["\x1b[2m", "\x1b[44m"])("carries %j across a row with no SGR of its own", (sgr) => {
expect(screenSnapshotEscape(`${sgr}first\nsecond\x1b[0m`, null))
.toBe(`\x1b[0m\x1b[2J\x1b[1;1H${sgr}first\x1b[2;1Hsecond\x1b[0m\x1b[0m\x1b[H`);
});
it("replays cumulative dim, bold and colour when height cropping drops their rows", () => {
const out = screenSnapshotEscape("\x1b[2mold\n\x1b[1;31mmiddle\n\x1b[32mvisible\nlast\x1b[0m", { x: 0, y: 1, height: 2 });
expect(out).toBe("\x1b[0m\x1b[2J\x1b[2m\x1b[1;31m\x1b[1;1H\x1b[32mvisible\x1b[2;1Hlast\x1b[0m\x1b[0m\x1b[2;1H");
expect(out).not.toContain("old");
expect(out).not.toContain("middle");
});
it("normalizes CRLF and drops exactly one trailing newline; null cursor homes", () => {
expect(screenSnapshotEscape("a\r\nb\r\n", null)).toBe("\x1b[2J\x1b[1;1Ha\x1b[2;1Hb\x1b[H");
expect(screenSnapshotEscape("a\r\nb\r\n", null)).toBe("\x1b[0m\x1b[2J\x1b[1;1Ha\x1b[2;1Hb\x1b[0m\x1b[H");
});
});
@@ -261,7 +278,7 @@ describe("TerminalSessionBroker", () => {
await broker.attach(a);
expect(a.received[0]).toBe(screenSnapshotEscape("line one\nline two", { x: 3, y: 1, height: 40 }));
expect(a.received[0]!.startsWith("\x1b[2J")).toBe(true);
expect(a.received[0]!.startsWith("\x1b[0m\x1b[2J")).toBe(true);
});
it("test 8b: EACH subscriber gets its own seed (2nd subscriber seeded too, no shared pipe)", async () => {
@@ -284,7 +301,7 @@ describe("TerminalSessionBroker", () => {
const a = makeSub();
await broker.attach(a);
expect(capturePaneScreen).toHaveBeenCalledWith("dev@rig");
expect(capturePaneScreen).toHaveBeenCalledWith("dev@rig", { escapeSequences: true });
const seed = a.received[0]!;
expect(seed).toContain("\x1b[1;1Hr1");
expect(seed).toContain("\x1b[3;1Hr3");
@@ -653,6 +670,61 @@ describe("TerminalSessionBroker - teardown during seed (honest close)", () => {
});
});
// The adapter models capture-pane: it returns SGR only when the caller asks for -e.
describe("terminal snapshot styling (#2598)", () => {
const styled = "\x1b[2msuggestion\x1b[0m\nplain";
const captureScreen = async (_name: string, opts?: { escapeSequences?: boolean }) =>
opts?.escapeSequences ? styled : "suggestion\nplain";
it("keeps a dim suggestion on every attach", async () => {
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({
capturePaneScreen: captureScreen,
})));
for (const sub of [makeSub(), makeSub()]) {
await broker.attach(sub);
expect(sub.received[0]).toContain("\x1b[2msuggestion\x1b[0m");
}
});
it("keeps dim text in the selected scroll-back window", async () => {
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({
capturePaneContent: async (_name: string, _lines: number, opts?: { escapeSequences?: boolean }) =>
opts?.escapeSequences ? "\x1b[2mhistory\x1b[0m\nplain\nlive" : "history\nplain\nlive",
}), { rows: 2 }));
const sub = makeSub();
await broker.attach(sub);
await broker.scroll(sub, 1);
expect(sub.received.at(-1)).toContain("\x1b[1;1H\x1b[2mhistory\x1b[0m");
expect(sub.received.at(-1)).toContain("\x1b[2;1Hplain");
expect(sub.received.at(-1)).not.toContain("live");
});
it("replays dim from above the first visible scroll-back row", async () => {
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({
capturePaneContent: async () => "\x1b[2molder\ncontinued\nlast\x1b[0m\nlive\nbottom",
}), { rows: 2 }));
const sub = makeSub();
await broker.attach(sub);
await broker.scroll(sub, 2);
expect(sub.received.at(-1)).toBe("\x1b[0m\x1b[2J\x1b[2m\x1b[1;1Hcontinued\x1b[2;1Hlast\x1b[0m\x1b[0m\x1b[H");
expect(sub.received.at(-1)).not.toContain("older");
});
it("keeps a dim suggestion when returning from scroll-back to live", async () => {
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({
capturePaneScreen: captureScreen,
capturePaneContent: async () => "history\nplain\nlive",
}), { rows: 2 }));
const sub = makeSub();
await broker.attach(sub);
await broker.scroll(sub, 1);
const beforeReturn = sub.received.length;
await broker.scroll(sub, 0);
expect(sub.received.length).toBe(beforeReturn + 1);
expect(sub.received.at(-1)).toContain("\x1b[2msuggestion\x1b[0m");
});
});
// ---- OPR.0.4.0.39 per-subscriber scroll-back (tmux capture-pane window) ------
// The live xterm screen is only the current `rows`; scrolling UP must show tmux
// SCROLLBACK. Because the broker fans ONE pipe out to many viewers, scroll-back is
@@ -684,10 +756,10 @@ describe("TerminalSessionBroker - per-subscriber scroll-back (OPR.0.4.0.39)", ()
// Captures (offset + rows) = 3 + 3 = 6 lines back; the painted window is `rows` (3)
// lines ending `offset` (3) ABOVE the live bottom: bottom row = L200 - 3 = L197,
// so the window is L195..L197 (NOT the older top of the capture, NOT the live tail).
expect(capturePaneContent).toHaveBeenCalledWith("dev@rig", 6);
expect(capturePaneContent).toHaveBeenCalledWith("dev@rig", 6, { escapeSequences: true });
expect(a.received.length).toBe(aBefore + 1);
const painted = a.received[a.received.length - 1]!;
expect(painted.startsWith("\x1b[2J")).toBe(true);
expect(painted.startsWith("\x1b[0m\x1b[2J")).toBe(true);
expect(painted).toContain("\x1b[1;1HL195");
expect(painted).toContain("\x1b[2;1HL196");
expect(painted).toContain("\x1b[3;1HL197");
@@ -734,7 +806,7 @@ describe("TerminalSessionBroker - per-subscriber scroll-back (OPR.0.4.0.39)", ()
await broker.scroll(a, 0); // back to the live bottom
expect(capturePaneScreen).toHaveBeenCalledWith("dev@rig");
expect(capturePaneScreen).toHaveBeenCalledWith("dev@rig", { escapeSequences: true });
expect(a.received.length).toBe(beforeReturn + 1);
expect(a.received[a.received.length - 1]!).toContain("LIVE SCREEN");