mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
Merge OPR.0.4.0.38 founder forward-fix: terminal local-trust + opaque render + 120x40 geometry pin + test fixes
This commit is contained in:
commit
212b4523f9
13 files changed
+276
-85
No files matched your search
@@ -4,6 +4,9 @@ import { createDaemon } from "./startup.js";
|
||||
import {
|
||||
assertBindAuthInvariant,
|
||||
detectTailscaleInterface,
|
||||
isLoopbackBind,
|
||||
isTailscaleBind,
|
||||
resolveToIpOrNull,
|
||||
} from "./middleware/auth-bearer-token.js";
|
||||
|
||||
/** OPR.0.3.4.9 — extracted for testability. Starts the periodic snapshot
|
||||
@@ -23,6 +26,16 @@ export function startPeriodicSnapshotScheduler(deps: {
|
||||
deps.psProjectionService.setPeriodicSnapshotState(true, intervalS);
|
||||
}
|
||||
|
||||
async function isTrustedLocalOrTailnetBind(host: string): Promise<boolean> {
|
||||
if (isLoopbackBind(host)) return true;
|
||||
if (isTailscaleBind(host)) return true;
|
||||
if (!/^[\d.]+$/.test(host) && !host.includes(":")) {
|
||||
const resolvedIp = await resolveToIpOrNull(host);
|
||||
if (resolvedIp) return isLoopbackBind(resolvedIp) || isTailscaleBind(resolvedIp);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function startServer(port?: number) {
|
||||
const p = port ?? parseInt(readOpenRigEnv("OPENRIG_PORT", "RIGGED_PORT") ?? "7433", 10);
|
||||
const dbPath = readOpenRigEnv("OPENRIG_DB", "RIGGED_DB") ?? "openrig.sqlite";
|
||||
@@ -39,10 +52,15 @@ export async function startServer(port?: number) {
|
||||
const bearerToken = process.env.OPENRIG_AUTH_BEARER_TOKEN ?? null;
|
||||
|
||||
let bindHosts: string[];
|
||||
const terminalTokenEnv = process.env.OPENRIG_TERMINAL_BEARER_TOKEN?.trim();
|
||||
let terminalBearerToken: string | null = terminalTokenEnv || null;
|
||||
if (explicitHost) {
|
||||
// Operator opt-in path — invariant enforces bearer requirement for
|
||||
// genuinely public/LAN binds; loopback/tailscale binds short-circuit.
|
||||
await assertBindAuthInvariant({ host: explicitHost, bearerToken });
|
||||
if (!terminalBearerToken && !(await isTrustedLocalOrTailnetBind(explicitHost))) {
|
||||
terminalBearerToken = bearerToken;
|
||||
}
|
||||
bindHosts = [explicitHost];
|
||||
} else {
|
||||
// Default path — loopback always, plus tailscale auto-add when active.
|
||||
@@ -50,7 +68,11 @@ export async function startServer(port?: number) {
|
||||
bindHosts = tailscaleIp ? ["127.0.0.1", tailscaleIp] : ["127.0.0.1"];
|
||||
}
|
||||
|
||||
const { app, contextMonitor, deps, injectWebSocket } = await createDaemon({ dbPath, bearerToken });
|
||||
const { app, contextMonitor, deps, injectWebSocket } = await createDaemon({
|
||||
dbPath,
|
||||
bearerToken,
|
||||
terminalBearerToken,
|
||||
});
|
||||
|
||||
// Multi-bind via N serve() instances sharing the same Hono app.
|
||||
// Hono's serve() targets a single host:port; for multi-bind we spawn
|
||||
|
||||
@@ -31,7 +31,7 @@ import { validateResumeToken } from "../domain/resume-token-validation.js";
|
||||
|
||||
function terminalAuthGuard(): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const token = c.get("terminalBearerToken" as never) as string | null;
|
||||
const token = (c.get("terminalBearerToken" as never) as string | null | undefined) ?? null;
|
||||
const mw = authBearerTokenMiddleware({ expectedToken: token });
|
||||
return mw(c, next);
|
||||
};
|
||||
|
||||
@@ -168,6 +168,13 @@ interface DaemonOptions {
|
||||
* comparison + 401 on missing/mismatch.
|
||||
*/
|
||||
bearerToken?: string | null;
|
||||
/**
|
||||
* Terminal bearer token for live-terminal routes. Null means the
|
||||
* daemon bind posture is already trusted (loopback/tailnet), matching
|
||||
* the Mission Control auth boundary. Non-null enforces bearer auth on
|
||||
* terminal preview/transport/websocket routes.
|
||||
*/
|
||||
terminalBearerToken?: string | null;
|
||||
}
|
||||
|
||||
interface DaemonResult {
|
||||
@@ -1032,26 +1039,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
deps.missionControlBearerToken = opts?.bearerToken ?? null;
|
||||
|
||||
const terminalTokenEnv = process.env.OPENRIG_TERMINAL_BEARER_TOKEN?.trim();
|
||||
if (terminalTokenEnv) {
|
||||
deps.terminalBearerToken = terminalTokenEnv;
|
||||
} else {
|
||||
const { randomBytes } = await import("node:crypto");
|
||||
const nodePath = await import("node:path");
|
||||
const nodeFs = await import("node:fs");
|
||||
const homeDir = process.env.OPENRIG_HOME ?? nodePath.default.join(process.env.HOME ?? "", ".openrig");
|
||||
const tokenPath = nodePath.default.join(homeDir, "terminal-token");
|
||||
try {
|
||||
const existing = nodeFs.default.readFileSync(tokenPath, "utf-8").trim();
|
||||
if (existing) { deps.terminalBearerToken = existing; }
|
||||
} catch {}
|
||||
if (!deps.terminalBearerToken) {
|
||||
deps.terminalBearerToken = randomBytes(32).toString("hex");
|
||||
try {
|
||||
nodeFs.default.mkdirSync(homeDir, { recursive: true });
|
||||
nodeFs.default.writeFileSync(tokenPath, deps.terminalBearerToken, { mode: 0o600 });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
deps.terminalBearerToken =
|
||||
opts && Object.prototype.hasOwnProperty.call(opts, "terminalBearerToken")
|
||||
? opts.terminalBearerToken ?? null
|
||||
: terminalTokenEnv || null;
|
||||
|
||||
// Notification dispatcher: chosen mechanism via env config.
|
||||
// OPENRIG_NOTIFICATIONS_MECHANISM=ntfy|webhook|none (default none).
|
||||
|
||||
@@ -115,6 +115,28 @@ export function screenSnapshotEscape(
|
||||
return `\x1b[2J${paintedRows}${cursor ? cursorPositionEscape(cursor.x, cursor.y) : "\x1b[H"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw pipe output from a full-screen TUI is a repaint stream, not durable
|
||||
* scrollback. Replaying cursor-addressed history into a fresh xterm before the
|
||||
* current snapshot causes stale prompt/status rows to appear above or under the
|
||||
* real screen. Preserve the AC-5 shared-history behavior for plain line output
|
||||
* (and simple SGR color), but skip history that contains cursor movement,
|
||||
* erase, alternate-screen, OSC, or carriage-return repaint semantics.
|
||||
*/
|
||||
export function isSafeHistoryReplay(data: string): boolean {
|
||||
if (!data) return false;
|
||||
if (/\r(?!\n)/.test(data)) return false;
|
||||
|
||||
const escapePattern = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[\(\)][ -~]|[@-Z\\-_])/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = escapePattern.exec(data)) !== null) {
|
||||
const sequence = match[0];
|
||||
const final = sequence.at(-1);
|
||||
if (final !== "m") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One broker per live tmux session. Owns a single pipe-pane and fans its output
|
||||
* out to every attached subscriber.
|
||||
@@ -319,13 +341,14 @@ export class TerminalSessionBroker {
|
||||
private async seed(sub: TerminalSubscriber): Promise<void> {
|
||||
// AC-5: replay the broker-owned recent-output ring FIRST so the late
|
||||
// subscriber's xterm builds the same scrollback the earlier subscribers
|
||||
// have (raw bytes write into xterm's scrollback). The visible-screen seed
|
||||
// then paints a cursor-correct current screen on top - screenSnapshotEscape
|
||||
// opens with ESC[2J, which clears only the VISIBLE viewport, NOT the
|
||||
// scrollback, so the recent scrolled-off output is preserved above. The
|
||||
// first subscriber's ring is empty, so it gets only the visible seed.
|
||||
// have for plain terminal streams. Cursor-addressed TUI repaint history is
|
||||
// not scrollback; replaying it corrupts late subscribers, so those sessions
|
||||
// seed from the current visible-screen snapshot only.
|
||||
if (this.historyBytes > 0) {
|
||||
try { sub.send(this.history.join("")); } catch { /* dead subscriber */ }
|
||||
const history = this.history.join("");
|
||||
if (isSafeHistoryReplay(history)) {
|
||||
try { sub.send(history); } catch { /* dead subscriber */ }
|
||||
}
|
||||
}
|
||||
// Best-effort: a failed capture (or an adapter without the seed methods)
|
||||
// must never break the attach - the tail still streams live output.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const serveMock = vi.fn();
|
||||
const createDaemonMock = vi.fn();
|
||||
@@ -15,7 +15,23 @@ describe("daemon startServer", () => {
|
||||
beforeEach(() => {
|
||||
serveMock.mockReset();
|
||||
createDaemonMock.mockReset();
|
||||
createDaemonMock.mockResolvedValue({ app: { fetch: vi.fn() }, contextMonitor: { start: vi.fn(), stop: vi.fn() } });
|
||||
createDaemonMock.mockResolvedValue({
|
||||
app: { fetch: vi.fn() },
|
||||
contextMonitor: { start: vi.fn(), stop: vi.fn() },
|
||||
deps: {},
|
||||
injectWebSocket: vi.fn(),
|
||||
});
|
||||
delete process.env.OPENRIG_HOST;
|
||||
delete process.env.RIGGED_HOST;
|
||||
delete process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
delete process.env.OPENRIG_TERMINAL_BEARER_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OPENRIG_HOST;
|
||||
delete process.env.RIGGED_HOST;
|
||||
delete process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
delete process.env.OPENRIG_TERMINAL_BEARER_TOKEN;
|
||||
});
|
||||
|
||||
it("binds the daemon to loopback", async () => {
|
||||
@@ -28,4 +44,29 @@ describe("daemon startServer", () => {
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("does not require a terminal bearer token for the default loopback bind", async () => {
|
||||
const { startServer } = await import("../src/index.js");
|
||||
|
||||
await startServer(7441);
|
||||
|
||||
expect(createDaemonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ terminalBearerToken: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the daemon bearer token for terminal routes on explicit public binds", async () => {
|
||||
process.env.OPENRIG_HOST = "0.0.0.0";
|
||||
process.env.OPENRIG_AUTH_BEARER_TOKEN = "daemon-token";
|
||||
const { startServer } = await import("../src/index.js");
|
||||
|
||||
await startServer(7441);
|
||||
|
||||
expect(createDaemonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bearerToken: "daemon-token",
|
||||
terminalBearerToken: "daemon-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,33 @@ describe("createDaemon startup composition", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("defaults terminal auth to local-trusted mode without minting a token file", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-terminal-auth-"));
|
||||
const priorHome = process.env.OPENRIG_HOME;
|
||||
const priorToken = process.env.OPENRIG_TERMINAL_BEARER_TOKEN;
|
||||
process.env.OPENRIG_HOME = tmpDir;
|
||||
delete process.env.OPENRIG_TERMINAL_BEARER_TOKEN;
|
||||
const cmuxFactory: CmuxTransportFactory = async () => {
|
||||
throw Object.assign(new Error(""), { code: "ENOENT" });
|
||||
};
|
||||
const tmuxExec: ExecFn = async () => "";
|
||||
|
||||
try {
|
||||
const { db, deps } = await createDaemon({ cmuxFactory, tmuxExec });
|
||||
|
||||
expect(deps.terminalBearerToken).toBeNull();
|
||||
expect(fs.existsSync(path.join(tmpDir, "terminal-token"))).toBe(false);
|
||||
|
||||
db.close();
|
||||
} finally {
|
||||
if (priorHome === undefined) delete process.env.OPENRIG_HOME;
|
||||
else process.env.OPENRIG_HOME = priorHome;
|
||||
if (priorToken === undefined) delete process.env.OPENRIG_TERMINAL_BEARER_TOKEN;
|
||||
else process.env.OPENRIG_TERMINAL_BEARER_TOKEN = priorToken;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("createDaemon app: GET /api/rigs/:rigId/sessions returns 200 (session routes mounted)", async () => {
|
||||
const cmuxFactory: CmuxTransportFactory = async () => {
|
||||
throw Object.assign(new Error(""), { code: "ENOENT" });
|
||||
|
||||
@@ -438,6 +438,31 @@ describe("TerminalSessionBroker - shared history ring (AC-5)", () => {
|
||||
expect(startPipePane).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("a LATE subscriber skips unsafe TUI repaint history and receives the current screen snapshot", async () => {
|
||||
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({
|
||||
capturePaneScreen: async () => "CURRENT SCREEN",
|
||||
getPaneCursorPosition: async () => ({ x: 0, y: 0, width: 120, height: 40 }),
|
||||
}), { pollMs: 10 }));
|
||||
const a = makeSub();
|
||||
await broker.attach(a);
|
||||
|
||||
fs.appendFileSync(
|
||||
broker.pipeOutputPath!,
|
||||
"\x1b[2J\x1b[12;1HSTALE TUI PROMPT\x1b[13;1Hoverpainted status",
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(a.received.join("")).toContain("STALE TUI PROMPT");
|
||||
}, { timeout: 1000 });
|
||||
|
||||
const b = makeSub();
|
||||
await broker.attach(b);
|
||||
const bSeed = b.received.join("");
|
||||
|
||||
expect(bSeed).not.toContain("STALE TUI PROMPT");
|
||||
expect(bSeed).not.toContain("overpainted status");
|
||||
expect(bSeed).toContain("CURRENT SCREEN");
|
||||
});
|
||||
|
||||
it("the history ring is bounded under sustained output", async () => {
|
||||
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux(), { pollMs: 5, maxHistoryBytes: 2048 }));
|
||||
await broker.attach(makeSub());
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { readTerminalBearerToken } from "../mission-control/missionControlAuth.js";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
@@ -33,7 +32,9 @@ const ESCAPE_SEQ_MAP: Record<string, string> = {
|
||||
"\x1b[2~": "IC",
|
||||
};
|
||||
|
||||
const SMOKED_TERMINAL_BACKGROUND = "rgba(12,10,9,0.6)";
|
||||
const LIVE_TERMINAL_RENDER_BACKGROUND = "#0c0a09";
|
||||
const LIVE_TERMINAL_COLS = 120;
|
||||
const LIVE_TERMINAL_ROWS = 40;
|
||||
|
||||
type WsMessage = { type: "keys"; keys: string[] } | { type: "text"; text: string };
|
||||
|
||||
@@ -79,6 +80,41 @@ export function mapXtermInput(data: string): WsMessage[] {
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function applyOpaqueTerminalBackground(container: HTMLElement): void {
|
||||
const surfaces = [
|
||||
container,
|
||||
container.querySelector<HTMLElement>(".xterm"),
|
||||
container.querySelector<HTMLElement>(".xterm-screen"),
|
||||
container.querySelector<HTMLElement>(".xterm-viewport"),
|
||||
container.querySelector<HTMLElement>(".xterm-rows"),
|
||||
];
|
||||
for (const surface of surfaces) {
|
||||
if (surface) surface.style.backgroundColor = LIVE_TERMINAL_RENDER_BACKGROUND;
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollTerminalViewportToPrompt(container: HTMLElement): void {
|
||||
const scroll = () => {
|
||||
const cursor = container.querySelector<HTMLElement>("textarea.xterm-helper-textarea");
|
||||
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
if (!cursor) {
|
||||
container.scrollTop = maxScrollTop;
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedCursorTop = Number.parseFloat(cursor.style.top);
|
||||
const cursorTop = Number.isFinite(parsedCursorTop) ? parsedCursorTop : cursor.offsetTop;
|
||||
const lineHeight = cursor.offsetHeight || 14;
|
||||
const cursorBottom = cursorTop + lineHeight;
|
||||
const desiredScrollTop = cursorBottom - container.clientHeight + lineHeight * 3;
|
||||
container.scrollTop = Math.min(maxScrollTop, Math.max(0, desiredScrollTop));
|
||||
};
|
||||
|
||||
scroll();
|
||||
window.requestAnimationFrame(scroll);
|
||||
window.setTimeout(scroll, 50);
|
||||
}
|
||||
|
||||
interface FocusedTerminalProps {
|
||||
sessionName: string;
|
||||
daemonBaseUrl?: string;
|
||||
@@ -88,17 +124,25 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<unknown>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitAddonRef = useRef<unknown>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const generationRef = useRef(0);
|
||||
const promptScrollUntilRef = useRef(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const disposeTerminal = useCallback(() => {
|
||||
const term = termRef.current as { dispose(): void } | null;
|
||||
term?.dispose();
|
||||
termRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
}, []);
|
||||
|
||||
const scrollLiveTerminalToPrompt = useCallback((term: { scrollToBottom(): void } | null) => {
|
||||
if (term) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
if (containerRef.current) {
|
||||
scrollTerminalViewportToPrompt(containerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connectForGeneration = useCallback((gen: number) => {
|
||||
@@ -110,19 +154,21 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
|
||||
|
||||
ws.onopen = () => {
|
||||
if (generationRef.current !== gen) { ws.close(); return; }
|
||||
// OPR.0.4.0.38 FR-7: fit the CONTAINER for scroll/pan only; do NOT send a
|
||||
// resize. The daemon broker owns fixed canonical geometry (120x40) shared
|
||||
// across every viewer - a client-driven resize would shrink the shared
|
||||
// pane for everyone else. The seed arrives from the broker on attach.
|
||||
const fitAddon = fitAddonRef.current as { fit(): void } | null;
|
||||
fitAddon?.fit();
|
||||
// The daemon broker owns fixed canonical geometry (120x40). The client
|
||||
// must keep the same grid and let the UI scroll/pan smaller surfaces.
|
||||
promptScrollUntilRef.current = Date.now() + 2500;
|
||||
const term = termRef.current as { scrollToBottom(): void } | null;
|
||||
scrollLiveTerminalToPrompt(term);
|
||||
};
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
if (generationRef.current !== gen) return;
|
||||
const term = termRef.current as { write(data: string): void } | null;
|
||||
const term = termRef.current as { write(data: string): void; scrollToBottom(): void } | null;
|
||||
if (typeof evt.data === "string" && term) {
|
||||
term.write(evt.data);
|
||||
if (Date.now() <= promptScrollUntilRef.current) {
|
||||
scrollLiveTerminalToPrompt(term);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,7 +193,7 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
|
||||
|
||||
wsRef.current = ws;
|
||||
return ws;
|
||||
}, [sessionName, daemonBaseUrl, disposeTerminal]);
|
||||
}, [sessionName, daemonBaseUrl, disposeTerminal, scrollLiveTerminalToPrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -155,7 +201,6 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
|
||||
generationRef.current++;
|
||||
const currentGen = generationRef.current;
|
||||
let cleanedUp = false;
|
||||
let resizeObs: ResizeObserver | undefined;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@@ -163,32 +208,27 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cols: LIVE_TERMINAL_COLS,
|
||||
rows: LIVE_TERMINAL_ROWS,
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
// OPR.0.4.0.1 styling polish (FR-1): the live terminal CONTENT carries
|
||||
// its OWN translucent smoked-black tint (stone-950 #0c0a09 at ~0.6 alpha)
|
||||
// so it reads as a floating smoked-glass plate on EVERY surface -- incl.
|
||||
// the truly-bare topology-tab + grid-popover surfaces that have no plate
|
||||
// behind them -- not only over the popover/shell backdrop-blur. Foreground
|
||||
// stays OPAQUE (#e0e0e0) so text is fully legible (AC-4 hard constraint);
|
||||
// alpha is the starting point, tuned toward opaque by QA if a busy backdrop
|
||||
// ever fights crispness. allowTransparency is required for a non-opaque bg.
|
||||
theme: { background: SMOKED_TERMINAL_BACKGROUND, foreground: "#e0e0e0", cursor: "#e0e0e0" },
|
||||
allowTransparency: true,
|
||||
// xterm erase/redraw needs an opaque cell background. A translucent
|
||||
// xterm render surface lets old TUI cells bleed through after clear
|
||||
// screen / absolute cursor repaint, which corrupts Claude/Codex views.
|
||||
theme: { background: LIVE_TERMINAL_RENDER_BACKGROUND, foreground: "#e0e0e0", cursor: "#e0e0e0" },
|
||||
allowTransparency: false,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(containerRef.current!);
|
||||
// xterm's viewport keeps its own default black background outside the
|
||||
// theme-painted row layer. Keep it aligned with the smoked live content
|
||||
// so live terminals do not regress to an opaque black panel.
|
||||
const viewport = containerRef.current!.querySelector<HTMLElement>(".xterm-viewport");
|
||||
if (viewport) viewport.style.backgroundColor = SMOKED_TERMINAL_BACKGROUND;
|
||||
fitAddon.fit();
|
||||
// Some xterm DOM layers do not inherit the theme background. Pin every
|
||||
// render layer opaque so clear/erase operations actually erase.
|
||||
applyOpaqueTerminalBackground(containerRef.current!);
|
||||
term.focus();
|
||||
promptScrollUntilRef.current = Date.now() + 2500;
|
||||
scrollTerminalViewportToPrompt(containerRef.current!);
|
||||
termRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
term.onData((data: string) => {
|
||||
const wsc = wsRef.current;
|
||||
@@ -200,13 +240,10 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
|
||||
});
|
||||
|
||||
// OPR.0.4.0.38 FR-7: no term.onResize -> ws resize relay. The pane
|
||||
// geometry is fixed daemon-side; the client only fits its container
|
||||
// viewport (scroll/pan) and never asks the pane to resize.
|
||||
// geometry is fixed daemon-side; the client grid matches it exactly
|
||||
// and never asks the pane to resize.
|
||||
|
||||
connectForGeneration(currentGen);
|
||||
|
||||
resizeObs = new ResizeObserver(() => { fitAddon.fit(); });
|
||||
resizeObs.observe(containerRef.current!);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Terminal initialization failed");
|
||||
}
|
||||
@@ -217,7 +254,6 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
|
||||
mountedRef.current = false;
|
||||
generationRef.current++;
|
||||
if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; }
|
||||
resizeObs?.disconnect();
|
||||
const activeWs = wsRef.current;
|
||||
if (activeWs) { activeWs.close(); wsRef.current = null; }
|
||||
disposeTerminal();
|
||||
@@ -243,7 +279,7 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
|
||||
key={`focused-terminal-live-${sessionName}`}
|
||||
ref={containerRef}
|
||||
data-testid={`focused-terminal-${sessionName}`}
|
||||
className="h-full w-full min-h-[200px]"
|
||||
className="h-full w-full min-h-[200px] overflow-auto bg-stone-950/60 backdrop-blur-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -222,11 +222,10 @@ export function TerminalPreviewPopover({
|
||||
data-terminal-preview-surface=""
|
||||
data-reduced-motion={reducedMotion ? "true" : "false"}
|
||||
className={cn(
|
||||
// OPR.0.4.0.1 (FR-4 de-dup): the terminal CONTENT now carries its own
|
||||
// smoked-glass tint (FocusedTerminal bg / the static smoked plate), so the
|
||||
// popover drops its redundant opaque smoked background to stay
|
||||
// transparent-glassy (not double-tinted toward opaque). The backdrop-blur
|
||||
// stays for the floating-plate effect over the page behind it.
|
||||
// OPR.0.4.0.1 (FR-4 de-dup): the terminal wrapper/static plate carry the
|
||||
// smoked-glass surface, while the xterm renderer itself stays opaque for
|
||||
// reliable erase/redraw. The popover drops its redundant bg so the wrapper
|
||||
// remains the single terminal plate.
|
||||
"nodrag nopan fixed z-[1000] max-h-[calc(100vh-1rem)] max-w-[calc(100vw-1rem)] overflow-hidden p-1.5 backdrop-blur-sm",
|
||||
// OPR.0.4.0.1 (rev1-r2 fix): widen the shell to fit the live plate (880px +
|
||||
// padding) when live so overflow-hidden no longer clips it; stay compact for
|
||||
|
||||
@@ -21,8 +21,12 @@ describe("FocusedTerminal lifecycle", () => {
|
||||
// The broker owns fixed canonical geometry; the client must NOT send a
|
||||
// resize (it would shrink the shared pane for every other viewer).
|
||||
expect(src).not.toMatch(/type:\s*["']resize["']/);
|
||||
// FitAddon still fits the CONTAINER (scroll/pan), just without a resize relay.
|
||||
expect(src).toContain("fitAddon");
|
||||
// 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
|
||||
// removed entirely (no container-fit resize-fight), which is the stronger
|
||||
// form of fixed-geometry mirroring.
|
||||
expect(src).not.toContain("FitAddon");
|
||||
expect(src).toContain("cols: LIVE_TERMINAL_COLS");
|
||||
});
|
||||
|
||||
it("source guard: cleanup closes wsRef.current not local ws", async () => {
|
||||
@@ -47,7 +51,10 @@ describe("FocusedTerminal lifecycle", () => {
|
||||
expect(src).toContain("[disconnected - reconnecting...]");
|
||||
expect(src).toContain("mountedRef.current");
|
||||
expect(src).toMatch(/setTimeout\(\s*\(\)\s*=>\s*\{/);
|
||||
expect(src).toContain("connect()");
|
||||
// The reconnect schedules connectForGeneration(gen). (The prior
|
||||
// toContain("connect()") was a false-positive match on resizeObs.disconnect()
|
||||
// - now removed with FitAddon - so it is corrected to the real reconnect call.)
|
||||
expect(src).toContain("connectForGeneration(gen)");
|
||||
});
|
||||
|
||||
it("source guard: cleanup sets mountedRef false and clears reconnect timer", async () => {
|
||||
|
||||
@@ -40,6 +40,11 @@ vi.mock("@xterm/xterm", () => ({
|
||||
write(data: string) { terminalWrites.push(data); }
|
||||
onData(_cb: (data: string) => void) {}
|
||||
onResize(_cb: (size: { cols: number; rows: number }) => void) {}
|
||||
// The real xterm Terminal exposes focus() + scrollToBottom(); FocusedTerminal
|
||||
// calls both (focus on open, scrollToBottom in the scroll-to-prompt path).
|
||||
// Stub them so the component's setup does not throw in the mock.
|
||||
focus() {}
|
||||
scrollToBottom() {}
|
||||
dispose() {
|
||||
terminalDisposeCount++;
|
||||
this.child?.remove();
|
||||
|
||||
@@ -211,9 +211,9 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
|
||||
expect(driverPopover.className).toContain("max-w-[calc(100vw-1rem)]");
|
||||
expect(driverPopover.className).toContain("max-h-[calc(100vh-1rem)]");
|
||||
expect(driverPopover.className).toContain("overflow-hidden");
|
||||
// OPR.0.4.0.1 (FR-4): the popover dropped its redundant bg-stone-950/65 -- the
|
||||
// terminal CONTENT now carries its own smoked-glass tint, so the popover stays
|
||||
// transparent-glassy (backdrop-blur retained) instead of double-tinted opaque.
|
||||
// OPR.0.4.0.1 (FR-4): the popover dropped its redundant bg-stone-950/65; the
|
||||
// live terminal wrapper supplies the smoked plate while xterm's renderer stays
|
||||
// opaque enough for reliable erase/redraw.
|
||||
expect(driverPopover.className).not.toContain("bg-stone-950/65");
|
||||
expect(driverPopover.className).toContain("font-mono");
|
||||
expect(driverPopover.className).toContain("text-[8px]");
|
||||
|
||||
@@ -49,22 +49,37 @@ describe("OPR.0.4.0.1 terminal styling polish", () => {
|
||||
expect(screen.getByTestId("preview-a@r").getAttribute("data-variant")).toBe("compact-terminal");
|
||||
});
|
||||
|
||||
it("FR-1: the LIVE terminal CONTENT carries its own translucent smoked tint (not the plate-dependent rgba(0,0,0,0))", () => {
|
||||
it("FR-1: the LIVE terminal keeps an opaque xterm render surface so erase/redraw is cursor-safe", () => {
|
||||
const s = src("../src/components/terminal/FocusedTerminal.tsx");
|
||||
expect(s).toContain('const SMOKED_TERMINAL_BACKGROUND = "rgba(12,10,9,0.6)"'); // stone-950 at ~0.6 alpha
|
||||
expect(s).toContain("viewport.style.backgroundColor = SMOKED_TERMINAL_BACKGROUND");
|
||||
expect(s).toContain('const LIVE_TERMINAL_RENDER_BACKGROUND = "#0c0a09"');
|
||||
expect(s).toContain("const LIVE_TERMINAL_COLS = 120");
|
||||
expect(s).toContain("const LIVE_TERMINAL_ROWS = 40");
|
||||
expect(s).toContain("cols: LIVE_TERMINAL_COLS");
|
||||
expect(s).toContain("rows: LIVE_TERMINAL_ROWS");
|
||||
expect(s).toContain("lineHeight: 1");
|
||||
expect(s).toContain("applyOpaqueTerminalBackground(containerRef.current!)");
|
||||
expect(s).toContain("scrollTerminalViewportToPrompt(containerRef.current!)");
|
||||
expect(s).toContain("term.scrollToBottom();");
|
||||
expect(s).toContain('querySelector<HTMLElement>("textarea.xterm-helper-textarea")');
|
||||
expect(s).toContain("const desiredScrollTop = cursorBottom - container.clientHeight + lineHeight * 3");
|
||||
expect(s).toContain("container.scrollTop = Math.min(maxScrollTop, Math.max(0, desiredScrollTop))");
|
||||
expect(s).toContain("term.focus();");
|
||||
expect(s).toContain("allowTransparency: false");
|
||||
expect(s).not.toContain("allowTransparency: true");
|
||||
expect(s).not.toContain("FitAddon");
|
||||
expect(s).not.toContain("fitAddon.fit");
|
||||
expect(s).not.toContain('background: "rgba(0,0,0,0)"');
|
||||
expect(s).toContain('foreground: "#e0e0e0"'); // text stays OPAQUE (AC-4 legibility)
|
||||
});
|
||||
|
||||
it("FR-3/FR-4: the popover sets the measured LIVE width + drops its redundant opaque bg (content self-tints)", () => {
|
||||
it("FR-3/FR-4: the popover sets the measured LIVE width + drops its redundant opaque bg", () => {
|
||||
const s = src("../src/components/topology/TerminalPreviewPopover.tsx");
|
||||
expect(s).toContain("w-[880px]"); // the LIVE inner sizer (optimal width)
|
||||
// OPR.0.4.0.1 (rev1-r2 fix): the shell widens to fit the 880px live plate so
|
||||
// overflow-hidden no longer clips it (the prior fixed compact shell did).
|
||||
expect(s).toContain("w-[904px]");
|
||||
expect(s).not.toContain("w-[820px]");
|
||||
expect(s).not.toContain("bg-stone-950/65"); // dropped -> transparent-glassy
|
||||
expect(s).not.toContain("bg-stone-950/65"); // dropped -> live wrapper supplies the plate
|
||||
expect(s).toContain("backdrop-blur-sm");
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user