Multi-agent audit + aggressive cleanup across server/web/coder/booterm, delivered behind a DEFER discipline so none of the in-flight files were touched. Removes dead code/deps/columns, dedups server + coder helpers, and splits the oversized modules (tools.ts, opencode-server.ts, sentinel-summaries, turn.ts, TerminalPane.tsx) behind stable contracts. Adds 78 parity/unit tests (server 587, coder 323); fixes two latent bugs (ChatPane queue keys, FileViewerOverlay blank-line parity). Intended tag: v2.7.12-audit-cleanup.
199 lines
8.0 KiB
TypeScript
199 lines
8.0 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Terminal } from '@xterm/xterm';
|
|
import { FitAddon } from '@xterm/addon-fit';
|
|
import { SearchAddon } from '@xterm/addon-search';
|
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
import '@xterm/xterm/css/xterm.css';
|
|
import { RefreshCw } from 'lucide-react';
|
|
import { useTerminalFit } from '@/hooks/terminal/useTerminalFit';
|
|
import { useTerminalSocket } from '@/hooks/terminal/useTerminalSocket';
|
|
import { useTerminalSelection } from '@/hooks/terminal/useTerminalSelection';
|
|
import { TerminalHotkeyBar } from '@/components/panes/terminal/TerminalHotkeyBar';
|
|
import { FloatingMenu } from '@/components/panes/terminal/FloatingMenu';
|
|
import { SearchBar } from '@/components/panes/terminal/SearchBar';
|
|
import { TERM_BG, XTERM_THEME } from '@/components/panes/terminal/theme';
|
|
|
|
// Architecture invariants (do not regress without reading the comments
|
|
// next to each):
|
|
//
|
|
// - Resize is in-band on the WebSocket as a JSON text frame. The HTTP
|
|
// /resize endpoint had a race with PTY-map registration; WS frames
|
|
// don't. (lib/terminal-protocol.ts owns the wire encoding.)
|
|
// - Server `init` text frame triggers term.clear() so a stale buffer
|
|
// can't leak through the capture-pane replay.
|
|
// - DOM renderer only (no @xterm/addon-webgl). The WebGL atlas bakes
|
|
// glyphs via Canvas2D at load time and locks against whatever font
|
|
// is registered then; @fontsource JBM Variable subsets don't cover
|
|
// U+2500-25FF, so the atlas baked against the system fallback and
|
|
// opencode/claude banners rendered garbled. DOM renderer uses real
|
|
// text spans, so the browser's text rasterizer handles unicode
|
|
// fallback per-character — boolab pattern, just works.
|
|
// - Refit on document.fonts.ready (post-open) so xterm's cell metrics
|
|
// don't bake against the system fallback. Boolab pattern: rAF inside
|
|
// fonts.ready, plus a 150ms safety-net setTimeout. (useTerminalFit.)
|
|
//
|
|
// Decomposition (v2 Phase 9): this orchestrator constructs/disposes the
|
|
// Terminal (spine effect) and composes three hooks —
|
|
// - useTerminalFit: measurement + the pure-fit refit triggers.
|
|
// - useTerminalSocket: WS lifecycle, term I/O handlers, bootstrap,
|
|
// visibility reconnect, sticky Ctrl.
|
|
// - useTerminalSelection: touch long-press, context menu, clipboard,
|
|
// custom keys, registries, menu actions.
|
|
// The spine effect is registered FIRST so termRef is populated before the
|
|
// hooks' effects run on mount. (On unmount that means term.dispose() runs
|
|
// before the hooks' cleanups — verified safe: no hook cleanup touches the
|
|
// terminal, and the WS message handler null-guards on termRef.)
|
|
|
|
interface Props {
|
|
sessionId: string;
|
|
paneId: string;
|
|
label: string;
|
|
active?: boolean;
|
|
}
|
|
|
|
export function TerminalPane({ sessionId, paneId, label, active = false }: Props) {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const termRef = useRef<Terminal | null>(null);
|
|
const searchRef = useRef<SearchAddon | null>(null);
|
|
|
|
// Spine: construct + dispose the Terminal. Registered before the hooks so
|
|
// termRef/searchRef are set when their effects run on mount. NOTE: `label`
|
|
// is intentionally NOT a dependency — it is positional ("Terminal N") and
|
|
// renumbers on pane add/remove; keeping it out of these deps stops the live
|
|
// terminal from tearing down + reconnecting on a cosmetic renumber. The
|
|
// terminal registry's label is refreshed by useTerminalSelection's
|
|
// [paneId, label] effect instead.
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
// On mobile viewports drop to 11px so opencode/claude get enough cols.
|
|
const fontSize = typeof window !== 'undefined' && window.innerWidth < 768 ? 11 : 13;
|
|
const term = new Terminal({
|
|
convertEol: true,
|
|
cursorBlink: true,
|
|
allowProposedApi: true,
|
|
fontFamily:
|
|
"'JetBrains Mono Variable', 'JetBrains Mono', 'Fira Code', Menlo, monospace",
|
|
fontSize,
|
|
// Locked cell metrics so DOM-renderer rows line up: integer-multiple
|
|
// line height and zero letter spacing keep glyph spans cell-aligned.
|
|
// globals.css enforces the same with !important so an inherited rule
|
|
// can't fight us.
|
|
lineHeight: 1.0,
|
|
letterSpacing: 0,
|
|
scrollback: 10_000,
|
|
fastScrollModifier: 'shift',
|
|
altClickMovesCursor: false,
|
|
theme: XTERM_THEME,
|
|
});
|
|
termRef.current = term;
|
|
const fitAddon = new FitAddon();
|
|
const search = new SearchAddon();
|
|
searchRef.current = search;
|
|
term.loadAddon(fitAddon);
|
|
term.loadAddon(search);
|
|
term.loadAddon(new WebLinksAddon());
|
|
term.open(container);
|
|
|
|
return () => {
|
|
try {
|
|
term.dispose();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
termRef.current = null;
|
|
searchRef.current = null;
|
|
};
|
|
}, [sessionId, paneId]);
|
|
|
|
const fit = useTerminalFit({ termRef, containerRef, sessionId, paneId });
|
|
const socket = useTerminalSocket({
|
|
termRef,
|
|
sessionId,
|
|
paneId,
|
|
fit: fit.fit,
|
|
getSize: fit.getSize,
|
|
setSize: fit.setSize,
|
|
});
|
|
const selection = useTerminalSelection({
|
|
termRef,
|
|
containerRef,
|
|
sessionId,
|
|
paneId,
|
|
label,
|
|
send: socket.send,
|
|
});
|
|
|
|
// Refit when this pane becomes the visible/focused one (e.g. mobile pane
|
|
// switch, maximize toggle). Routes through useTerminalFit().fit so the
|
|
// visualViewport keyboard clip is applied consistently (v2 Phase 9 dedup —
|
|
// previously this effect re-implemented the cell-metric→resize math WITHOUT
|
|
// the keyboard clip). term.onResize sends the resize WS frame if cols or
|
|
// rows actually changed.
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
const raf = requestAnimationFrame(() => fit.fit());
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [active, fit.fit]);
|
|
|
|
return (
|
|
<div className="relative flex flex-col flex-1 min-w-0 self-stretch w-full h-full bg-[#0b0f14]">
|
|
{/* xterm CSS overrides live in src/styles/globals.css (boolab pattern).
|
|
Putting them in an inline <style> here races the upstream xterm.css
|
|
on first paint — the right-edge #000 stripe survives the override. */}
|
|
<TerminalHotkeyBar
|
|
ctrlArmed={socket.ctrlArmed}
|
|
onSendBytes={socket.send}
|
|
onArmCtrl={socket.armCtrl}
|
|
onFit={fit.fit}
|
|
/>
|
|
<div
|
|
ref={containerRef}
|
|
className="flex-1 min-h-0 w-full overflow-hidden"
|
|
style={{ touchAction: 'pan-y', background: TERM_BG }}
|
|
data-testid="terminal-pane"
|
|
/>
|
|
{selection.searchOpen && (
|
|
<SearchBar
|
|
searchRef={searchRef}
|
|
theme={XTERM_THEME}
|
|
onClose={() => selection.setSearchOpen(false)}
|
|
/>
|
|
)}
|
|
{selection.menu && (
|
|
<FloatingMenu
|
|
x={selection.menu.x}
|
|
y={selection.menu.y}
|
|
hasSelection={selection.hasSelection}
|
|
chatInputs={selection.chatInputs}
|
|
onCopy={selection.actions.copy}
|
|
onPaste={selection.actions.paste}
|
|
onSelectAll={selection.actions.selectAll}
|
|
onSearch={selection.actions.search}
|
|
onSendToChat={selection.actions.sendToChat}
|
|
onDismiss={() => selection.setMenu(null)}
|
|
/>
|
|
)}
|
|
{socket.connState === 'reconnecting' && (
|
|
<div className="absolute inset-x-0 top-0 bg-amber-900/85 text-amber-100 text-xs px-3 py-1 flex items-center gap-2 pointer-events-none">
|
|
<RefreshCw size={12} className="animate-spin" />
|
|
<span>Reconnecting…</span>
|
|
</div>
|
|
)}
|
|
{socket.connState === 'disconnected' && (
|
|
<div className="absolute inset-x-0 top-0 bg-destructive/85 text-destructive-foreground text-xs px-3 py-1 flex items-center gap-2">
|
|
<span>Disconnected from terminal server.</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => socket.reconnect()}
|
|
className="ml-auto inline-flex items-center gap-1 rounded bg-background/20 hover:bg-background/30 px-2 py-0.5"
|
|
>
|
|
<RefreshCw size={12} /> Reconnect
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|