Merge OPR.0.4.0.39 unified static/live terminal component (grid in-place + graph/table popover, 90x27, fontSize selection, per-subscriber scrollback)

This commit is contained in:
mvschwarz committed 2026-06-22 03:22:29 -07:00
commit e9d3f1515f
22 files changed
+985 -196

No files matched your search

@@ -105,6 +105,11 @@ export function registerTerminalWs(
await broker.input({ type: "keys", keys: msg.keys as string[] });
} else if (msg.type === "text" && typeof msg.text === "string") {
await broker.input({ type: "text", text: msg.text });
} else if (msg.type === "scroll" && typeof msg.offset === "number") {
// OPR.0.4.0.39: per-subscriber scroll-back (tmux capture-pane window).
// offset = lines above the live bottom; 0 = live. Per-connection so each
// viewer scrolls independently (read-only on the shared pane).
if (subscriber) await broker.scroll(subscriber, msg.offset);
}
// FR-7: there is intentionally NO resize branch. The broker owns the
// fixed canonical geometry; a client-driven resize is ignored so
@@ -31,14 +31,18 @@ const DEFAULT_LIVENESS_MS = 2000;
const MAX_HISTORY_BYTES = 64 * 1024;
/**
* Canonical fixed terminal geometry (FR-7). 120 cols is the styling slice's
* measured live-plate width (~880px at fontSize 12, TerminalPreviewPopover).
* 40 rows is a comfortable agent-TUI working area; subscribers fit/scroll/pan
* their viewport but never resize the pane - so multiple viewers cannot shrink
* the session to the smallest one.
* Canonical fixed terminal geometry (FR-7). 90 cols (OPR.0.4.0.39, founder-directed):
* Claude Code (Ink) + Codex CLI are RESPONSIVE TUIs that reflow to whatever width they
* are given - they have no required width; 80 is the legacy fallback that users find
* too narrow, so 90 sits comfortably above the 80 floor while being narrower than 120 so
* the scaled static/live mirror reads bigger (more legible) in the topology grid cells.
* 27 rows gives the classic-terminal 1.72:1 landscape shape (90x27 = ~650x378px, matching the canonical 80x24 aspect) (founder: classic terminal rectangle) while staying a workable agent-TUI height; subscribers fit/scroll/pan their
* viewport but never resize the pane - so multiple viewers cannot shrink the session to
* the smallest one. MUST stay in sync with the client mirror LIVE_TERMINAL_COLS
* (packages/ui/.../terminal/terminal-geometry.ts) - the xterm grid must match the pane.
*/
export const CANONICAL_COLS = 120;
export const CANONICAL_ROWS = 40;
export const CANONICAL_COLS = 90;
export const CANONICAL_ROWS = 27;
/** A connected viewer of one broker. The route adapts a WebSocket to this. */
export interface TerminalSubscriber {
@@ -60,6 +64,9 @@ export interface BrokerTmux {
sendText(name: string, text: string): Promise<TmuxResult>;
capturePaneScreen(name: string): 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>;
}
export interface BrokerOptions {
@@ -67,9 +74,9 @@ export interface BrokerOptions {
pollMs?: number;
/** Session-liveness probe interval (ms). Default 2000. */
livenessMs?: number;
/** Canonical pane width. Default 120. */
/** Canonical pane width. Default CANONICAL_COLS (90). */
cols?: number;
/** Canonical pane height. Default 40. */
/** Canonical pane height. Default CANONICAL_ROWS (27). */
rows?: number;
/** Bounded size of the recent-output history ring in bytes. Default 64KB. */
maxHistoryBytes?: number;
@@ -152,6 +159,12 @@ export class TerminalSessionBroker {
private readonly onEmpty?: (sessionName: string) => void;
private readonly subscribers = new Set<TerminalSubscriber>();
// OPR.0.4.0.39: per-subscriber scroll-back offset (lines above the live bottom).
// 0/absent = live. A scrolled subscriber is PAINTED a tmux history window and is
// SKIPPED by the live fanout (so live output doesn't yank it back); read-only on
// the pane (capture-pane), so every viewer scrolls independently and nobody else's
// live view is disturbed (multi-subscriber-safe scrollback - vs pane-global copy-mode).
private readonly scrollOffsets = new Map<TerminalSubscriber, number>();
// Broker-owned recent-output ring (AC-5): raw fanned-out bytes, bounded,
// replayed to late subscribers so their scrollback matches the earlier ones.
private history: string[] = [];
@@ -277,12 +290,66 @@ export class TerminalSessionBroker {
});
}
/**
* OPR.0.4.0.39: per-subscriber scroll-back. `offset` = lines above the live
* bottom (0 = live). Reads tmux scrollback via capture-pane (READ-ONLY on the
* pane, so it never disturbs the live view of OTHER subscribers) and paints the
* windowed history to THIS subscriber. At offset 0 it repaints the current screen
* and the subscriber rejoins the live fanout.
*/
async scroll(sub: TerminalSubscriber, offset: number): Promise<void> {
if (this.torndown || !this.subscribers.has(sub)) return;
const clamped = Math.max(0, Math.floor(offset));
if (clamped === 0) {
this.scrollOffsets.delete(sub);
await this.repaintScreen(sub);
return;
}
this.scrollOffsets.set(sub, clamped);
// tmux `capture-pane -p -S -(offset+rows)` returns a buffer that ENDS at the live
// bottom (verified against real tmux: `-S -N` returns ~N history lines above the
// visible screen PLUS the screen, ending at the bottom row). To show a window
// `offset` lines ABOVE the live bottom, the slice must be BOTTOM-anchored: drop
// the last `offset` lines (toward live) and take the `rows` above them. Slicing
// 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);
} catch {
content = null;
}
if (content === null) return;
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
// Drop a single trailing empty line (capture-pane's trailing newline) so the last
// 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);
try {
sub.send(screenSnapshotEscape(window.join("\n"), null));
} catch { /* dead subscriber */ }
}
/** Repaint the current visible screen to ONE subscriber (scroll-back to live). */
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 { cursor = await this.tmux.getPaneCursorPosition(this.sessionName); } catch { cursor = null; }
if (snapshot !== null) {
try { sub.send(screenSnapshotEscape(snapshot, cursor)); } catch { /* dead */ }
}
}
/**
* Detach a subscriber. The broker SURVIVES while other subscribers remain
* (FR-6); the LAST detach tears the pipe down and deletes the temp file.
*/
detach(sub: TerminalSubscriber): void {
if (!this.subscribers.delete(sub)) return;
this.scrollOffsets.delete(sub);
if (this.subscribers.size === 0) {
void this.teardown();
}
@@ -294,6 +361,7 @@ export class TerminalSessionBroker {
this.lastClose = { code: 1011, reason: "terminal broker unavailable" };
this.torndown = true;
this.subscribers.clear();
this.scrollOffsets.clear();
if (this.pipeActive) {
this.pipeActive = false;
void this.tmux.stopPipePane(this.sessionName).catch(() => {});
@@ -402,6 +470,10 @@ export class TerminalSessionBroker {
// and it should be detached cleanly (a throwing send means a dead socket).
let dead: TerminalSubscriber[] | null = null;
for (const sub of this.subscribers) {
// OPR.0.4.0.39: a subscriber scrolled back into history is viewing a static
// tmux capture window; skip the live fanout so output does not overwrite it.
// It rejoins live when it scrolls back to the bottom (offset 0).
if ((this.scrollOffsets.get(sub) ?? 0) > 0) continue;
try {
sub.send(data);
} catch {
@@ -38,6 +38,7 @@ function makeTmux(overrides: Partial<BrokerTmux> = {}): BrokerTmux {
sendText: async () => ({ ok: true }),
capturePaneScreen: async () => null,
getPaneCursorPosition: async () => null,
capturePaneContent: async () => null,
...overrides,
};
}
@@ -582,3 +583,105 @@ describe("TerminalSessionBroker - teardown during seed (honest close)", () => {
expect(evicted).toBe(1);
});
});
// ---- 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
// per-subscriber and READ-ONLY on the pane (capture-pane history window), not a
// pane-global copy-mode (which would freeze every viewer). A scrolled subscriber is
// painted a static history window and SKIPPED by the live fanout until it returns to
// the bottom (offset 0), where it repaints the live screen and rejoins the fanout.
describe("TerminalSessionBroker - per-subscriber scroll-back (OPR.0.4.0.39)", () => {
it("scroll(offset>0) paints a BOTTOM-anchored tmux history window (offset lines up) to ONLY that subscriber", async () => {
// Model REAL tmux: `capture-pane -p -S -N` returns a buffer that ENDS at the live
// bottom and contains ~N lines of history ABOVE the visible screen PLUS the screen
// (so ~N + rows lines). L1..L200 with L200 = the live bottom row.
const BUF = Array.from({ length: 200 }, (_, i) => `L${i + 1}`);
const ROWS = 3;
const capturePaneContent = vi.fn(async (_n: string, n: number) => {
const count = Math.min(BUF.length, n + ROWS); // -S -N => ~N + rows lines, bottom-anchored
return BUF.slice(BUF.length - count).join("\n");
});
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({ capturePaneContent }), { pollMs: 10, rows: ROWS }));
const a = makeSub();
const b = makeSub();
await broker.attach(a);
await broker.attach(b);
const aBefore = a.received.length;
const bBefore = b.received.length;
await broker.scroll(a, 3); // wheel up 3 lines from the live bottom (L200)
// 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(a.received.length).toBe(aBefore + 1);
const painted = a.received[a.received.length - 1]!;
expect(painted.startsWith("\x1b[2J")).toBe(true);
expect(painted).toContain("\x1b[1;1HL195");
expect(painted).toContain("\x1b[2;1HL196");
expect(painted).toContain("\x1b[3;1HL197");
expect(painted).not.toContain("L198"); // L198..L200 are within the offset (toward live)
expect(painted).not.toContain("L194"); // above the rows-tall window
// b (live, never scrolled) is untouched by a's scroll - per-subscriber.
expect(b.received.length).toBe(bBefore);
});
it("a scrolled-back subscriber is SKIPPED by the live fanout; the live viewer still streams", async () => {
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({
capturePaneContent: async () => "x1\nx2\nx3\nx4",
}), { pollMs: 10, rows: 3 }));
const a = makeSub();
const b = makeSub();
await broker.attach(a);
await broker.attach(b);
await broker.scroll(a, 2); // a is now viewing a static history window
const aAfterScroll = a.received.length;
fs.appendFileSync(broker.pipeOutputPath!, "LIVE-AFTER-SCROLL");
await vi.waitFor(() => {
expect(b.received.join("")).toContain("LIVE-AFTER-SCROLL");
}, { timeout: 1000 });
// a was scrolled back: the live byte must NOT overwrite its history view.
expect(a.received.length).toBe(aAfterScroll);
expect(a.received.join("")).not.toContain("LIVE-AFTER-SCROLL");
});
it("scroll(offset 0) repaints the live screen and the subscriber REJOINS the fanout", async () => {
const capturePaneScreen = vi.fn(async () => "LIVE SCREEN");
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({
capturePaneContent: async () => "g1\ng2\ng3\ng4",
capturePaneScreen,
getPaneCursorPosition: async () => ({ x: 0, y: 0, width: 90, height: 3 }),
}), { pollMs: 10, rows: 3 }));
const a = makeSub();
await broker.attach(a);
await broker.scroll(a, 2); // into history (skipped by fanout)
capturePaneScreen.mockClear();
const beforeReturn = a.received.length;
await broker.scroll(a, 0); // back to the live bottom
expect(capturePaneScreen).toHaveBeenCalledWith("dev@rig");
expect(a.received.length).toBe(beforeReturn + 1);
expect(a.received[a.received.length - 1]!).toContain("LIVE SCREEN");
// ...and it rejoins the live fanout (no longer skipped).
fs.appendFileSync(broker.pipeOutputPath!, "BACK-TO-LIVE-STREAM");
await vi.waitFor(() => {
expect(a.received.join("")).toContain("BACK-TO-LIVE-STREAM");
}, { timeout: 1000 });
});
it("scroll on an UNKNOWN subscriber is a no-op (never throws, sends nothing)", async () => {
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux(), { pollMs: 10, rows: 3 }));
await broker.attach(makeSub());
const ghost = makeSub(); // never attached
await broker.scroll(ghost, 5);
expect(ghost.received.length).toBe(0);
expect(ghost.closed.length).toBe(0);
});
});
@@ -297,7 +297,7 @@ describe("terminal WebSocket broker (multi-subscriber route)", () => {
await vi.waitFor(() => {
expect(resizeWindowCalls).toHaveLength(1);
}, { timeout: 1000 });
expect(resizeWindowCalls[0]).toEqual({ cols: 120, rows: 40 });
expect(resizeWindowCalls[0]).toEqual({ cols: 90, rows: 27 });
ws.send(JSON.stringify({ type: "resize", cols: 200, rows: 9 }));
// Give the message time to (not) be processed.
@@ -490,11 +490,16 @@ function InlineTerminal({ data }: { data: NodeDetailData }) {
{/* OPR.0.4.0.1 (round-two QA ruling): the node-detail inline terminal joins
the reusable progressive default-static -> click-inside-to-go-live model
under the global live-terminal cap, instead of an always-live uncapped
FocusedTerminal. terminalKey is session-scoped so the registry tracks it. */}
FocusedTerminal. terminalKey is session-scoped so the registry tracks it.
OPR.0.4.0.39: fit="contain" - this panel gives the terminal a big dedicated
500px area, so the 90x27 mirror scales up (capped) to FILL it, centered,
instead of sitting small top-left (the grid cells keep the default
fit-width). */}
<ProgressiveTerminal
sessionName={data.canonicalSessionName}
terminalKey={`node-detail:${data.canonicalSessionName}`}
testIdPrefix="node-detail-terminal"
fit="contain"
/>
</div>
);
@@ -9,9 +9,27 @@
// node-detail context. Operator dogfood reports needing pin from
// Loop State / Topology tab → NAMED v0+1 trigger.
import { useLayoutEffect, useRef } from "react";
import { useLayoutEffect, useRef, type CSSProperties } from "react";
import { useSessionPreview, isNodePreviewUnavailable } from "../../hooks/useNodePreview.js";
import { cn } from "../../lib/utils.js";
import {
LIVE_TERMINAL_COLS,
LIVE_TERMINAL_FONT_FAMILY,
LIVE_TERMINAL_FONT_SIZE,
LIVE_TERMINAL_LINE_HEIGHT,
} from "../terminal/terminal-geometry.js";
// OPR.0.4.0.39: the compact static terminal mirrors the LIVE xterm geometry exactly
// (same font + 90-col width) so static and live are the SAME shape under the shared
// ScaleToFitTerminal scaler - only glass (static) vs opaque (live) differs. Width is a
// fixed 90ch (the live 90-col grid), so the box is always the terminal width
// regardless of how short the captured lines are - just like a real 90-col terminal.
const STATIC_TERMINAL_GEOMETRY: CSSProperties = {
fontFamily: LIVE_TERMINAL_FONT_FAMILY,
fontSize: `${LIVE_TERMINAL_FONT_SIZE}px`,
lineHeight: LIVE_TERMINAL_LINE_HEIGHT,
width: `${LIVE_TERMINAL_COLS}ch`,
};
interface SessionPreviewPaneProps {
sessionName: string;
@@ -59,6 +77,12 @@ export function SessionPreviewPane({
data-variant={variant}
className={cn(
"space-y-1",
// OPR.0.4.0.39 FR-1 (founder spec-correction): the compact STATIC content is
// translucent smoked-GLASS - it lets the caller's SMOKED_STATIC_PLATE_CLASS
// (bg-stone-950/85 backdrop-blur) show through (bg-transparent), NOT opaque.
// The static <pre> is not an xterm, so it has no cursor-safety reason to be
// opaque; opaque #0c0a09 is the LIVE xterm only, and the glass->opaque flip
// on click-to-live is the intentional static-vs-live activation affordance.
compactTerminal
? "border-0 bg-transparent p-0 text-stone-50"
: "border border-stone-300/40 bg-white/8 px-3 py-2",
@@ -112,10 +136,19 @@ export function SessionPreviewPane({
ref={contentRef}
data-testid={`${testIdPrefix}-content`}
onScroll={handleScroll}
style={compactTerminal ? STATIC_TERMINAL_GEOMETRY : undefined}
className={cn(
"font-mono",
// OPR.0.4.0.39 FR-1/FR-4/FR-5 (founder spec): the compact static renders
// at the LIVE xterm geometry (STATIC_TERMINAL_GEOMETRY: same font, fixed
// 90-col width) so static and live are the SAME shape - the shared
// ScaleToFitTerminal scales the whole fixed block to the column (fit-
// width, never clip; no overflow-x pan/cut-off). whitespace-pre renders
// the captured lines as-is (no re-wrap = correct line returns; FR-5).
// bg-transparent = the smoked GLASS plate shows through (FR-2); opaque
// #0c0a09 is the LIVE xterm only - the glass->opaque flip is the affordance.
compactTerminal
? "scrollbar-none max-h-72 overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words bg-transparent px-1 py-0.5 text-[8px] leading-[1.2] text-stone-50"
? "scrollbar-none max-h-[420px] overflow-y-auto whitespace-pre bg-transparent text-stone-50"
: "max-h-32 overflow-y-auto whitespace-pre-wrap break-all bg-stone-50 px-2 py-1 text-[9px] text-stone-800",
)}
>
@@ -1,8 +1,25 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { Terminal } from "@xterm/xterm";
import { readTerminalBearerToken } from "../mission-control/missionControlAuth.js";
import {
LIVE_TERMINAL_RENDER_BACKGROUND,
LIVE_TERMINAL_COLS,
LIVE_TERMINAL_ROWS,
LIVE_TERMINAL_FONT_SIZE,
LIVE_TERMINAL_LINE_HEIGHT,
LIVE_TERMINAL_FONT_FAMILY,
} from "./terminal-geometry.js";
import "@xterm/xterm/css/xterm.css";
// OPR.0.4.0.39 (selection fix): when the live terminal must fit a container, it
// scales by FONT SIZE, not a CSS transform. xterm's mouse hit-test (text selection,
// link clicks) divides a post-transform pointer offset by the pre-transform cell
// size, so a CSS transform:scale ancestor makes selection drift by the scale factor
// (xterm.js #6023). Scaling via fontSize keeps the cell metrics honest -> native
// selection. Upscale (contain mode) is capped to keep text crisp (matches
// ScaleToFitTerminal's MAX_CONTAIN_SCALE).
const MAX_FIT_UPSCALE = 2;
const SPECIAL_KEY_MAP: Record<string, string> = {
"\t": "Tab",
"\r": "Enter",
@@ -32,10 +49,6 @@ const ESCAPE_SEQ_MAP: Record<string, string> = {
"\x1b[2~": "IC",
};
const LIVE_TERMINAL_RENDER_BACKGROUND = "#0c0a09";
const LIVE_TERMINAL_COLS = 120;
const LIVE_TERMINAL_ROWS = 40;
type WsMessage = { type: "keys"; keys: string[] } | { type: "text"; text: string };
export function mapXtermInput(data: string): WsMessage[] {
@@ -118,18 +131,72 @@ export function scrollTerminalViewportToPrompt(container: HTMLElement): void {
interface FocusedTerminalProps {
sessionName: string;
daemonBaseUrl?: string;
/**
* OPR.0.4.0.39: how the live xterm sizes to its container. "natural" (default) =
* render at the native 90x27 size (callers that don't scale, e.g. the feed-card).
* "width" = scale DOWN via fontSize to fit the container width (never upscale) -
* the grid/graph/table cells. "contain" = fit both axes via fontSize with capped
* upscale, centered - the node-detail panel. fontSize-scaling (not CSS transform)
* keeps xterm's selection/click hit-testing native-correct (#6023).
*/
fit?: "natural" | "width" | "contain";
}
export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalProps) {
export function FocusedTerminal({ sessionName, daemonBaseUrl, fit = "natural" }: FocusedTerminalProps) {
const containerRef = useRef<HTMLDivElement>(null);
// OPR.0.4.0.39: the fit wrapper fills the available container; the inner
// containerRef holds the natural-sized xterm. We measure the wrapper (available)
// vs the xterm's natural size (captured once at the base font) and set the xterm
// fontSize so 90x27 fits - no CSS transform, so selection stays native-correct.
const fitWrapperRef = useRef<HTMLDivElement>(null);
const naturalSizeRef = useRef<{ w: number; h: number } | null>(null);
const fitRef = useRef(fit);
fitRef.current = fit;
const termRef = useRef<unknown>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const generationRef = useRef(0);
const promptScrollUntilRef = useRef(0);
// OPR.0.4.0.39: lines scrolled back from the live bottom (0 = live). Driven by the
// wheel handler; the broker paints the matching tmux history window. Typing or
// wheeling back to 0 returns to live.
const scrollOffsetRef = useRef(0);
const [error, setError] = useState<string | null>(null);
const sendScroll = useCallback((offset: number) => {
const wsc = wsRef.current;
if (wsc && wsc.readyState === WebSocket.OPEN) {
wsc.send(JSON.stringify({ type: "scroll", offset }));
}
}, []);
// OPR.0.4.0.39 (selection fix): size the xterm to its container by FONT SIZE (not a
// CSS transform, which breaks xterm's mouse/selection coords - #6023). Reads only
// refs so it is a stable, dependency-free callback. natural is the xterm's 90x27
// pixel size at the base font (captured once); the wrapper is the available space.
const applyFontSizeFit = useCallback(() => {
const mode = fitRef.current;
if (mode === "natural") return;
const term = termRef.current as { options: { fontSize: number } } | null;
const wrapper = fitWrapperRef.current;
const natural = naturalSizeRef.current;
if (!term || !wrapper || !natural || natural.w <= 0 || natural.h <= 0) return;
const availW = wrapper.clientWidth;
const availH = wrapper.clientHeight;
if (availW <= 0) return; // not laid out yet (or jsdom) - skip, no crash
const scale = mode === "contain" && availH > 0
? Math.min(MAX_FIT_UPSCALE, availW / natural.w, availH / natural.h)
: Math.min(1, availW / natural.w); // "width": fit width, never upscale
const nextFont = Math.max(2, LIVE_TERMINAL_FONT_SIZE * scale);
try {
// Only write on a meaningful change (avoids churn + ResizeObserver feedback).
if (Math.abs(term.options.fontSize - nextFont) > 0.1) {
term.options.fontSize = nextFont;
}
} catch { /* term not ready / disposed */ }
}, []);
const disposeTerminal = useCallback(() => {
const term = termRef.current as { dispose(): void } | null;
term?.dispose();
@@ -154,8 +221,10 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
ws.onopen = () => {
if (generationRef.current !== gen) { ws.close(); return; }
// The daemon broker owns fixed canonical geometry (120x40). The client
// must keep the same grid and let the UI scroll/pan smaller surfaces.
// The daemon broker owns fixed canonical geometry (90x27). The client keeps
// the same grid and scrolls history server-side (per-subscriber capture-pane).
// A (re)connect starts at the live bottom.
scrollOffsetRef.current = 0;
promptScrollUntilRef.current = Date.now() + 2500;
const term = termRef.current as { scrollToBottom(): void } | null;
scrollLiveTerminalToPrompt(term);
@@ -210,9 +279,9 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
cursorBlink: true,
cols: LIVE_TERMINAL_COLS,
rows: LIVE_TERMINAL_ROWS,
fontSize: 12,
lineHeight: 1,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
fontSize: LIVE_TERMINAL_FONT_SIZE,
lineHeight: LIVE_TERMINAL_LINE_HEIGHT,
fontFamily: LIVE_TERMINAL_FONT_FAMILY,
// 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.
@@ -233,17 +302,57 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
term.onData((data: string) => {
const wsc = wsRef.current;
if (!wsc || wsc.readyState !== WebSocket.OPEN) return;
// OPR.0.4.0.39: typing returns to the live bottom before sending input.
if (scrollOffsetRef.current > 0) {
scrollOffsetRef.current = 0;
sendScroll(0);
}
const mapped = mapXtermInput(data);
for (const msg of mapped) {
wsc.send(JSON.stringify(msg));
}
});
// OPR.0.4.0.39: wheel = scroll back through tmux history (server-side).
// Up increases the offset (paints an older capture-pane window); down
// decreases it; reaching 0 resumes live. We handle the wheel ourselves and
// return false so xterm's local (empty) scrollback does not interfere.
const scrollHandlerTerm = term as {
attachCustomWheelEventHandler(handler: (ev: WheelEvent) => boolean): void;
};
scrollHandlerTerm.attachCustomWheelEventHandler((ev: WheelEvent) => {
const wsc = wsRef.current;
if (!wsc || wsc.readyState !== WebSocket.OPEN) return true;
const STEP = 3;
if (ev.deltaY < 0) {
scrollOffsetRef.current += STEP;
} else if (ev.deltaY > 0) {
scrollOffsetRef.current = Math.max(0, scrollOffsetRef.current - STEP);
} else {
return true;
}
sendScroll(scrollOffsetRef.current);
return false;
});
// OPR.0.4.0.38 FR-7: no term.onResize -> ws resize relay. The pane
// geometry is fixed daemon-side; the client grid matches it exactly
// and never asks the pane to resize.
connectForGeneration(currentGen);
// OPR.0.4.0.39 (selection fix): capture the xterm's NATURAL 90x27 pixel size
// at the base font (before any fontSize-fit) as the fixed reference, then
// apply the initial fit. rAF so layout has settled. Guarded for jsdom (0 size).
requestAnimationFrame(() => {
if (cleanedUp || !containerRef.current) return;
if (!naturalSizeRef.current) {
const w = containerRef.current.offsetWidth;
const h = containerRef.current.offsetHeight;
if (w > 0 && h > 0) naturalSizeRef.current = { w, h };
}
applyFontSizeFit();
});
} catch (err) {
setError(err instanceof Error ? err.message : "Terminal initialization failed");
}
@@ -260,6 +369,20 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
};
}, [connectForGeneration, disposeTerminal]);
// OPR.0.4.0.39 (selection fix): refit the xterm fontSize when its container resizes
// (responsive grid columns, window resize, node-detail panel). Observes the fit
// WRAPPER (which fills the parent), so changing the INNER xterm fontSize does not
// feed back into the observed box. No-op in "natural" mode.
useEffect(() => {
if (fit === "natural") return undefined;
const wrapper = fitWrapperRef.current;
if (!wrapper || typeof ResizeObserver === "undefined") return undefined;
const ro = new ResizeObserver(() => applyFontSizeFit());
ro.observe(wrapper);
applyFontSizeFit();
return () => ro.disconnect();
}, [fit, applyFontSizeFit]);
if (error) {
return (
<div
@@ -274,12 +397,34 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
);
}
return (
// The xterm always renders at its NATURAL full 90x27 geometry (w-max) so the WHOLE
// screen + cursor are visible (no min-h/h-full cap that hid the bottom rows).
const liveTerminal = (
<div
key={`focused-terminal-live-${sessionName}`}
ref={containerRef}
data-testid={`focused-terminal-${sessionName}`}
className="h-full w-full min-h-[200px] overflow-auto bg-stone-950/60 backdrop-blur-sm"
className="w-max bg-stone-950/85 backdrop-blur-sm"
/>
);
// OPR.0.4.0.39 (selection fix): in "natural" mode the xterm renders at native size
// (no scaling). In "width"/"contain" mode it is wrapped in a fit-wrapper that fills
// the container; applyFontSizeFit sets the xterm fontSize so 90x27 fits - NO CSS
// transform, so xterm's selection/click hit-testing stays native-correct (#6023).
if (fit === "natural") return liveTerminal;
return (
<div
ref={fitWrapperRef}
data-testid={`focused-terminal-fit-${sessionName}`}
className={
fit === "contain"
? "flex h-full w-full items-center justify-center overflow-hidden"
: "w-full overflow-hidden"
}
>
{liveTerminal}
</div>
);
}
@@ -8,17 +8,19 @@
// Static previews are uncapped (cheap polling). Used by all three surfaces.
import { useCallback, useEffect, useRef, useState } from "react";
import { SessionPreviewPane } from "../preview/SessionPreviewPane.js";
import { FocusedTerminal } from "./FocusedTerminal.js";
import { StaticTerminalPlate } from "./StaticTerminalPlate.js";
import { ScaleToFitTerminal } from "./ScaleToFitTerminal.js";
import { useLiveTerminal } from "./LiveTerminalProvider.js";
import { cn } from "../../lib/utils.js";
/** OPR.0.4.0.1 (FR-1/FR-4): the borderless smoked-glass plate the STATIC terminal
* preview carries so it reads as floating glass on EVERY surface -- including the
* truly-bare ones with no popover/shell plate behind them (topology-tab, and the
* topology grid thumbnail). Exported so the grid thumbnail uses the SAME plate as
* ProgressiveTerminal's static view (one shared smoked-static source). */
export const SMOKED_STATIC_PLATE_CLASS = "bg-stone-950/60 backdrop-blur-sm";
// OPR.0.4.0.39: the static fetches a deeper history than the visible landscape window
// so you can SCROLL BACK through it (the compact <pre> caps the visible height with
// max-h + overflow-y); the live xterm scrolls its own buffer.
const STATIC_HISTORY_LINES = 100;
// OPR.0.4.0.39 FR-1: the shared static-terminal plate now owns
// SMOKED_STATIC_PLATE_CLASS; re-export here for existing importers.
export { SMOKED_STATIC_PLATE_CLASS } from "./StaticTerminalPlate.js";
interface ProgressiveTerminalProps {
sessionName: string;
@@ -31,15 +33,21 @@ interface ProgressiveTerminalProps {
* (e.g. the popover) can size its shell to the wide live plate when live and
* stay compact when static. */
onLiveChange?: (isLive: boolean) => void;
/** OPR.0.4.0.39: scale-to-fit mode. "width" (default) fits the column width and
* never upscales (grid/graph/table cells). "contain" fills a big dedicated
* container on both axes (capped upscale, centered) - the node-detail panel. */
fit?: "width" | "contain";
}
export function ProgressiveTerminal({
sessionName,
terminalKey,
lines = 20,
// OPR.0.4.0.39: fetch deeper history so the static landscape window can scroll back.
lines = STATIC_HISTORY_LINES,
testIdPrefix = "progressive-terminal",
className,
onLiveChange,
fit = "width",
}: ProgressiveTerminalProps) {
const [mode, setMode] = useState<"static" | "live">("static");
const live = useLiveTerminal();
@@ -68,40 +76,43 @@ export function ProgressiveTerminal({
return () => live.release(terminalKey);
}, [mode, live, terminalKey]);
// OPR.0.4.0.39 (founder spec): static and live are the SAME 90x27 geometry, both
// wrapped in the shared ScaleToFitTerminal so they scale identically to the column
// (fit-width, never clip). The glass->opaque flip on click is the only change - the
// live xterm appears at the same size in the same place (the mirror).
if (mode === "live") {
// OPR.0.4.0.39 (selection fix): the LIVE xterm scales via fontSize (FocusedTerminal's
// own fit), NOT a CSS transform. A CSS transform:scale ancestor breaks xterm's
// mouse/selection hit-testing (it divides a post-transform offset by the pre-
// transform cell size - #6023), so dragging selects the wrong cells. FocusedTerminal
// fills this container and fontSize-fits 90x27 to it, pixel-matching the static plate
// (which keeps its transform - native DOM selection follows transforms fine). Same
// signed-off look, native-correct selection.
return (
<div data-testid={`${testIdPrefix}-live`} className={cn("h-full w-full", className)}>
<FocusedTerminal sessionName={sessionName} />
<div
data-testid={`${testIdPrefix}-live`}
className={[fit === "contain" ? "h-full w-full" : "w-full", className].filter(Boolean).join(" ")}
>
<FocusedTerminal sessionName={sessionName} fit={fit} />
</div>
);
}
// Static default: the whole preview is the click target to go live.
// Static default: the whole preview is the click target to go live. The shared
// StaticTerminalPlate carries the translucent smoked-GLASS plate + transparent
// compact content at the 90-col geometry (FR-1/FR-2); clicking flips it to the
// OPAQUE #0c0a09 live xterm in place - the glass->opaque activation affordance.
return (
<button
type="button"
data-testid={`${testIdPrefix}-static`}
aria-label={`Make ${sessionName} terminal live (typeable)`}
title="Click to go live (typeable)"
onClick={goLive}
className={cn(
// OPR.0.4.0.1 (FR-1/FR-2/FR-4): the static preview carries its OWN
// borderless smoked-glass plate so it reads as floating glass on the
// truly-bare surfaces (topology-tab / grid), matching the live look. The
// compact-terminal SessionPreviewPane variant (border-0 bg-transparent
// text-stone-50) sits ON this smoke. The live mode relies on
// FocusedTerminal's own tinted bg -> one tint per mode, no double-tint.
"block h-full w-full cursor-pointer text-left",
SMOKED_STATIC_PLATE_CLASS,
className,
)}
>
<SessionPreviewPane
<ScaleToFitTerminal testId={`${testIdPrefix}-fit`} className={className} fit={fit}>
<StaticTerminalPlate
sessionName={sessionName}
lines={lines}
variant="compact-terminal"
testIdPrefix={`${testIdPrefix}-preview`}
plateTestId={`${testIdPrefix}-static`}
previewTestIdPrefix={`${testIdPrefix}-preview`}
onClick={goLive}
ariaLabel={`Make ${sessionName} terminal live (typeable)`}
title="Click to go live (typeable)"
/>
</button>
</ScaleToFitTerminal>
);
}
@@ -0,0 +1,123 @@
// OPR.0.4.0.39 - scale-to-fit wrapper for the unified static/live terminal mirror.
//
// Founder spec (spec-dev2-authored-2026-06-22): the static and live terminals are
// THE SAME fixed geometry (the live xterm's 90x27). Rather than pan/clip a fixed
// 90-col block inside a narrow column, we render it at its natural width and CSS-
// transform scale the WHOLE block to fit the available space (the same wrapper
// scales BOTH the static plate and the live xterm identically, so the glass->opaque
// flip on click stays the same size in the same place - the mirror).
//
// Two fit modes (scale is measured via ResizeObserver, not guessed):
// - "width" (default, the grid/graph/table cells): fit the available WIDTH, never
// UPSCALE past the native geometry (cap at 1), origin top-left. The block is
// fixed-width so this is true fit-width (the full terminal width is always
// visible, never cut off - founder: "prefer too small over cut off"). The outer
// height is the scaled natural height so surrounding layout flows.
// - "contain" (the node-detail panel, which gives the terminal a big dedicated
// area): fit BOTH width and height of the container (so the terminal fills the
// panel as much as its 90x27 aspect allows), ALLOWING upscale up to a cap so it
// uses the space, centered, never clipped. The off-axis margin is balanced
// (letterboxed) instead of a top-left gap with dead space.
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
// Upscale ceiling for "contain" mode: filling a big panel is worth a moderate
// CSS-transform upscale, but past ~2x the xterm text starts to soften, so cap it.
const MAX_CONTAIN_SCALE = 2;
interface ScaleToFitTerminalProps {
children: ReactNode;
/** Optional testid for the outer (fit) container. */
testId?: string;
className?: string;
/**
* "width" (default): fit available width, never upscale, top-left - for the
* grid/graph/table cells. "contain": fit both axes of the container with upscale
* (capped) + centered - for the node-detail panel's big dedicated area.
*/
fit?: "width" | "contain";
}
export function ScaleToFitTerminal({ children, testId, className, fit = "width" }: ScaleToFitTerminalProps) {
const outerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const [boxHeight, setBoxHeight] = useState<number | undefined>(undefined);
const contain = fit === "contain";
useLayoutEffect(() => {
const outer = outerRef.current;
const inner = innerRef.current;
if (!outer || !inner) return;
const measure = () => {
const availableWidth = outer.clientWidth;
// scrollWidth/Height report the UN-transformed natural size of the fixed
// 90x27 block, regardless of the scale transform already applied.
const naturalWidth = inner.scrollWidth;
const naturalHeight = inner.scrollHeight;
if (naturalWidth <= 0 || naturalHeight <= 0 || availableWidth <= 0) return;
if (contain) {
// Fit BOTH axes of the container, allow upscale (capped), keep aspect.
const availableHeight = outer.clientHeight;
if (availableHeight <= 0) return;
const next = Math.min(
MAX_CONTAIN_SCALE,
availableWidth / naturalWidth,
availableHeight / naturalHeight,
);
setScale(next);
// Outer already fills the panel (h-full); the inner is centered by flex.
setBoxHeight(undefined);
} else {
// Fit width, never upscale; reserve the scaled height so layout flows.
const next = Math.min(1, availableWidth / naturalWidth);
setScale(next);
setBoxHeight(naturalHeight * next);
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(outer);
ro.observe(inner);
return () => ro.disconnect();
}, [contain]);
if (contain) {
return (
<div
ref={outerRef}
data-testid={testId}
className={
className
? `flex h-full w-full items-center justify-center overflow-hidden ${className}`
: "flex h-full w-full items-center justify-center overflow-hidden"
}
>
<div
ref={innerRef}
style={{ width: "max-content", transform: `scale(${scale})`, transformOrigin: "center center" }}
>
{children}
</div>
</div>
);
}
return (
<div
ref={outerRef}
data-testid={testId}
className={className ? `w-full overflow-hidden ${className}` : "w-full overflow-hidden"}
style={{ height: boxHeight }}
>
<div
ref={innerRef}
style={{ width: "max-content", transform: `scale(${scale})`, transformOrigin: "top left" }}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,90 @@
// OPR.0.4.0.39 - the ONE shared static-terminal component.
//
// Backs EVERY static polling-preview terminal (the topology grid card and
// ProgressiveTerminal's static mode) with the SAME borderless smoked-glass plate
// (SMOKED_STATIC_PLATE_CLASS = bg-stone-950/60 backdrop-blur) wrapping the compact
// SessionPreviewPane. The static content is translucent GLASS (bg-transparent;
// the plate shows through) - the INACTIVE state. Founder spec-correction: the
// LIVE xterm is opaque #0c0a09, and the glass->opaque flip on click-to-live is the
// intentional static-vs-live activation affordance (glass = inactive, opaque =
// live), NOT "mirror the live look". Consolidates the duplicated plate+preview
// pattern so every static terminal is consistent and upgrades to live in place.
import { SessionPreviewPane } from "../preview/SessionPreviewPane.js";
import { cn } from "../../lib/utils.js";
/** The borderless smoked-glass plate every static-terminal preview carries so it
* reads as floating glass on the truly-bare surfaces (topology tab / grid),
* matching the live look. Defined here as the shared static-terminal home;
* ProgressiveTerminal re-exports it for existing importers. */
// OPR.0.4.0.39 (founder spec-correction): the static plate reads as smoked-black
// GLASS over the LIGHT topology page. At 60% the stone-950 tint over the cream paper
// rendered a washed-out light gray (the founder's "way too washed out"); 85% reads as
// rich smoked black (matching the dialed-in graph-popover look) while keeping the glass
// translucency + backdrop-blur. The LIVE xterm is fully opaque #0c0a09, so the
// glass->opaque flip stays a visible activation affordance.
export const SMOKED_STATIC_PLATE_CLASS = "bg-stone-950/85 backdrop-blur-sm";
interface StaticTerminalPlateProps {
sessionName: string;
lines?: number;
/** data-testid for the plate element (button when onClick, div otherwise). */
plateTestId?: string;
/** testIdPrefix passed through to the inner SessionPreviewPane. */
previewTestIdPrefix?: string;
className?: string;
/** When provided, the plate is itself the click-to-live target (a button). */
onClick?: () => void;
ariaLabel?: string;
title?: string;
}
/**
* The shared static-terminal plate. With `onClick` it renders as the
* click-to-live button (ProgressiveTerminal's static mode); without it, a plain
* plate (the topology grid thumbnail, whose click-to-live is the separate
* TerminalPreviewPopover trigger).
*/
export function StaticTerminalPlate({
sessionName,
lines,
plateTestId,
previewTestIdPrefix,
className,
onClick,
ariaLabel,
title,
}: StaticTerminalPlateProps) {
const preview = (
<SessionPreviewPane
sessionName={sessionName}
lines={lines}
variant="compact-terminal"
testIdPrefix={previewTestIdPrefix}
/>
);
if (onClick) {
return (
<button
type="button"
data-testid={plateTestId}
aria-label={ariaLabel}
title={title}
onClick={onClick}
// OPR.0.4.0.39: w-max - the plate sizes to its fixed 90-col content (not
// fill), so the shared ScaleToFitTerminal can measure the natural width and
// scale the whole block to the column (the static<->live geometry mirror).
className={cn("block w-max cursor-pointer text-left", SMOKED_STATIC_PLATE_CLASS, className)}
>
{preview}
</button>
);
}
return (
<div data-testid={plateTestId} className={cn("w-max", SMOKED_STATIC_PLATE_CLASS, className)}>
{preview}
</div>
);
}
@@ -0,0 +1,24 @@
// OPR.0.4.0.39 - the single source of truth for the static<->live terminal mirror.
//
// Founder spec (spec-dev2-authored-2026-06-22): the static (polling-preview) and the
// live (interactive xterm) terminals are THE SAME shape - the optimal Claude/Codex
// CLI geometry, which is the live xterm's pinned 90x27 at fontSize 12 / lineHeight 1.
// The static plate renders at this exact font + 90-col width so it mirrors the live;
// ScaleToFitTerminal scales both identically to the column. Only glass (static) vs
// opaque (live) differs.
//
// Kept dependency-free (no xterm import) so the lightweight static preview can share
// it without pulling the xterm bundle into the static path.
export const LIVE_TERMINAL_RENDER_BACKGROUND = "#0c0a09";
// OPR.0.4.0.39 (founder-directed): 90 cols. Claude Code (Ink) + Codex CLI reflow to
// any width (80 is the too-narrow legacy fallback); 90 stays above that floor while
// rendering the scaled static/live mirror bigger/more legible in the grid cells than
// 120 did. MUST match the daemon broker CANONICAL_COLS (TerminalSessionBroker.ts) -
// the client xterm grid must equal the pane geometry.
export const LIVE_TERMINAL_COLS = 90;
export const LIVE_TERMINAL_ROWS = 27;
export const LIVE_TERMINAL_FONT_SIZE = 12;
export const LIVE_TERMINAL_LINE_HEIGHT = 1;
export const LIVE_TERMINAL_FONT_FAMILY =
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
@@ -3,6 +3,11 @@ import type React from "react";
import { createPortal } from "react-dom";
import { FocusedTerminal } from "../terminal/FocusedTerminal.js";
import { ProgressiveTerminal } from "../terminal/ProgressiveTerminal.js";
import {
LIVE_TERMINAL_COLS,
LIVE_TERMINAL_FONT_FAMILY,
LIVE_TERMINAL_FONT_SIZE,
} from "../terminal/terminal-geometry.js";
import { cn } from "../../lib/utils.js";
import { ToolMark } from "../graphics/RuntimeMark.js";
@@ -111,17 +116,12 @@ export function TerminalPreviewPopover({
const popoverRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<PopoverPosition | null>(null);
// OPR.0.4.0.1 (rev1-r2 fix): the popover shell must fit the WIDE live plate when
// live (or it clips the FR-3 optimal width via overflow-hidden), and stay compact
// when showing the static preview. A non-progressive popover (feed-card) is always
// live, so it is always wide.
const [liveInside, setLiveInside] = useState(false);
const liveWide = !progressive || liveInside;
// Reset to compact whenever the popover closes (the next open starts static).
useEffect(() => {
if (!open) setLiveInside(false);
}, [open]);
// OPR.0.4.0.39 (founder spec): static and live are the SAME 90x27 mirror (same
// size, same place) via ProgressiveTerminal -> ScaleToFitTerminal, so the popover
// no longer reshapes on go-live. The old compact-static -> wide-live grow was the
// "separately shaped, different location" the founder flagged; now the shell holds
// the full-terminal width for BOTH states and the static just flips glass->opaque
// in place (matching the grid).
const updatePosition = useCallback(() => {
if (!open) return;
@@ -227,10 +227,10 @@ export function TerminalPreviewPopover({
// 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
// the static preview.
liveWide ? "w-[904px]" : "w-[calc(80ch+24px)]",
// OPR.0.4.0.39: the shell sizes to the terminal (w-max) for BOTH static + live
// - no reshape on go-live, no loose empty width. The inner is the canonical
// geometry width so the shell tracks the column count automatically.
"w-max",
"cursor-default select-text font-mono text-[8px] text-stone-50",
popoverClassName,
)}
@@ -238,20 +238,29 @@ export function TerminalPreviewPopover({
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{/* OPR.0.4.0.1 (FR-3): the LIVE wide plate width is set to the optimal render
width of the Claude Code + Codex CLI TUIs. ~120 cols (the wider of the two)
at xterm fontSize 12 ui-monospace (cell ~7.23px) ~= 868px -> 880px sizer.
FitAddon propagates cols/rows over the WS so the TUI reflows. This is a
computed starting width; QA's fix-loop finalizes it against a REAL agent
TUI per the proof bar. Optimal width is LIVE-only -- the static topology
grid columns are NOT widened (3-col overview preserved). */}
<div className={cn("h-[440px] max-w-[calc(100vw-2rem)]", liveWide ? "w-[880px]" : "w-full")}>
{/* OPR.0.4.0.39: the popover holds the full 90-col terminal width (~650px at
fontSize 12 ui-monospace, the broker's canonical geometry) for BOTH the
static and the live state. ProgressiveTerminal renders the static smoked-
glass mirror or the opaque live xterm at the SAME size in place (no reshape).
The grid uses a smaller scaled mirror per cell; this popover (graph/table)
has room for the near-full-size terminal. */}
<div
// OPR.0.4.0.39: the inner is the canonical geometry WIDTH (90ch at the live
// font) so the static/live mirror fits exactly; height is driven by the
// content (ScaleToFitTerminal for progressive, the natural xterm for the feed)
// so the bottom is never cut off.
className="max-w-[calc(100vw-2rem)]"
style={{
width: `${LIVE_TERMINAL_COLS}ch`,
fontFamily: LIVE_TERMINAL_FONT_FAMILY,
fontSize: `${LIVE_TERMINAL_FONT_SIZE}px`,
}}
>
{progressive ? (
<ProgressiveTerminal
sessionName={sessionName}
terminalKey={key}
testIdPrefix={testIdPrefix}
onLiveChange={setLiveInside}
/>
) : (
<FocusedTerminal sessionName={sessionName} />
@@ -22,9 +22,7 @@ import { useMemo, useState } from "react";
import { useRigSummary } from "../../hooks/useRigSummary.js";
import { useNodeInventory, type NodeInventoryEntry } from "../../hooks/useNodeInventory.js";
import { displayAgentName } from "../../lib/display-name.js";
import { SessionPreviewPane } from "../preview/SessionPreviewPane.js";
import { TerminalPreviewPopover } from "./TerminalPreviewPopover.js";
import { SMOKED_STATIC_PLATE_CLASS } from "../terminal/ProgressiveTerminal.js";
import { ProgressiveTerminal } from "../terminal/ProgressiveTerminal.js";
import { EmptyState } from "../ui/empty-state.js";
import { SectionHeader } from "../ui/section-header.js";
import { cn } from "../../lib/utils.js";
@@ -86,16 +84,20 @@ function TokenMetric({ seat }: { seat: NodeInventoryEntry }) {
}
// V0.3.1 slice 14 walk-item 17 — TerminalView card.
// OPR.0.4.0.1 (FR-5 founder amendment — RECONCILES the merged live-in-place model):
// a TUI-optimal-width live terminal does NOT fit a 1/3-width grid cell, so the card
// keeps a SMALL static thumbnail (the 3-col overview stays an at-a-glance scan) and
// reaches the wide LIVE terminal by EXPANDING OUT via the SAME graph/table primitive
// (TerminalPreviewPopover): the trigger opens the wide popover (static), then the
// universal click-inside goes live. This REPLACES the prior live-in-place model so
// every surface (graph/table/grid) opens live the same way. The global cap=2 binds
// on the inside go-live (2 wide live plates max; a 3rd evicts the oldest); static
// thumbnails + open popovers are uncapped. The pulsing-ring active treatment is
// preserved.
// OPR.0.4.0.39 (FR-1/FR-3/FR-4/FR-6 founder spec-correction): each grid cell is the
// agent name + Claude/Codex stats in a header ABOVE a FULL-WIDTH ProgressiveTerminal
// (static-by-default -> click -> live IN PLACE). The static IS the click-to-live
// target -- there is NO separate expand-out popover trigger and NO white card
// wrapper (too contrasty with the terminal). The static renders translucent smoked-
// GLASS; clicking flips it to the OPAQUE #0c0a09 live xterm in place (the activation
// affordance). The live xterm scrolls/pans within the cell (38 pins 90x27 +
// overflow-auto) -- fine; the grid is a scan-and-click-to-intervene view, not for
// long sessions. The global live cap binds on go-live (shared LiveTerminalProvider).
// FR-3: origin-top-left + a per-breakpoint CSS scale-down (xl:scale-90 starting
// value; QA measures the legibility floor + forward-fixes the value) fits the full
// terminal width in the densest 3-col cells -- prefer too-small over CLIP, never cut
// off the right edge (no overflow-hidden; the static <pre> pans wider lines). The
// pulsing-ring active treatment is preserved as the at-a-glance scan signal.
function SeatTerminalCard({ seat }: { seat: NodeInventoryEntry }) {
const sessionName = seat.canonicalSessionName ?? seat.logicalId;
@@ -106,14 +108,15 @@ function SeatTerminalCard({ seat }: { seat: NodeInventoryEntry }) {
data-testid={`terminal-card-${seat.rigId}-${seat.logicalId}`}
data-active={active ? "true" : "false"}
className={cn(
"relative border bg-white/40 p-2 flex flex-col gap-2",
active
? "border-secondary terminal-card-active"
: "border-outline-variant",
// OPR.0.4.0.39 FR-4: NO white card wrapper (bg-white/40 was too contrasty);
// tight p-1.5/gap-1.5 so the grid does not waste edge space. The active
// pulsing-ring border stays as the scan signal; inactive is borderless.
"relative flex flex-col gap-1.5 p-1.5",
active ? "terminal-card-active border border-secondary" : "border border-transparent",
)}
>
<header className="relative z-0 flex items-center justify-between gap-2 pointer-events-none">
<span className="font-mono text-[8px] font-semibold uppercase tracking-[0.10em] text-stone-900 truncate">
<header className="flex items-center justify-between gap-2">
<span className="font-mono text-[8px] font-semibold uppercase tracking-[0.10em] text-stone-500 truncate">
{memberName}
</span>
<span className="inline-flex shrink-0 items-center gap-1">
@@ -122,38 +125,17 @@ function SeatTerminalCard({ seat }: { seat: NodeInventoryEntry }) {
<TokenMetric seat={seat} />
</span>
</header>
<div className="relative z-0 min-h-0 flex items-start gap-2">
{/* OPR.0.4.0.1 (FR-5): a SMALL static thumbnail keeps the 3-col grid an
at-a-glance overview -- NOT widened to TUI width. */}
<div
data-testid={`terminal-grid-${seat.rigId}-${seat.logicalId}-thumb-plate`}
className={cn("min-w-0 flex-1 overflow-hidden", SMOKED_STATIC_PLATE_CLASS)}
>
{/* The grid is a TRULY BARE surface, so the static thumbnail carries the
SAME borderless smoked-glass plate as ProgressiveTerminal's static
view (shared SMOKED_STATIC_PLATE_CLASS) -- the compact-terminal
SessionPreviewPane is transparent and sits ON this smoke. */}
<SessionPreviewPane
sessionName={sessionName}
lines={6}
variant="compact-terminal"
testIdPrefix={`terminal-grid-${seat.rigId}-${seat.logicalId}-thumb`}
/>
</div>
{/* OPR.0.4.0.1 (FR-5 PINNED expand-OUT): the wide LIVE plate is reached via
the SAME graph/table primitive -- the TerminalPreviewPopover trigger
opens the wide popover (static), then the universal click-inside goes
live. cap=2 binds on that inside go-live (2 wide live plates max; a 3rd
evicts the oldest). This REPLACES the prior live-in-place model so all
surfaces (graph/table/grid) open live the same way; no new primitive. */}
<TerminalPreviewPopover
rigId={seat.rigId}
logicalId={seat.logicalId}
sessionName={sessionName}
testIdPrefix={`terminal-grid-${seat.rigId}-${seat.logicalId}`}
progressive
/>
</div>
{/* OPR.0.4.0.39 FR-1/FR-3/FR-6 (founder spec): ProgressiveTerminal IS the
in-place click-to-live target (static smoked-glass -> click -> opaque live
xterm at the SAME 90x27 geometry, same place). It wraps both states in the
shared ScaleToFitTerminal, which measures the fixed 90-col block and scales
it to fit the cell width (fit-width, never clip - no hardcoded per-breakpoint
scale). No separate popover trigger. */}
<ProgressiveTerminal
sessionName={sessionName}
terminalKey={`${seat.rigId}:${seat.logicalId}`}
testIdPrefix={`terminal-grid-${seat.rigId}-${seat.logicalId}`}
/>
</div>
);
}
@@ -199,7 +181,10 @@ function TerminalGrid({
</button>
) : null}
</div>
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{/* OPR.0.4.0.39 FR-2 (founder: 2 columns MAXIMUM): 1-col narrow / 2-col wider.
3-col was dropped - at 3-across the scaled 90-col terminal is too small to
read; 2-col keeps each cell wide enough for legible terminals. */}
<div className="grid gap-2 grid-cols-1 md:grid-cols-2">
{visible.map((seat) => (
<SeatTerminalCard
key={`${seat.rigId}-${seat.logicalId}`}
@@ -22,7 +22,7 @@ describe("FocusedTerminal lifecycle", () => {
// resize (it would shrink the shared pane for every other viewer).
expect(src).not.toMatch(/type:\s*["']resize["']/);
// OPR.0.4.0.38 forward-fix: the client now PINS to the broker canonical
// geometry (cols=120 rows=40) and scrolls/pans its container - FitAddon was
// geometry (cols=100 rows=40) and scrolls/pans its container - FitAddon was
// removed entirely (no container-fit resize-fight), which is the stronger
// form of fixed-geometry mirroring.
expect(src).not.toContain("FitAddon");
@@ -45,6 +45,8 @@ vi.mock("@xterm/xterm", () => ({
// Stub them so the component's setup does not throw in the mock.
focus() {}
scrollToBottom() {}
// OPR.0.4.0.39: FocusedTerminal attaches a wheel handler for tmux scroll-back.
attachCustomWheelEventHandler(_h: (ev: WheelEvent) => boolean) {}
dispose() {
terminalDisposeCount++;
this.child?.remove();
@@ -203,10 +203,10 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(driverPopover.parentElement).toBe(document.body);
expect(driverPopover.className).toContain("fixed");
expect(driverPopover.className).toContain("z-[1000]");
// OPR.0.4.0.1 (rev1-r2 fix): once LIVE, the popover shell widens to fit the
// 880px live plate (w-[904px]) so overflow-hidden no longer clips it -- it is
// NOT the compact static w-[calc(80ch+24px)] here (this terminal is live).
expect(driverPopover.className).toContain("w-[904px]");
// OPR.0.4.0.1 + OPR.0.4.0.39 (no-reshape): the popover shell is w-max for BOTH
// static and live now (it no longer widens from a compact static to a hardcoded
// live plate), so overflow-hidden no longer clips the live terminal here.
expect(driverPopover.className).toContain("w-max");
expect(driverPopover.className).not.toContain("w-[calc(80ch+24px)]");
expect(driverPopover.className).toContain("max-w-[calc(100vw-1rem)]");
expect(driverPopover.className).toContain("max-h-[calc(100vh-1rem)]");
+27 -29
View File
@@ -223,14 +223,13 @@ describe("TopologyTerminalView P5-7 grid", () => {
});
});
// V0.3.1 slice 14 walk-item 17 forward-fix #1 (a11y): each terminal
// card exposes EXACTLY ONE tabbable trigger control. The TerminalPreview-
// Popover instance is mounted with `renderTrigger={false}` so the card's
// full-card overlay button is the sole interaction surface; a duplicate
// invisible-but-reachable sr-only popover button would fail
// sensible-tab-order a11y review.
describe("TerminalView card a11y (slice 14 FF#1)", () => {
it("each terminal card exposes exactly one tab-reachable trigger button (no duplicate sr-only popover button)", async () => {
// OPR.0.4.0.39 (FR-6 founder spec-correction -- REVERSES the slice-01 expand-out):
// each grid card's static IS the single in-place click-to-live target (a full-width
// ProgressiveTerminal static button) -- there is NO separate TerminalPreviewPopover
// trigger. So each card has exactly ONE tab-reachable control (the static itself),
// preserving the a11y intent (one trigger per card) while restoring live-in-place.
describe("TerminalView card a11y (OPR.0.4.0.39 in-place click-to-live)", () => {
it("each terminal card exposes exactly one tab-reachable control: the in-place static button (no popover trigger)", async () => {
const seats = [
makeSeat({ logicalId: "alpha" }),
makeSeat({ logicalId: "beta" }),
@@ -241,23 +240,21 @@ describe("TerminalView card a11y (slice 14 FF#1)", () => {
);
await findByTestId("topology-terminal-grid");
// OPR.0.4.0.1 (FR-5): each card shows a SMALL static thumbnail + exactly ONE
// tab-reachable TerminalPreviewPopover trigger (the wide expand-out, identical
// to graph/table). The a11y intent (exactly one trigger per card) is preserved.
const cardTriggers = container.querySelectorAll(
"button[data-testid^='terminal-grid-'][data-testid$='-terminal-open']",
);
expect(cardTriggers).toHaveLength(2);
// The live-in-place model is REMOVED: NO in-place ProgressiveTerminal static
// trigger in the card body (going live now happens INSIDE the popover).
// FR-6: the static ITSELF is the in-place click-to-live target (ProgressiveTerminal
// static, full-width) -- exactly ONE per card.
const inPlaceStatic = container.querySelectorAll(
"button[data-testid^='terminal-grid-'][data-testid$='-static']",
);
expect(inPlaceStatic).toHaveLength(0);
expect(inPlaceStatic).toHaveLength(2);
// The separate expand-out popover trigger is REMOVED from the grid.
const popoverTriggers = container.querySelectorAll(
"button[data-testid^='terminal-grid-'][data-testid$='-terminal-open']",
);
expect(popoverTriggers).toHaveLength(0);
});
it("OPR.0.4.0.1 (FR-1/FR-4): the grid static thumbnail carries the smoked-glass plate on the bare grid surface", async () => {
it("OPR.0.4.0.39 (FR-1/FR-6): the grid static carries the smoked-glass plate AND is the in-place click-to-live button", async () => {
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": [makeSeat({ logicalId: "alpha" })] } });
const { findByTestId, container } = withQueryClient(
<TopologyTerminalView scope="rig" rigId="rig-1" />,
@@ -265,16 +262,17 @@ describe("TerminalView card a11y (slice 14 FF#1)", () => {
await findByTestId("topology-terminal-grid");
// The grid is a TRULY BARE surface (no popover/shell plate behind it), so the
// static thumbnail must carry its OWN borderless smoked-glass plate -- NOT a
// light card-bg preview. (guard BLOCKING: the FR-5 thumbnail bypassed the smoke.)
const thumbPlate = await findByTestId("terminal-grid-rig-1-alpha-thumb-plate");
expect(thumbPlate.className).toContain("bg-stone-950/60");
expect(thumbPlate.className).toContain("backdrop-blur-sm");
// static carries its OWN borderless smoked-glass plate; and per FR-6 it IS the
// click-to-live button (ProgressiveTerminal static), not a non-interactive
// thumbnail beside a separate popover trigger.
const staticPlate = await findByTestId("terminal-grid-rig-1-alpha-static");
expect(staticPlate.tagName).toBe("BUTTON");
expect(staticPlate.className).toContain("bg-stone-950/85");
expect(staticPlate.className).toContain("backdrop-blur-sm");
// ...while still exactly ONE popover trigger + NO in-place ProgressiveTerminal
// static button (the FR-5 expand-out model is intact).
expect(container.querySelectorAll("button[data-testid$='-terminal-open']")).toHaveLength(1);
expect(container.querySelectorAll("button[data-testid$='-static']")).toHaveLength(0);
// ...exactly ONE in-place static button + NO expand-out popover trigger.
expect(container.querySelectorAll("button[data-testid$='-static']")).toHaveLength(1);
expect(container.querySelectorAll("button[data-testid$='-terminal-open']")).toHaveLength(0);
});
});
+20 -4
View File
@@ -89,12 +89,28 @@ describe("SessionPreviewPane", () => {
const content = await screen.findByTestId("compact-terminal-test-content");
expect(pane.getAttribute("data-variant")).toBe("compact-terminal");
expect(content.className).toContain("text-[8px]");
expect(content.className).toContain("text-stone-50");
expect(content.className).toContain("whitespace-pre-wrap");
expect(content.className).toContain("break-words");
// OPR.0.4.0.39 (founder spec): the compact static renders at the LIVE xterm
// geometry (same font, fixed 90-col width) so static and live are the SAME
// shape under the shared ScaleToFitTerminal. Font + width are inline (mirror the
// live exactly), not utility classes.
expect(content.style.fontSize).toBe("12px");
expect(content.style.width).toBe("90ch");
expect(content.style.fontFamily).toContain("ui-monospace");
expect(content.className).not.toContain("text-[8px]");
// OPR.0.4.0.39 FR-5: the tmux capture is already pane-width-wrapped, so the
// static <pre> uses whitespace-pre (no re-wrap, mirroring the live fixed-
// geometry xterm) - NOT whitespace-pre-wrap/break-words (which double-wrapped).
expect(content.className).toContain("whitespace-pre");
expect(content.className).not.toContain("whitespace-pre-wrap");
expect(content.className).not.toContain("break-words");
// OPR.0.4.0.39 FR-1 (founder spec-correction): the static content is translucent
// smoked-GLASS (bg-transparent; the SMOKED_STATIC_PLATE_CLASS plate shows
// through). Opaque #0c0a09 is the LIVE xterm only; the glass->opaque flip on
// click-to-live is the static-vs-live activation affordance.
expect(content.className).toContain("bg-transparent");
expect(content.className).not.toContain("bg-[#0c0a09]");
expect(content.className).toContain("scrollbar-none");
expect(content.className).toContain("overflow-x-hidden");
expect(content.className).not.toContain("break-all");
expect(screen.queryByText(/live preview/i)).toBeNull();
expect(screen.queryByText(/captured/i)).toBeNull();
@@ -0,0 +1,67 @@
// OPR.0.4.0.39 FR-1 - the shared static-terminal component. The preview internals
// are mocked; this asserts the plate/button structure, the smoke plate, and the
// testid passthrough that the two callers (grid thumbnail + ProgressiveTerminal
// static) depend on.
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import {
StaticTerminalPlate,
SMOKED_STATIC_PLATE_CLASS,
} from "../src/components/terminal/StaticTerminalPlate.js";
vi.mock("../src/components/preview/SessionPreviewPane.js", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
SessionPreviewPane: ({ sessionName, variant, testIdPrefix }: any) => (
<div
data-testid={testIdPrefix ? `${testIdPrefix}-pane` : "preview"}
data-variant={variant}
data-session={sessionName}
/>
),
}));
describe("StaticTerminalPlate (OPR.0.4.0.39 FR-1)", () => {
it("renders a NON-interactive plate (div) carrying the smoke plate + compact preview", () => {
render(
<StaticTerminalPlate
sessionName="dev@rig"
plateTestId="x-thumb-plate"
previewTestIdPrefix="x-thumb"
className="min-w-0 flex-1 overflow-hidden"
/>,
);
const plate = screen.getByTestId("x-thumb-plate");
expect(plate.tagName).toBe("DIV");
expect(plate.className).toContain("bg-stone-950/85");
expect(plate.className).toContain("backdrop-blur-sm");
expect(plate.className).toContain("overflow-hidden"); // caller className composed in
const pane = screen.getByTestId("x-thumb-pane");
expect(pane.getAttribute("data-variant")).toBe("compact-terminal");
expect(pane.getAttribute("data-session")).toBe("dev@rig");
});
it("renders an INTERACTIVE click-to-live button when onClick is provided", () => {
const onClick = vi.fn();
render(
<StaticTerminalPlate
sessionName="dev@rig"
plateTestId="pt-static"
previewTestIdPrefix="pt-preview"
onClick={onClick}
ariaLabel="go live"
title="Click to go live"
/>,
);
const btn = screen.getByTestId("pt-static");
expect(btn.tagName).toBe("BUTTON");
expect(btn.className).toContain("bg-stone-950/85");
expect(btn.getAttribute("aria-label")).toBe("go live");
fireEvent.click(btn);
expect(onClick).toHaveBeenCalledOnce();
expect(screen.getByTestId("pt-preview-pane")).toBeTruthy();
});
it("exports the shared smoke-plate class", () => {
expect(SMOKED_STATIC_PLATE_CLASS).toBe("bg-stone-950/85 backdrop-blur-sm");
});
});
@@ -54,21 +54,21 @@ describe("Progressive terminal popovers coexist under the global cap (rev1-r2 fi
expect(screen.getByTestId("live-b@r")).toBeTruthy();
});
it("rev1-r2 fix: the popover shell is COMPACT when static and WIDENS to fit the live plate", () => {
it("OPR.0.4.0.39 (founder spec, REVERSES rev1-r2 reshape): the popover holds the full-terminal width for BOTH static and live (no reshape on go-live)", () => {
render(
<LiveTerminalProvider cap={2}>
<TerminalPreviewPopover rigId="r1" logicalId="a" sessionName="a@r" testIdPrefix="pa" progressive />
</LiveTerminalProvider>,
);
// open the popover -> STATIC -> shell is compact (NOT the wide live width), so
// the static preview stays small.
// open the popover -> STATIC -> shell is already the full-terminal width (the
// static is the 90-col mirror, not a small compact preview).
fireEvent.click(screen.getByTestId("pa-terminal-open"));
expect(screen.getByTestId("pa-terminal-popover").className).toContain("w-[calc(80ch+24px)]");
expect(screen.getByTestId("pa-terminal-popover").className).not.toContain("w-[904px]");
// click inside -> LIVE -> the shell WIDENS to fit the 880px live plate (the
// overflow-hidden clip is gone).
expect(screen.getByTestId("pa-terminal-popover").className).toContain("w-max");
expect(screen.getByTestId("pa-terminal-popover").className).not.toContain("w-[calc(80ch+24px)]");
// click inside -> LIVE -> SAME width (no reshape / relocation; the static just
// flips glass->opaque in place - the founder's mirror requirement).
fireEvent.click(screen.getByTestId("pa-static"));
expect(screen.getByTestId("pa-terminal-popover").className).toContain("w-[904px]");
expect(screen.getByTestId("pa-terminal-popover").className).toContain("w-max");
expect(screen.getByTestId("pa-terminal-popover").className).not.toContain("w-[calc(80ch+24px)]");
});
@@ -41,7 +41,7 @@ describe("OPR.0.4.0.1 terminal styling polish", () => {
);
const staticBtn = screen.getByTestId("t-static");
// the bare-surface static view carries its OWN smoked-glass plate (FR-4) ...
expect(staticBtn.className).toContain("bg-stone-950/60");
expect(staticBtn.className).toContain("bg-stone-950/85");
expect(staticBtn.className).toContain("backdrop-blur-sm");
// ... and is BORDERLESS (FR-2 floating plate, not a bordered box)
expect(staticBtn.className).not.toContain("border");
@@ -51,12 +51,18 @@ describe("OPR.0.4.0.1 terminal styling polish", () => {
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 LIVE_TERMINAL_RENDER_BACKGROUND = "#0c0a09"');
expect(s).toContain("const LIVE_TERMINAL_COLS = 120");
expect(s).toContain("const LIVE_TERMINAL_ROWS = 40");
// OPR.0.4.0.39: the geometry constants moved to terminal-geometry.ts (the single
// source of truth shared by the static<->live mirror). FocusedTerminal imports +
// uses them; the opaque / 90x27 / lineHeight-1 contract is unchanged.
const geo = src("../src/components/terminal/terminal-geometry.ts");
expect(geo).toContain('export const LIVE_TERMINAL_RENDER_BACKGROUND = "#0c0a09"');
expect(geo).toContain("export const LIVE_TERMINAL_COLS = 90");
expect(geo).toContain("export const LIVE_TERMINAL_ROWS = 27");
expect(geo).toContain("export const LIVE_TERMINAL_LINE_HEIGHT = 1");
expect(s).toContain('from "./terminal-geometry.js"');
expect(s).toContain("cols: LIVE_TERMINAL_COLS");
expect(s).toContain("rows: LIVE_TERMINAL_ROWS");
expect(s).toContain("lineHeight: 1");
expect(s).toContain("lineHeight: LIVE_TERMINAL_LINE_HEIGHT");
expect(s).toContain("applyOpaqueTerminalBackground(containerRef.current!)");
expect(s).toContain("scrollTerminalViewportToPrompt(containerRef.current!)");
expect(s).toContain("term.scrollToBottom();");
@@ -72,20 +78,61 @@ describe("OPR.0.4.0.1 terminal styling polish", () => {
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", () => {
it("OPR.0.4.0.39 (founder spec): the popover sizes to the canonical geometry (w-max shell, LIVE_TERMINAL_COLS-ch inner), no hardcoded plate width, no 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 -> live wrapper supplies the plate
// The shell sizes to its content (w-max) and the inner is the canonical geometry
// width (LIVE_TERMINAL_COLS ch) - tracks the column count, no reshape, no loose
// empty width, no hardcoded 880/904 plate.
expect(s).toContain("w-max");
expect(s).toContain("LIVE_TERMINAL_COLS");
expect(s).not.toContain("w-[880px]");
expect(s).not.toContain("w-[904px]");
expect(s).not.toContain("bg-stone-950/65"); // live wrapper supplies the plate
expect(s).toContain("backdrop-blur-sm");
});
it("FR-5: the topology grid card mounts the TerminalPreviewPopover trigger, NOT a live-in-place ProgressiveTerminal", () => {
it("OPR.0.4.0.39 (founder spec, REVERSES slice-01 FR-5): the topology grid card mounts the in-place ProgressiveTerminal, NOT the TerminalPreviewPopover trigger", () => {
// Founder live-review reversed the popover-expand-out for the grid (and all
// surfaces): the static IS the in-place click-to-live target.
const s = src("../src/components/topology/TopologyTerminalView.tsx");
expect(s).toContain("TerminalPreviewPopover");
expect(s).not.toContain('import { ProgressiveTerminal }');
expect(s).toContain("import { ProgressiveTerminal }");
expect(s).not.toContain("TerminalPreviewPopover");
});
it("OPR.0.4.0.39 (node-detail fit): ScaleToFitTerminal has a 'contain' mode (fill both axes, capped upscale, centered) that the node-detail panel opts into; the grid keeps fit-width", () => {
const scaler = src("../src/components/terminal/ScaleToFitTerminal.tsx");
// contain fits BOTH axes (min of width/height ratios), allows a CAPPED upscale,
// and centers - so a big dedicated panel is filled, not left small top-left.
expect(scaler).toContain('fit?: "width" | "contain"');
expect(scaler).toContain("MAX_CONTAIN_SCALE");
expect(scaler).toContain("availableHeight / naturalHeight");
expect(scaler).toContain("items-center justify-center");
// ProgressiveTerminal forwards `fit` to BOTH the static + live scalers (mirror).
const prog = src("../src/components/terminal/ProgressiveTerminal.tsx");
expect(prog).toContain('fit?: "width" | "contain"');
expect((prog.match(/fit=\{fit\}/g) ?? []).length).toBeGreaterThanOrEqual(2);
// The node-detail panel (big 500px area) opts into contain; the grid does NOT
// (it keeps the default fit-width so cells never upscale).
expect(src("../src/components/LiveNodeDetails.tsx")).toContain('fit="contain"');
expect(src("../src/components/topology/TopologyTerminalView.tsx")).not.toContain('fit="contain"');
});
it("OPR.0.4.0.39 (selection fix #6023): the LIVE xterm scales via fontSize, NOT a CSS transform, so xterm selection/click hit-testing stays native-correct", () => {
const focused = src("../src/components/terminal/FocusedTerminal.tsx");
// FocusedTerminal sizes the live xterm by SETTING term fontSize to fit its
// container (no CSS transform on the xterm) - the maintainer-blessed fix for #6023.
expect(focused).toContain('fit?: "natural" | "width" | "contain"');
expect(focused).toContain("options.fontSize");
expect(focused).toContain("MAX_FIT_UPSCALE");
expect(focused).toContain("#6023");
// The live xterm must NOT be CSS-transform-scaled (that's what breaks selection).
expect(focused).not.toContain("transform: `scale");
expect(focused).not.toContain("transformOrigin");
// ProgressiveTerminal's LIVE branch renders FocusedTerminal with fit (fontSize),
// NOT wrapped in the transform-based ScaleToFitTerminal (static plate only).
const prog = src("../src/components/terminal/ProgressiveTerminal.tsx");
expect(prog).toContain("<FocusedTerminal sessionName={sessionName} fit={fit}");
// ScaleToFitTerminal (CSS transform) is still imported + used - for the STATIC plate.
expect(prog).toContain("ScaleToFitTerminal");
});
});
@@ -0,0 +1,54 @@
// OPR.0.4.0.39 FR-2/3/4 - source guards for the topology terminal grid:
// responsive 1/2/3-col, the legibility-floored CSS scale-down, and tightened
// padding. Source-guards (the exact visual result is QA-screenshot-validated).
import { describe, it, expect } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
const RAW = fs.readFileSync(
path.resolve(import.meta.dirname, "../src/components/topology/TopologyTerminalView.tsx"),
"utf-8",
);
// Source-guard on CODE, not comments: strip /* */ (incl JSX {/* */}) and // line
// comments so a comment that legitimately NAMES a removed token (e.g. "no separate
// TerminalPreviewPopover trigger", "no overflow-hidden") does not trip the negative
// assertions below.
const SRC = RAW.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
describe("TopologyTerminalView grid (OPR.0.4.0.39 FR-2/3/4)", () => {
it("FR-2 (founder: 2 columns MAXIMUM): 1-col narrow / 2-col wider, NO 3-col", () => {
expect(SRC).toContain("grid-cols-1");
expect(SRC).toContain("md:grid-cols-2");
// 3-col was dropped - at 3-across the scaled 90-col terminal is too small to
// read; 2-col keeps each cell wide enough for legible terminals.
expect(SRC).not.toContain("grid-cols-3");
});
it("FR-3: fit-width is delegated to the shared scaler (ProgressiveTerminal -> ScaleToFitTerminal), not a hardcoded grid scale", () => {
// The grid must NOT hardcode a per-breakpoint CSS scale anymore; ProgressiveTerminal
// wraps both static + live in ScaleToFitTerminal, which measures the fixed 90-col
// block and scales it to fit each cell width (fit-width, never clip).
expect(SRC).not.toContain("origin-top-left");
expect(SRC).not.toContain("xl:scale-90");
const PROG = fs.readFileSync(
path.resolve(import.meta.dirname, "../src/components/terminal/ProgressiveTerminal.tsx"),
"utf-8",
);
expect(PROG).toContain("ScaleToFitTerminal");
});
it("FR-4: NO white card wrapper + tightened padding/gaps (founder spec-correction)", () => {
expect(SRC).toContain("p-1.5");
expect(SRC).toContain("gap-1.5");
// the white card wrapper is REMOVED entirely (bg-white/40 was too contrasty),
// not merely retuned; the looser gap-3 grid gap is gone too.
expect(SRC).not.toContain("bg-white/40");
expect(SRC).not.toContain("gap-3");
});
it("FR-1/FR-6: the static IS the in-place click-to-live target (ProgressiveTerminal), no separate popover trigger", () => {
expect(SRC).toContain("ProgressiveTerminal");
// the grid no longer reaches live via a separate expand-out popover trigger.
expect(SRC).not.toContain("TerminalPreviewPopover");
});
});