mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
fix(terminal): stabilize live xterm repaint
This commit is contained in:
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");
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user