mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
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:
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)]");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user