terminals: resize with the panel, and five xterm addons

The terminal was xterm with one addon (fit) and a single fit() call at connect, so the
shell kept whatever cols/rows it was born with. Drag a panel wider and the PTY never
heard about it — anything drawing a full screen (an agent TUI, top, vim) rendered into a
box that no longer matched the one you were looking at. A ResizeObserver now refits and
sends pty:resize, coalesced to one fit per frame because dragging an edge fires
continuously and each fit reflows.

Scrollback goes from xterm's default 1000 lines to 10,000 — one long agent turn was
enough to lose the start of it.

Addons, all off the shelf and none previously loaded:

- webgl — the renderer, and the reason fast repainting feels smooth rather than syrupy.
  Guarded twice: construction can throw where there is no GL context, and the context can
  be lost later, so both paths fall back to the DOM renderer instead of a dead canvas.
- unicode11 (+ allowProposedApi) — correct widths for emoji, CJK and box-drawing. The
  default unicode 6 tables are why agent TUI frames sit a column out.
- web-links — URLs in output are clickable.
- search — with a find bar on Ctrl/Cmd-Shift-F. Not plain Ctrl-F: that is forward-one-char
  in readline and emacs, and swallowing it would break every shell in the app.
- serialize — installed for exact-state replay, not yet wired.

Also reports the shell's own title (OSC 0/2) and the bell through new optional callbacks,
so a panel can show what is running in it and flag a finished turn you weren't watching.
Nothing consumes them yet.

Unrelated but found by this run: three origin-validation tests were failing. Not from this
change — ALLOW_ANY_ORIGIN=true in .env, added last night, and Bun loads the host .env into
tests, so the kill switch had silently turned the assertions into no-ops. The test now pins
both escape hatches off, since its whole job is asserting the checks reject things.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 10:26:57 +00:00
co-authored by Claude Opus 5
parent 188b45c113
commit dfb20a734e
4 changed files with 211 additions and 2 deletions
+15
View File
@@ -56,6 +56,11 @@
"@visual-json/core": "^0.1.1",
"@visual-json/react": "^0.1.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"argon2": "^0.44.0",
"bech32": "2.0.0",
@@ -1012,6 +1017,16 @@
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
"@xterm/addon-search": ["@xterm/addon-search@0.16.0", "", {}, "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="],
"@xterm/addon-serialize": ["@xterm/addon-serialize@0.14.0", "", {}, "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA=="],
"@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="],
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
"@xterm/addon-webgl": ["@xterm/addon-webgl@0.19.0", "", {}, "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.8", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA=="],
+5
View File
@@ -87,6 +87,11 @@
"@visual-json/core": "^0.1.1",
"@visual-json/react": "^0.1.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"argon2": "^0.44.0",
"bech32": "2.0.0",
@@ -3,6 +3,11 @@ import { test, expect } from 'bun:test';
// origin-validation reads PUBLIC_URL / PUBLIC_BUILD_ENV at module load, so set them before importing.
process.env.PUBLIC_URL = 'https://officer.example.com';
process.env.PUBLIC_BUILD_ENV = 'production';
// Bun loads the host's .env into tests, so an operational kill switch left on there would silently turn
// these assertions into no-ops — which is exactly what ALLOW_ANY_ORIGIN=true did. Pin the escape hatches
// off: this file's whole job is asserting that the checks reject things.
process.env.ALLOW_ANY_ORIGIN = 'false';
process.env.ALLOW_ANY_ORIGIN_MUSIC = 'false';
const { isOriginAllowed } = await import('./origin-validation');
@@ -1,10 +1,18 @@
import type { CSSProperties } from 'react';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Terminal as XTerm } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { SearchAddon } from '@xterm/addon-search';
import '@xterm/xterm/css/xterm.css';
import { useMounted } from 'hooks/useMounted';
// Scrollback the browser keeps. The sidecar keeps its own replay buffer for re-attach; this is what you
// can scroll back to within a live session, and 1000 (xterm's default) is about one long agent turn.
const SCROLLBACK_LINES = 10_000;
type TerminalTheme = {
background?: string;
foreground?: string;
@@ -32,6 +40,10 @@ export type TerminalViewProps = {
onDisconnect?: () => void;
onPanelRefresh?: () => void;
onConnectionChange?: (state: TerminalConnectionState) => void;
/** The shell's own title (OSC 0/2) — what's actually running, for the panel header. */
onTitleChange?: (title: string) => void;
/** The shell rang the bell. Useful to flag a finished agent turn on a panel you aren't watching. */
onBell?: () => void;
};
const DEFAULT_THEME: Required<TerminalTheme> = {
@@ -79,10 +91,15 @@ export const TerminalView = ({
onDisconnect,
onPanelRefresh,
onConnectionChange,
onTitleChange,
onBell,
}: TerminalViewProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const searchRef = useRef<SearchAddon | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const isMounted = useMounted();
const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady);
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
@@ -102,6 +119,11 @@ export const TerminalView = ({
commandRef.current = command;
initialInputRef.current = initialInput;
const onTitleChangeRef = useRef<TerminalViewProps['onTitleChange']>(onTitleChange);
const onBellRef = useRef<TerminalViewProps['onBell']>(onBell);
onTitleChangeRef.current = onTitleChange;
onBellRef.current = onBell;
const background = theme?.background ?? DEFAULT_THEME.background;
const foreground = theme?.foreground ?? DEFAULT_THEME.foreground;
const cursor = theme?.cursor ?? DEFAULT_THEME.cursor;
@@ -121,6 +143,8 @@ export const TerminalView = ({
rows: 24,
fontSize,
fontFamily,
scrollback: SCROLLBACK_LINES,
allowProposedApi: true, // required by the unicode11 addon
theme: {
background,
foreground,
@@ -131,12 +155,45 @@ export const TerminalView = ({
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
// Correct display widths for emoji, CJK and box-drawing. Agent TUIs lean on all three, and the
// default (unicode 6) tables are what make their frames look one column out.
const unicode11 = new Unicode11Addon();
term.loadAddon(unicode11);
term.unicode.activeVersion = '11';
// Clickable URLs in output.
term.loadAddon(new WebLinksAddon());
const searchAddon = new SearchAddon();
term.loadAddon(searchAddon);
searchRef.current = searchAddon;
term.open(container);
// WebGL renderer — the reason fast-repainting output feels smooth instead of syrupy. It can fail on
// machines with no GL context, and the context can be lost later (GPU reset, tab backgrounded too
// long), so both paths fall back to the DOM renderer rather than leaving a dead canvas.
let webgl: WebglAddon | null = null;
try {
webgl = new WebglAddon();
webgl.onContextLoss(() => {
webgl?.dispose();
webgl = null;
});
term.loadAddon(webgl);
} catch {
webgl = null;
}
if (autoFocus) term.focus();
termRef.current = term;
onReadyRef.current?.(term);
const titleDisposable = term.onTitleChange((title) => onTitleChangeRef.current?.(title));
const bellDisposable = term.onBell(() => onBellRef.current?.());
// Reconnect state
let reconnectAttempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -276,6 +333,17 @@ export const TerminalView = ({
}
});
// Ctrl/Cmd-Shift-F opens the find bar. Deliberately NOT plain Ctrl-F: that is "forward one character"
// in readline and emacs, and swallowing it would break every shell in the app. Returning false stops
// xterm forwarding the chord to the PTY.
term.attachCustomKeyEventHandler((ev) => {
if (ev.type === 'keydown' && ev.shiftKey && (ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 'f') {
setSearchOpen(true);
return false;
}
return true;
});
// Reconnect when tab becomes visible again
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && !disposed && !processExited) {
@@ -287,6 +355,29 @@ export const TerminalView = ({
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Refit on any panel resize and tell the PTY the new size. Without this the shell keeps the cols/rows
// it was born with, so anything drawing a full screen — an agent TUI, top, vim — renders into a box
// that no longer matches the one you're looking at. fit() used to run once, at connect.
let resizeFrame = 0;
const applyFit = () => {
if (disposed) return;
try {
fitAddon.fit();
} catch {
return; // container has no layout yet — the initial double-rAF path will fit it
}
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
};
// Coalesced to one fit per frame: dragging a panel edge fires this continuously and each fit reflows.
const resizeObserver = new ResizeObserver(() => {
cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(applyFit);
});
resizeObserver.observe(container);
// Wait for layout to fully settle (double rAF), then fit + connect
requestAnimationFrame(() => {
requestAnimationFrame(() => {
@@ -296,6 +387,12 @@ export const TerminalView = ({
const originalCleanup = () => {
dataDisposable.dispose();
titleDisposable.dispose();
bellDisposable.dispose();
resizeObserver.disconnect();
cancelAnimationFrame(resizeFrame);
webgl?.dispose();
searchRef.current = null;
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (reconnectTimer) clearTimeout(reconnectTimer);
const wsCleanup = (container as any).__terminalCleanup as (() => void) | undefined;
@@ -329,9 +426,96 @@ export const TerminalView = ({
autoFocus,
]);
const closeSearch = () => {
searchRef.current?.clearDecorations();
setSearchOpen(false);
setSearchTerm('');
termRef.current?.focus();
};
const runSearch = (value: string, direction: 'next' | 'previous') => {
const addon = searchRef.current;
if (!addon || !value) return;
const opts = {
decorations: {
activeMatchBackground: cursor,
matchBackground: selectionBackground,
// Overview-ruler marks: the scrollbar-side hints showing where matches sit in the scrollback.
activeMatchColorOverviewRuler: cursor,
matchOverviewRuler: selectionBackground,
},
};
if (direction === 'next') addon.findNext(value, opts);
else addon.findPrevious(value, opts);
};
return (
<div className={className} style={{ backgroundColor: background, overflow: 'hidden', ...style }}>
<div
className={className}
style={{ backgroundColor: background, overflow: 'hidden', position: 'relative', ...style }}
>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
{searchOpen && (
<div
style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '4px 6px',
borderRadius: 6,
background: background,
border: `1px solid ${selectionBackground}`,
boxShadow: '0 2px 8px rgba(0,0,0,0.35)',
zIndex: 5,
}}
>
<input
autoFocus
value={searchTerm}
placeholder="Find"
onChange={(ev) => {
setSearchTerm(ev.target.value);
runSearch(ev.target.value, 'next');
}}
onKeyDown={(ev) => {
if (ev.key === 'Enter') runSearch(searchTerm, ev.shiftKey ? 'previous' : 'next');
if (ev.key === 'Escape') closeSearch();
}}
style={{
background: 'transparent',
border: 'none',
outline: 'none',
color: foreground,
fontSize: 12,
width: 140,
}}
/>
<button
type="button"
onClick={() => runSearch(searchTerm, 'previous')}
style={{ background: 'transparent', border: 'none', color: foreground, cursor: 'pointer' }}
>
</button>
<button
type="button"
onClick={() => runSearch(searchTerm, 'next')}
style={{ background: 'transparent', border: 'none', color: foreground, cursor: 'pointer' }}
>
</button>
<button
type="button"
onClick={closeSearch}
style={{ background: 'transparent', border: 'none', color: foreground, cursor: 'pointer' }}
>
</button>
</div>
)}
</div>
);
};