fix(terminal): pin live xterm to broker canonical 120x40 + scroll to cursor row on activation (remove FitAddon)

OPR.0.4.0.38 forward-fix. Authored + verified live in the dev1-qa dogfood loop;
committed by dev2-driver (the Codex authoring seat is usage-capped). This is the
exact dogfood-verified state.

- Remove FitAddon (+ its ResizeObserver/fit() container-fit): the live xterm no
  longer resizes to the container. Instead the terminal is created at the broker
  canonical geometry cols=120 rows=40 lineHeight=1, and the container scrolls/pans
  (overflow-auto) on smaller surfaces. This is the cleaner realization of the
  OPR.0.4.0.38 FR-7 fixed-geometry contract: the client mirrors the broker pane
  exactly instead of fitting (which mismatched wrap when the container was narrower).
- scrollTerminalViewportToPrompt(): on activation (and for a 2.5s settle window as
  seed/output lands) scroll the viewport so the cursor/prompt row is visible -
  finds textarea.xterm-helper-textarea, computes the cursor row, clamps to
  [0, maxScrollTop]. This is the founder-verified "scrollback is fixed" behavior.
- Live xterm render surface stays OPAQUE (#0c0a09, from the prior repaint-stabilize
  commit) so erase/redraw is cursor-safe. NOTE for OPR.0.4.0.39: the opaque
  #0c0a09 live look is the new static mirror-target.

terminal-styling-polish.test.tsx updated to assert the new source (120x40 pin,
lineHeight 1, scroll-to-prompt helpers, FitAddon removed). LOCAL only, no push.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.8 committed 2026-06-21 19:30:36 -07:00
1 parent cba0a1824a
commit ada55f3009
2 files changed
+64 -23

No files matched your search

@@ -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";
@@ -34,6 +33,8 @@ const ESCAPE_SEQ_MAP: Record<string, string> = {
};
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 };
@@ -92,6 +93,28 @@ export function applyOpaqueTerminalBackground(container: HTMLElement): void {
}
}
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;
@@ -101,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) => {
@@ -123,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);
}
}
};
@@ -160,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;
@@ -168,7 +201,6 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
generationRef.current++;
const currentGen = generationRef.current;
let cleanedUp = false;
let resizeObs: ResizeObserver | undefined;
(async () => {
try {
@@ -176,7 +208,10 @@ 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",
// xterm erase/redraw needs an opaque cell background. A translucent
// xterm render surface lets old TUI cells bleed through after clear
@@ -186,16 +221,14 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
allowProposedApi: true,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(containerRef.current!);
// Some xterm DOM layers do not inherit the theme background. Pin every
// render layer opaque so clear/erase operations actually erase.
applyOpaqueTerminalBackground(containerRef.current!);
fitAddon.fit();
term.focus();
promptScrollUntilRef.current = Date.now() + 2500;
scrollTerminalViewportToPrompt(containerRef.current!);
termRef.current = term;
fitAddonRef.current = fitAddon;
term.onData((data: string) => {
const wsc = wsRef.current;
@@ -207,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");
}
@@ -224,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();
@@ -250,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] bg-stone-950/60 backdrop-blur-sm"
className="h-full w-full min-h-[200px] overflow-auto bg-stone-950/60 backdrop-blur-sm"
/>
);
}
@@ -52,10 +52,22 @@ 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");
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)
});