From dfb20a734e48d86b392c2b0c1f1fa86934a42bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 31 Jul 2026 10:26:57 +0000 Subject: [PATCH] terminals: resize with the panel, and five xterm addons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bun.lock | 15 ++ package.json | 5 + .../_middlewares/origin-validation.test.ts | 5 + .../officerdev/src/apps/Terminal/Terminal.tsx | 188 +++++++++++++++++- 4 files changed, 211 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index bc4e828f..7a92982d 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index a7c5d968..93064ad6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/servers/_middlewares/origin-validation.test.ts b/src/servers/_middlewares/origin-validation.test.ts index f2fa8492..52d8b94a 100644 --- a/src/servers/_middlewares/origin-validation.test.ts +++ b/src/servers/_middlewares/origin-validation.test.ts @@ -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'); diff --git a/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx b/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx index 8e4963f8..71824c41 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx @@ -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 = { @@ -79,10 +91,15 @@ export const TerminalView = ({ onDisconnect, onPanelRefresh, onConnectionChange, + onTitleChange, + onBell, }: TerminalViewProps) => { const containerRef = useRef(null); const termRef = useRef(null); const wsRef = useRef(null); + const searchRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); const isMounted = useMounted(); const onReadyRef = useRef(onReady); const onExitRef = useRef(onExit); @@ -102,6 +119,11 @@ export const TerminalView = ({ commandRef.current = command; initialInputRef.current = initialInput; + const onTitleChangeRef = useRef(onTitleChange); + const onBellRef = useRef(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 | 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 ( -
+
+ {searchOpen && ( +
+ { + 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, + }} + /> + + + +
+ )}
); };