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:
@@ -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=="],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user