fix(terminal): stabilize live xterm repaint

This commit is contained in:
mvschwarz committed 2026-06-21 18:04:01 -07:00
1 parent 50ab9b29aa
commit cba0a1824a
6 files changed
+93 -36

No files matched your search

@@ -115,6 +115,28 @@ export function screenSnapshotEscape(
return `\x1b[2J${paintedRows}${cursor ? cursorPositionEscape(cursor.x, cursor.y) : "\x1b[H"}`;
}
/**
* Raw pipe output from a full-screen TUI is a repaint stream, not durable
* scrollback. Replaying cursor-addressed history into a fresh xterm before the
* current snapshot causes stale prompt/status rows to appear above or under the
* real screen. Preserve the AC-5 shared-history behavior for plain line output
* (and simple SGR color), but skip history that contains cursor movement,
* erase, alternate-screen, OSC, or carriage-return repaint semantics.
*/
export function isSafeHistoryReplay(data: string): boolean {
if (!data) return false;
if (/\r(?!\n)/.test(data)) return false;
const escapePattern = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[\(\)][ -~]|[@-Z\\-_])/g;
let match: RegExpExecArray | null;
while ((match = escapePattern.exec(data)) !== null) {
const sequence = match[0];
const final = sequence.at(-1);
if (final !== "m") return false;
}
return true;
}
/**
* One broker per live tmux session. Owns a single pipe-pane and fans its output
* out to every attached subscriber.
@@ -319,13 +341,14 @@ export class TerminalSessionBroker {
private async seed(sub: TerminalSubscriber): Promise<void> {
// AC-5: replay the broker-owned recent-output ring FIRST so the late
// subscriber's xterm builds the same scrollback the earlier subscribers
// have (raw bytes write into xterm's scrollback). The visible-screen seed
// then paints a cursor-correct current screen on top - screenSnapshotEscape
// opens with ESC[2J, which clears only the VISIBLE viewport, NOT the
// scrollback, so the recent scrolled-off output is preserved above. The
// first subscriber's ring is empty, so it gets only the visible seed.
// have for plain terminal streams. Cursor-addressed TUI repaint history is
// not scrollback; replaying it corrupts late subscribers, so those sessions
// seed from the current visible-screen snapshot only.
if (this.historyBytes > 0) {
try { sub.send(this.history.join("")); } catch { /* dead subscriber */ }
const history = this.history.join("");
if (isSafeHistoryReplay(history)) {
try { sub.send(history); } catch { /* dead subscriber */ }
}
}
// Best-effort: a failed capture (or an adapter without the seed methods)
// must never break the attach - the tail still streams live output.
@@ -438,6 +438,31 @@ describe("TerminalSessionBroker - shared history ring (AC-5)", () => {
expect(startPipePane).toHaveBeenCalledOnce();
});
it("a LATE subscriber skips unsafe TUI repaint history and receives the current screen snapshot", async () => {
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({
capturePaneScreen: async () => "CURRENT SCREEN",
getPaneCursorPosition: async () => ({ x: 0, y: 0, width: 120, height: 40 }),
}), { pollMs: 10 }));
const a = makeSub();
await broker.attach(a);
fs.appendFileSync(
broker.pipeOutputPath!,
"\x1b[2J\x1b[12;1HSTALE TUI PROMPT\x1b[13;1Hoverpainted status",
);
await vi.waitFor(() => {
expect(a.received.join("")).toContain("STALE TUI PROMPT");
}, { timeout: 1000 });
const b = makeSub();
await broker.attach(b);
const bSeed = b.received.join("");
expect(bSeed).not.toContain("STALE TUI PROMPT");
expect(bSeed).not.toContain("overpainted status");
expect(bSeed).toContain("CURRENT SCREEN");
});
it("the history ring is bounded under sustained output", async () => {
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux(), { pollMs: 5, maxHistoryBytes: 2048 }));
await broker.attach(makeSub());
@@ -33,7 +33,7 @@ const ESCAPE_SEQ_MAP: Record<string, string> = {
"\x1b[2~": "IC",
};
const SMOKED_TERMINAL_BACKGROUND = "rgba(12,10,9,0.6)";
const LIVE_TERMINAL_RENDER_BACKGROUND = "#0c0a09";
type WsMessage = { type: "keys"; keys: string[] } | { type: "text"; text: string };
@@ -79,6 +79,19 @@ export function mapXtermInput(data: string): WsMessage[] {
return messages;
}
export function applyOpaqueTerminalBackground(container: HTMLElement): void {
const surfaces = [
container,
container.querySelector<HTMLElement>(".xterm"),
container.querySelector<HTMLElement>(".xterm-screen"),
container.querySelector<HTMLElement>(".xterm-viewport"),
container.querySelector<HTMLElement>(".xterm-rows"),
];
for (const surface of surfaces) {
if (surface) surface.style.backgroundColor = LIVE_TERMINAL_RENDER_BACKGROUND;
}
}
interface FocusedTerminalProps {
sessionName: string;
daemonBaseUrl?: string;
@@ -165,28 +178,22 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
cursorBlink: true,
fontSize: 12,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
// OPR.0.4.0.1 styling polish (FR-1): the live terminal CONTENT carries
// its OWN translucent smoked-black tint (stone-950 #0c0a09 at ~0.6 alpha)
// so it reads as a floating smoked-glass plate on EVERY surface -- incl.
// the truly-bare topology-tab + grid-popover surfaces that have no plate
// behind them -- not only over the popover/shell backdrop-blur. Foreground
// stays OPAQUE (#e0e0e0) so text is fully legible (AC-4 hard constraint);
// alpha is the starting point, tuned toward opaque by QA if a busy backdrop
// ever fights crispness. allowTransparency is required for a non-opaque bg.
theme: { background: SMOKED_TERMINAL_BACKGROUND, foreground: "#e0e0e0", cursor: "#e0e0e0" },
allowTransparency: true,
// xterm erase/redraw needs an opaque cell background. A translucent
// xterm render surface lets old TUI cells bleed through after clear
// screen / absolute cursor repaint, which corrupts Claude/Codex views.
theme: { background: LIVE_TERMINAL_RENDER_BACKGROUND, foreground: "#e0e0e0", cursor: "#e0e0e0" },
allowTransparency: false,
allowProposedApi: true,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(containerRef.current!);
// xterm's viewport keeps its own default black background outside the
// theme-painted row layer. Keep it aligned with the smoked live content
// so live terminals do not regress to an opaque black panel.
const viewport = containerRef.current!.querySelector<HTMLElement>(".xterm-viewport");
if (viewport) viewport.style.backgroundColor = SMOKED_TERMINAL_BACKGROUND;
// Some xterm DOM layers do not inherit the theme background. Pin every
// render layer opaque so clear/erase operations actually erase.
applyOpaqueTerminalBackground(containerRef.current!);
fitAddon.fit();
term.focus();
termRef.current = term;
fitAddonRef.current = fitAddon;
@@ -243,7 +250,7 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
key={`focused-terminal-live-${sessionName}`}
ref={containerRef}
data-testid={`focused-terminal-${sessionName}`}
className="h-full w-full min-h-[200px]"
className="h-full w-full min-h-[200px] bg-stone-950/60 backdrop-blur-sm"
/>
);
}
@@ -222,11 +222,10 @@ export function TerminalPreviewPopover({
data-terminal-preview-surface=""
data-reduced-motion={reducedMotion ? "true" : "false"}
className={cn(
// OPR.0.4.0.1 (FR-4 de-dup): the terminal CONTENT now carries its own
// smoked-glass tint (FocusedTerminal bg / the static smoked plate), so the
// popover drops its redundant opaque smoked background to stay
// transparent-glassy (not double-tinted toward opaque). The backdrop-blur
// stays for the floating-plate effect over the page behind it.
// OPR.0.4.0.1 (FR-4 de-dup): the terminal wrapper/static plate carry the
// smoked-glass surface, while the xterm renderer itself stays opaque for
// reliable erase/redraw. The popover drops its redundant bg so the wrapper
// remains the single terminal plate.
"nodrag nopan fixed z-[1000] max-h-[calc(100vh-1rem)] max-w-[calc(100vw-1rem)] overflow-hidden p-1.5 backdrop-blur-sm",
// OPR.0.4.0.1 (rev1-r2 fix): widen the shell to fit the live plate (880px +
// padding) when live so overflow-hidden no longer clips it; stay compact for
@@ -211,9 +211,9 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(driverPopover.className).toContain("max-w-[calc(100vw-1rem)]");
expect(driverPopover.className).toContain("max-h-[calc(100vh-1rem)]");
expect(driverPopover.className).toContain("overflow-hidden");
// OPR.0.4.0.1 (FR-4): the popover dropped its redundant bg-stone-950/65 -- the
// terminal CONTENT now carries its own smoked-glass tint, so the popover stays
// transparent-glassy (backdrop-blur retained) instead of double-tinted opaque.
// OPR.0.4.0.1 (FR-4): the popover dropped its redundant bg-stone-950/65; the
// live terminal wrapper supplies the smoked plate while xterm's renderer stays
// opaque enough for reliable erase/redraw.
expect(driverPopover.className).not.toContain("bg-stone-950/65");
expect(driverPopover.className).toContain("font-mono");
expect(driverPopover.className).toContain("text-[8px]");
@@ -49,22 +49,25 @@ describe("OPR.0.4.0.1 terminal styling polish", () => {
expect(screen.getByTestId("preview-a@r").getAttribute("data-variant")).toBe("compact-terminal");
});
it("FR-1: the LIVE terminal CONTENT carries its own translucent smoked tint (not the plate-dependent rgba(0,0,0,0))", () => {
it("FR-1: the LIVE terminal keeps an opaque xterm render surface so erase/redraw is cursor-safe", () => {
const s = src("../src/components/terminal/FocusedTerminal.tsx");
expect(s).toContain('const SMOKED_TERMINAL_BACKGROUND = "rgba(12,10,9,0.6)"'); // stone-950 at ~0.6 alpha
expect(s).toContain("viewport.style.backgroundColor = SMOKED_TERMINAL_BACKGROUND");
expect(s).toContain('const LIVE_TERMINAL_RENDER_BACKGROUND = "#0c0a09"');
expect(s).toContain("applyOpaqueTerminalBackground(containerRef.current!)");
expect(s).toContain("term.focus();");
expect(s).toContain("allowTransparency: false");
expect(s).not.toContain("allowTransparency: true");
expect(s).not.toContain('background: "rgba(0,0,0,0)"');
expect(s).toContain('foreground: "#e0e0e0"'); // text stays OPAQUE (AC-4 legibility)
});
it("FR-3/FR-4: the popover sets the measured LIVE width + drops its redundant opaque bg (content self-tints)", () => {
it("FR-3/FR-4: the popover sets the measured LIVE width + drops its redundant opaque bg", () => {
const s = src("../src/components/topology/TerminalPreviewPopover.tsx");
expect(s).toContain("w-[880px]"); // the LIVE inner sizer (optimal width)
// OPR.0.4.0.1 (rev1-r2 fix): the shell widens to fit the 880px live plate so
// overflow-hidden no longer clips it (the prior fixed compact shell did).
expect(s).toContain("w-[904px]");
expect(s).not.toContain("w-[820px]");
expect(s).not.toContain("bg-stone-950/65"); // dropped -> transparent-glassy
expect(s).not.toContain("bg-stone-950/65"); // dropped -> live wrapper supplies the plate
expect(s).toContain("backdrop-blur-sm");
});