Files
boocode/apps/web/src/components/panes/TerminalPane.tsx
indifferentketchup abe1a311d0 refactor: codebase audit cleanup — dead code, dedup, module splits
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.
2026-06-02 21:12:29 +00:00

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>
);
}