Files
boocode/apps/web/src/components/panes/TerminalPane.tsx
2026-05-20 14:56:02 +00:00

1299 lines
43 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } 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 { ChevronDown, ChevronUp, Maximize2, RefreshCw, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import {
chatInputsRegistry,
sendToChat,
sendToTerminal,
terminalsRegistry,
type ChatInputRegistration,
} from '@/lib/events';
import { cn } from '@/lib/utils';
// 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.
// - 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.
interface Props {
sessionId: string;
paneId: string;
label: string;
active?: boolean;
}
// Terminal background matches the pane container's `bg-[#0b0f14]` so any
// sub-cell rounding remainder is invisible. Update both if the theme changes.
const TERM_BG = '#0b0f14';
const XTERM_THEME = {
background: TERM_BG,
foreground: '#d6deeb',
cursor: '#82aaff',
selectionBackground: '#1d3b53',
black: '#011627',
red: '#ef5350',
green: '#22da6e',
yellow: '#c5e478',
blue: '#82aaff',
magenta: '#c792ea',
cyan: '#7fdbca',
white: '#d6deeb',
brightBlack: '#575656',
brightRed: '#ef5350',
brightGreen: '#22da6e',
brightYellow: '#ffeb95',
brightBlue: '#82aaff',
brightMagenta: '#c792ea',
brightCyan: '#7fdbca',
brightWhite: '#ffffff',
};
type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected';
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000];
const LONG_PRESS_MS = 500;
const LONG_PRESS_TOLERANCE_PX = 10;
// xterm 5 ships no public dimensions API — `_core._renderService.dimensions`
// is internal but stable across the package versions we ship.
function cellSize(term: Terminal, container: HTMLElement): { w: number; h: number } {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dims = (term as any)._core?._renderService?.dimensions?.css?.cell;
if (dims && dims.width > 0 && dims.height > 0) {
return { w: dims.width, h: dims.height };
}
const rect = container.getBoundingClientRect();
return { w: rect.width / Math.max(term.cols, 1), h: rect.height / Math.max(term.rows, 1) };
}
function pointToCell(
term: Terminal,
container: HTMLElement,
clientX: number,
clientY: number,
): { col: number; bufferRow: number } {
const rect = container.getBoundingClientRect();
const { w, h } = cellSize(term, container);
const localX = Math.max(0, clientX - rect.left);
const localY = Math.max(0, clientY - rect.top);
const col = Math.min(term.cols - 1, Math.floor(localX / Math.max(w, 1)));
const screenRow = Math.min(term.rows - 1, Math.floor(localY / Math.max(h, 1)));
const bufferRow = term.buffer.active.viewportY + screenRow;
return { col, bufferRow };
}
const WORD_RE = /[\w.~$\-/]+/g;
function wordRangeAt(line: string, col: number): { start: number; end: number } | null {
for (const m of line.matchAll(WORD_RE)) {
const start = m.index ?? 0;
const end = start + m[0].length;
if (col >= start && col < end) return { start, end };
if (start > col) return null;
}
return null;
}
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);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectAttemptRef = useRef(0);
const closedByUserRef = useRef(false);
// Last size pushed through term.onResize. Mirrors xterm's authoritative
// cols/rows; consulted on every WS open so the resize frame carries the
// currently-measured size, not a stale lastSize from before the latest fit.
const lastSizeRef = useRef<{ cols: number; rows: number }>({ cols: 80, rows: 24 });
const reconnectFnRef = useRef<() => void>(() => {});
const [connState, setConnState] = useState<ConnState>('connecting');
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [chatInputs, setChatInputs] = useState<ChatInputRegistration[]>([]);
const setMenuRef = useRef(setMenu);
setMenuRef.current = setMenu;
const setSearchOpenRef = useRef(setSearchOpen);
setSearchOpenRef.current = setSearchOpen;
const setChatInputsRef = useRef(setChatInputs);
setChatInputsRef.current = setChatInputs;
// v1.10.8d: sticky Ctrl for the on-screen hotkey bar (boolab pattern).
// ctrlArmedRef is read synchronously inside term.onData (per keystroke);
// ctrlArmed state mirror exists so the Ctrl button can highlight in React.
// Auto-disarms after 5s so a stray tap doesn't quietly mangle the next
// keystroke minutes later.
const [ctrlArmed, setCtrlArmed] = useState(false);
const ctrlArmedRef = useRef(false);
const ctrlTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setCtrlArmedSync = useCallback((armed: boolean) => {
ctrlArmedRef.current = armed;
setCtrlArmed(armed);
if (ctrlTimerRef.current) {
clearTimeout(ctrlTimerRef.current);
ctrlTimerRef.current = null;
}
if (armed) {
ctrlTimerRef.current = setTimeout(() => {
ctrlArmedRef.current = false;
setCtrlArmed(false);
ctrlTimerRef.current = null;
}, 5000);
}
}, []);
const armCtrl = useCallback(() => {
setCtrlArmedSync(!ctrlArmedRef.current);
}, [setCtrlArmedSync]);
// sendInput: write to the WS as a binary frame (server-side discriminator
// routes binary to PTY, text to JSON control). Used by the hotkey bar.
const sendInput = useCallback((text: string) => {
if (!text) return;
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(new TextEncoder().encode(text));
} catch {
/* ignore */
}
}, []);
// fitFull lives inside the main useEffect (closure over `disposed`/`ctr`);
// expose via a ref so the hotkey bar's fit button and the visualViewport
// resize handler can reach it.
const fitFullRef = useRef<() => void>(() => {});
const triggerFit = useCallback(() => {
fitFullRef.current();
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const ctr: HTMLDivElement = container;
let disposed = false;
closedByUserRef.current = false;
reconnectAttemptRef.current = 0;
// 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 fit = new FitAddon();
const search = new SearchAddon();
searchRef.current = search;
term.loadAddon(fit);
term.loadAddon(search);
term.loadAddon(new WebLinksAddon());
term.open(ctr);
// Bypass FitAddon's proposeDimensions(), which subtracts the native
// scrollbar's reserved width even after CSS hides it (boolab fix). We
// compute cols/rows directly from host.clientWidth/Height ÷ cell metrics.
// Falls through (no-op) if cell metrics aren't measured yet — a later
// refit (fonts.ready / setTimeout / ResizeObserver) will catch it.
//
// v1.10.8d: row count uses the VISIBLE height (clipped against the
// visualViewport) rather than raw clientHeight. h-dvh on the root only
// shrinks for the URL bar — not the iOS keyboard — so without the clip
// we allocate rows for the area hidden behind the keyboard, and bash's
// cursor (pushed down by MOTD on SSH login) ends up below the fold.
const fitFull = (): void => {
if (disposed) return;
const t = termRef.current;
if (!t) return;
if (!t.element || !(t.element as HTMLElement).offsetParent) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const core: any = (t as any)._core;
const cellW: number | undefined = core?._renderService?.dimensions?.css?.cell?.width;
const cellH: number | undefined = core?._renderService?.dimensions?.css?.cell?.height;
if (!cellW || !cellH || cellW <= 0 || cellH <= 0) return;
let visibleHeight = ctr.clientHeight;
const vp2 = typeof window !== 'undefined' ? window.visualViewport : null;
if (vp2) {
const rect = ctr.getBoundingClientRect();
// visualViewport.height is the layout viewport minus the keyboard.
// rect.bottom is in layout-viewport coordinates. clip to whichever
// is smaller. offsetTop/pageTop adjustments aren't needed at zoom=1.
const visibleBottom = Math.min(rect.bottom, vp2.height);
const clipped = Math.max(0, visibleBottom - rect.top);
if (clipped > 0) visibleHeight = clipped;
}
const cols = Math.max(2, Math.floor(ctr.clientWidth / cellW));
const rows = Math.max(1, Math.floor(visibleHeight / cellH));
if (cols !== t.cols || rows !== t.rows) {
try {
t.resize(cols, rows);
} catch {
/* ignore */
}
}
};
fitFullRef.current = fitFull;
// boolab pattern: term.onResize fires synchronously inside term.resize()
// and on init. We always update lastSizeRef so a fresh WS connect (or
// reconnect) carries the current measured size; we send a resize frame
// when the WS is open. No gating by an "is-started" flag — the WS itself
// is the gate (it ignores writes when not open).
term.onResize(({ cols, rows }) => {
lastSizeRef.current = { cols, rows };
if (cols < 2 || rows < 1) return;
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
} catch {
/* ignore */
}
});
// Keystrokes go out as a BINARY frame so the server can disambiguate them
// from JSON control frames. TextEncoder is in every modern browser.
//
// Sticky Ctrl: when armed (via the hotkey bar), apply Ctrl to the next
// single ASCII printable (0x40-0x7e — '@', A-Z, [, \, ], ^, _, `, a-z,
// {, |, }, ~) and disarm. Multi-byte sequences (arrows, Esc, F-keys) pass
// through untouched and disarm so a stuck arm doesn't poison them.
term.onData((data) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
let out = data;
if (ctrlArmedRef.current) {
if (data.length === 1) {
const c = data.charCodeAt(0);
if (c >= 0x40 && c <= 0x7e) {
out = String.fromCharCode(c & 0x1f);
}
}
setCtrlArmedSync(false);
}
try {
ws.send(new TextEncoder().encode(out));
} catch {
/* ignore */
}
});
function buildWsUrl(): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const { cols, rows } = lastSizeRef.current;
return `${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}?cols=${cols}&rows=${rows}`;
}
function scheduleReconnect(): void {
if (disposed || closedByUserRef.current) return;
const attempt = reconnectAttemptRef.current;
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
setConnState('disconnected');
return;
}
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]!;
reconnectAttemptRef.current = attempt + 1;
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = setTimeout(connect, delay);
}
function connect(): void {
if (disposed || closedByUserRef.current) return;
const existing = wsRef.current;
if (existing && existing.readyState !== WebSocket.CLOSED) return;
let ws: WebSocket;
try {
ws = new WebSocket(buildWsUrl());
} catch {
scheduleReconnect();
return;
}
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.addEventListener('open', () => {
if (disposed) return;
reconnectAttemptRef.current = 0;
setConnState('open');
// boolab pattern: send our currently-measured size as a resize frame
// on every WS open. Server's PTY adopts it immediately; no race
// window between attach and resize.
const { cols, rows } = lastSizeRef.current;
if (cols >= 2 && rows >= 1) {
try {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
} catch {
/* ignore */
}
}
});
ws.addEventListener('message', (e) => {
const t = termRef.current;
if (!t) return;
if (typeof e.data === 'string') {
try {
const parsed = JSON.parse(e.data) as { type?: string; code?: number };
if (parsed.type === 'init') {
// boolab pattern: wipe any pre-attach buffer state, then the
// server's next frame (a single binary capture-pane replay)
// will paint the current tmux pane state in cleanly. Re-send
// our measured size in case the server's init size (from the
// WS query string) is stale relative to a fit that landed
// between opening and now.
try {
t.clear();
} catch {
/* ignore */
}
const { cols, rows } = lastSizeRef.current;
if (cols >= 2 && rows >= 1) {
try {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
} catch {
/* ignore */
}
}
return;
}
if (parsed.type === 'exit') {
t.write(`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`);
return;
}
} catch {
/* not JSON — fall through and write as text */
}
t.write(e.data);
} else {
t.write(new Uint8Array(e.data as ArrayBuffer));
}
});
ws.addEventListener('close', () => {
if (disposed) return;
wsRef.current = null;
if (closedByUserRef.current) return;
setConnState('reconnecting');
scheduleReconnect();
});
ws.addEventListener('error', () => {
// close fires after; let close own the retry.
});
}
function manualReconnect(): void {
if (disposed) return;
closedByUserRef.current = false;
reconnectAttemptRef.current = 0;
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
try {
wsRef.current?.close();
} catch {
/* ignore */
}
setConnState('reconnecting');
setTimeout(connect, 50);
}
reconnectFnRef.current = manualReconnect;
// Init: rAF-defer one frame so xterm's render service can populate
// _renderService.dimensions, then fitFull → /start (idempotent, sizes
// the tmux session) → connect WS. The WS handler also ensureSession's
// as belt-and-suspenders, so a /start failure is non-fatal.
const initRaf = requestAnimationFrame(() => {
if (disposed) return;
fitFull();
const { cols, rows } = lastSizeRef.current;
api.terminals
.start(sessionId, paneId, cols, rows)
.catch(() => {
/* WS handler will ensureSession itself — non-fatal */
})
.finally(() => {
if (disposed) return;
connect();
});
});
// Boolab pattern: term measures cell metrics on open() against whatever
// font is registered at that instant; if the font hasn't loaded yet we
// get a fallback measurement and the wrong cols. Refit when
// document.fonts.ready resolves.
let fontRaf: number | null = null;
if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
document.fonts.ready
.then(() => {
if (disposed) return;
fontRaf = requestAnimationFrame(() => fitFull());
})
.catch(() => {
/* ignore */
});
}
// Second-pass refit: mobile browsers can settle layout slightly late,
// causing the rAF fit to measure a stale clientWidth.
const delayedFit = setTimeout(() => fitFull(), 150);
// ResizeObserver: refit on any container size change. No debounce —
// term.onResize itself is the throttle (no-op when cols/rows unchanged).
const ro = new ResizeObserver(() => {
fitFull();
});
ro.observe(ctr);
const onVis = (): void => {
if (document.visibilityState !== 'visible') return;
const ws = wsRef.current;
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
manualReconnect();
} else {
// Refit in case the layout changed while hidden. Single rAF so the
// measurement runs after the tab's first composited frame back in
// the foreground.
requestAnimationFrame(() => {
if (disposed) return;
fitFull();
});
}
};
document.addEventListener('visibilitychange', onVis);
// v1.10.8d: visualViewport.resize fires when the iOS keyboard opens or
// closes — the layout viewport stays full-height (so our h-dvh root
// doesn't shrink), but the visual viewport contracts above the keyboard.
// Refit so xterm's row count matches the visible area, then scroll to
// bottom so the cursor stays above the keyboard fold (boolab pattern).
let onVpResize: (() => void) | null = null;
const vp = typeof window !== 'undefined' ? window.visualViewport : null;
if (vp) {
onVpResize = (): void => {
if (disposed) return;
fitFull();
const t = termRef.current;
if (t) {
try { t.scrollToBottom(); } catch { /* ignore */ }
}
};
vp.addEventListener('resize', onVpResize);
}
// ============================================================
// v1.10.4 features (long-press menu, right-click, search, registry)
// Kept verbatim — independent of the WS/fit path that v1.10.8c fixes.
// ============================================================
function pasteFromClipboard(): void {
if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
toast.error('Paste blocked — long-press input area instead');
return;
}
navigator.clipboard
.readText()
.then((text) => {
if (!text) return;
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(new TextEncoder().encode(text));
} catch {
/* ignore */
}
}
})
.catch(() => {
toast.error('Paste blocked — long-press input area instead');
});
}
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true;
const mod = e.ctrlKey || e.metaKey;
if (!mod) return true;
const isC = e.key === 'c' || e.key === 'C';
const isV = e.key === 'v' || e.key === 'V';
const isF = e.key === 'f' || e.key === 'F';
if (isC) {
if (term.hasSelection()) {
const sel = term.getSelection();
if (sel) {
navigator.clipboard.writeText(sel).catch(() => {
toast.error('Clipboard write failed');
});
}
return false;
}
return !e.shiftKey;
}
if (isV) {
pasteFromClipboard();
return false;
}
if (isF) {
setSearchOpenRef.current(true);
return false;
}
return true;
});
// Long-press selection + floating menu (touch).
let lpTimer: ReturnType<typeof setTimeout> | null = null;
let lpStart: { x: number; y: number } | null = null;
let lpAnchor: { col: number; bufferRow: number } | null = null;
let inSelection = false;
function clearLp(): void {
if (lpTimer !== null) {
clearTimeout(lpTimer);
lpTimer = null;
}
lpStart = null;
}
function selectWord(col: number, bufferRow: number): boolean {
const line = term.buffer.active.getLine(bufferRow);
if (!line) return false;
const text = line.translateToString(true);
const range = wordRangeAt(text, col);
if (!range) return false;
term.select(range.start, bufferRow, range.end - range.start);
return true;
}
function extendSelection(
anchor: { col: number; bufferRow: number },
to: { col: number; bufferRow: number },
): void {
const a = anchor;
const b = to;
let s: { col: number; row: number };
let e: { col: number; row: number };
if (a.bufferRow < b.bufferRow || (a.bufferRow === b.bufferRow && a.col <= b.col)) {
s = { col: a.col, row: a.bufferRow };
e = { col: b.col, row: b.bufferRow };
} else {
s = { col: b.col, row: b.bufferRow };
e = { col: a.col, row: a.bufferRow };
}
const rowsBetween = e.row - s.row;
const length = rowsBetween * term.cols + (e.col - s.col) + 1;
term.select(s.col, s.row, length);
}
function onTouchStart(e: TouchEvent): void {
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
if ((e.target as Element | null)?.closest('[data-term-menu]')) return;
lpStart = { x: t.clientX, y: t.clientY };
lpAnchor = pointToCell(term, ctr, t.clientX, t.clientY);
inSelection = false;
lpTimer = setTimeout(() => {
if (disposed || !lpAnchor) return;
const ok = selectWord(lpAnchor.col, lpAnchor.bufferRow);
if (!ok) return;
inSelection = true;
setMenuRef.current({ x: t.clientX, y: Math.max(t.clientY - 50, 8) });
}, LONG_PRESS_MS);
}
function onTouchMove(e: TouchEvent): void {
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
if (inSelection && lpAnchor) {
e.preventDefault();
const to = pointToCell(term, ctr, t.clientX, t.clientY);
extendSelection(lpAnchor, to);
return;
}
if (!lpStart) return;
const dx = t.clientX - lpStart.x;
const dy = t.clientY - lpStart.y;
if (Math.abs(dx) > LONG_PRESS_TOLERANCE_PX || Math.abs(dy) > LONG_PRESS_TOLERANCE_PX) {
clearLp();
}
}
function onTouchEnd(): void {
if (!inSelection) clearLp();
inSelection = false;
}
function onTouchCancel(): void {
clearLp();
inSelection = false;
}
ctr.addEventListener('touchstart', onTouchStart, { passive: true });
ctr.addEventListener('touchmove', onTouchMove, { passive: false });
ctr.addEventListener('touchend', onTouchEnd, { passive: true });
ctr.addEventListener('touchcancel', onTouchCancel, { passive: true });
function onContextMenu(e: MouseEvent): void {
e.preventDefault();
setMenuRef.current({ x: e.clientX, y: e.clientY });
}
ctr.addEventListener('contextmenu', onContextMenu);
function onDocPointerDown(e: PointerEvent): void {
const t = e.target as Element | null;
if (t && t.closest('[data-term-menu]')) return;
setMenuRef.current(null);
}
document.addEventListener('pointerdown', onDocPointerDown);
const unregister = terminalsRegistry.register(
paneId,
label,
() => {
try {
term.focus();
} catch {
/* ignore */
}
},
() => setSearchOpenRef.current(true),
() => pasteFromClipboard(),
);
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
if (pane_id !== paneId) return;
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const payload = text.endsWith('\n') ? text : `${text}\n`;
try {
ws.send(new TextEncoder().encode(payload));
} catch {
/* ignore */
}
});
setChatInputsRef.current(chatInputsRegistry.list());
const unsubChats = chatInputsRegistry.subscribe(() => {
setChatInputsRef.current(chatInputsRegistry.list());
});
return () => {
disposed = true;
closedByUserRef.current = true;
cancelAnimationFrame(initRaf);
if (fontRaf !== null) cancelAnimationFrame(fontRaf);
clearTimeout(delayedFit);
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (ctrlTimerRef.current) {
clearTimeout(ctrlTimerRef.current);
ctrlTimerRef.current = null;
}
if (lpTimer !== null) clearTimeout(lpTimer);
ro.disconnect();
document.removeEventListener('visibilitychange', onVis);
if (vp && onVpResize) vp.removeEventListener('resize', onVpResize);
document.removeEventListener('pointerdown', onDocPointerDown);
ctr.removeEventListener('touchstart', onTouchStart);
ctr.removeEventListener('touchmove', onTouchMove);
ctr.removeEventListener('touchend', onTouchEnd);
ctr.removeEventListener('touchcancel', onTouchCancel);
ctr.removeEventListener('contextmenu', onContextMenu);
unsubscribe();
unsubChats();
unregister();
const ws = wsRef.current;
if (ws) {
try {
ws.close(1000, 'unmount');
} catch {
/* ignore */
}
wsRef.current = null;
}
try {
term.dispose();
} catch {
/* ignore */
}
termRef.current = null;
searchRef.current = null;
reconnectFnRef.current = () => {};
};
}, [sessionId, paneId, label]);
// Refit when this pane becomes the visible/focused one (e.g. mobile pane
// switch, maximize toggle). term.onResize will send the resize WS frame if
// cols or rows actually changed.
useEffect(() => {
if (!active) return;
const raf = requestAnimationFrame(() => {
const t = termRef.current;
const host = containerRef.current;
if (!t || !host) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const core: any = (t as any)._core;
const cellW: number | undefined = core?._renderService?.dimensions?.css?.cell?.width;
const cellH: number | undefined = core?._renderService?.dimensions?.css?.cell?.height;
if (!cellW || !cellH || cellW <= 0 || cellH <= 0) return;
const cols = Math.max(2, Math.floor(host.clientWidth / cellW));
const rows = Math.max(1, Math.floor(host.clientHeight / cellH));
if (cols !== t.cols || rows !== t.rows) {
try {
t.resize(cols, rows);
} catch {
/* ignore */
}
}
});
return () => cancelAnimationFrame(raf);
}, [active]);
function actCopy(): void {
const term = termRef.current;
if (!term) return;
const sel = term.getSelection();
if (!sel) {
setMenu(null);
return;
}
navigator.clipboard.writeText(sel).catch(() => toast.error('Clipboard write failed'));
term.clearSelection();
setMenu(null);
}
function actPaste(): void {
const reg = terminalsRegistry.get(paneId);
reg?.paste();
setMenu(null);
}
function actSelectAll(): void {
termRef.current?.selectAll();
setMenu(null);
}
function actSearch(): void {
setSearchOpen(true);
setMenu(null);
}
function actSendToChat(chatId: string): void {
const term = termRef.current;
if (!term) return;
const sel = term.getSelection();
if (!sel) {
setMenu(null);
return;
}
sendToChat.emit({ chat_id: chatId, text: sel });
setMenu(null);
}
const hasSelection = !!termRef.current?.getSelection();
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={ctrlArmed}
onSendBytes={sendInput}
onArmCtrl={armCtrl}
onFit={triggerFit}
/>
<div
ref={containerRef}
className="flex-1 min-h-0 w-full overflow-hidden"
style={{ touchAction: 'pan-y', background: TERM_BG }}
data-testid="terminal-pane"
/>
{searchOpen && (
<SearchBar
searchRef={searchRef}
theme={XTERM_THEME}
onClose={() => setSearchOpen(false)}
/>
)}
{menu && (
<FloatingMenu
x={menu.x}
y={menu.y}
hasSelection={hasSelection}
chatInputs={chatInputs}
onCopy={actCopy}
onPaste={actPaste}
onSelectAll={actSelectAll}
onSearch={actSearch}
onSendToChat={actSendToChat}
onDismiss={() => setMenu(null)}
/>
)}
{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>
)}
{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={() => reconnectFnRef.current()}
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>
);
}
// ============================================================
// TerminalHotkeyBar — v1.10.8d port of boolab's TerminalHotkeyBar.jsx +
// terminalHotkeysStore.js DEFAULT_BAR. The catalog is hardcoded inline (no
// zustand store, no settings UI) — single-user homelab doesn't need either.
// Add new entries by extending BAR below.
// ============================================================
type Hotkey =
| { id: string; label: string; bytes: string; sticky?: undefined }
| { id: string; label: string; sticky: 'ctrl'; bytes?: undefined };
const HOTKEY_BAR: Hotkey[] = [
{ id: 'esc', label: 'Esc', bytes: '\x1b' },
{ id: 'shift-tab', label: '⇧Tab', bytes: '\x1b[Z' },
{ id: 'tab', label: 'Tab', bytes: '\t' },
{ id: 'ctrl', label: 'Ctrl', sticky: 'ctrl' },
{ id: 'ctrl-c', label: 'Ctrl+C', bytes: '\x03' },
{ id: 'arrow-up', label: '↑', bytes: '\x1b[A' },
{ id: 'arrow-down', label: '↓', bytes: '\x1b[B' },
{ id: 'arrow-left', label: '←', bytes: '\x1b[D' },
{ id: 'arrow-right', label: '→', bytes: '\x1b[C' },
];
interface TerminalHotkeyBarProps {
ctrlArmed: boolean;
onSendBytes: (bytes: string) => void;
onArmCtrl: () => void;
onFit: () => void;
}
function TerminalHotkeyBar({
ctrlArmed,
onSendBytes,
onArmCtrl,
onFit,
}: TerminalHotkeyBarProps) {
// Stop the touch from reaching the terminal pane below (which calls
// preventDefault on touchmove to suppress page-scroll). Without this a
// tap-and-drag on a hotkey button would also scroll the terminal buffer.
const stopTouch = useCallback((e: React.TouchEvent) => e.stopPropagation(), []);
const press = useCallback(
(entry: Hotkey) => {
if (entry.sticky === 'ctrl') {
onArmCtrl();
} else if (entry.bytes !== undefined) {
onSendBytes(entry.bytes);
}
},
[onArmCtrl, onSendBytes],
);
return (
<div
role="toolbar"
aria-label="Terminal hotkeys"
className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-border bg-muted/30 px-2 py-1"
style={{
scrollbarWidth: 'thin',
WebkitOverflowScrolling: 'touch',
// Suppress iOS native swipe-back gesture starting on the bar; pinch
// and other multi-touch gestures still pass through.
touchAction: 'pan-x',
}}
onTouchStart={stopTouch}
onTouchMove={stopTouch}
>
{HOTKEY_BAR.map((entry) => {
const isCtrl = entry.sticky === 'ctrl';
const armed = isCtrl && ctrlArmed;
return (
<button
key={entry.id}
type="button"
onClick={() => press(entry)}
aria-pressed={isCtrl ? armed : undefined}
aria-label={entry.label}
className={cn(
'shrink-0 rounded border px-2 py-0.5 text-xs font-mono transition-colors',
armed
? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-foreground hover:bg-muted',
)}
style={{
minHeight: 28,
minWidth: 36,
WebkitTouchCallout: 'none',
userSelect: 'none',
}}
>
{entry.label}
</button>
);
})}
<button
type="button"
onClick={onFit}
aria-label="Fit terminal"
title="Fit terminal to container"
className="shrink-0 inline-flex items-center justify-center rounded border border-border text-foreground hover:bg-muted"
style={{
minHeight: 28,
minWidth: 36,
paddingInline: 8,
WebkitTouchCallout: 'none',
userSelect: 'none',
}}
>
<Maximize2 size={14} />
</button>
</div>
);
}
// ============================================================
// FloatingMenu — kept from v1.10.4 (mobile long-press + desktop right-click)
// ============================================================
interface FloatingMenuProps {
x: number;
y: number;
hasSelection: boolean;
chatInputs: ChatInputRegistration[];
onCopy: () => void;
onPaste: () => void;
onSelectAll: () => void;
onSearch: () => void;
onSendToChat: (chatId: string) => void;
onDismiss: () => void;
}
function FloatingMenu({
x,
y,
hasSelection,
chatInputs,
onCopy,
onPaste,
onSelectAll,
onSearch,
onSendToChat,
onDismiss,
}: FloatingMenuProps) {
const [submenu, setSubmenu] = useState(false);
const MENU_W = 200;
const MENU_H = 220;
const left = Math.min(x, window.innerWidth - MENU_W - 8);
const top = Math.min(y, window.innerHeight - MENU_H - 8);
useEffect(() => {
function onKey(ev: KeyboardEvent): void {
if (ev.key === 'Escape') onDismiss();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onDismiss]);
const flatChat = chatInputs.length === 1 ? chatInputs[0] : null;
return (
<div
data-term-menu="1"
role="menu"
style={{
position: 'fixed',
top,
left,
background: '#1a1d24',
border: '1px solid #2a2d34',
borderRadius: 8,
padding: 4,
fontSize: 14,
minWidth: MENU_W,
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
zIndex: 50,
color: '#d6deeb',
}}
>
<MenuItem disabled={!hasSelection} onClick={onCopy}>Copy</MenuItem>
<MenuItem onClick={onPaste}>Paste</MenuItem>
<MenuItem onClick={onSelectAll}>Select All</MenuItem>
<MenuItem onClick={onSearch}>Search</MenuItem>
{flatChat && (
<MenuItem disabled={!hasSelection} onClick={() => onSendToChat(flatChat.chatId)}>
Send to {flatChat.label}
</MenuItem>
)}
{chatInputs.length > 1 && (
<div>
<button
type="button"
disabled={!hasSelection}
onClick={() => setSubmenu((v) => !v)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '10px 12px',
minHeight: 44,
background: 'transparent',
border: 0,
color: hasSelection ? '#d6deeb' : '#575656',
cursor: hasSelection ? 'pointer' : 'not-allowed',
borderRadius: 6,
textAlign: 'left',
}}
>
<span>Send to chat</span>
{submenu ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{submenu && hasSelection && (
<div style={{ paddingLeft: 8 }}>
{chatInputs.map((c) => (
<MenuItem key={c.chatId} onClick={() => onSendToChat(c.chatId)}>
{c.label}
</MenuItem>
))}
</div>
)}
</div>
)}
<div style={{ height: 1, background: '#2a2d34', margin: '4px 0' }} />
<MenuItem onClick={onDismiss}>Dismiss</MenuItem>
</div>
);
}
function MenuItem({
children,
onClick,
disabled = false,
}: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onClick}
style={{
display: 'block',
width: '100%',
padding: '10px 12px',
minHeight: 44,
background: 'transparent',
border: 0,
color: disabled ? '#575656' : '#d6deeb',
cursor: disabled ? 'not-allowed' : 'pointer',
textAlign: 'left',
borderRadius: 6,
fontSize: 14,
}}
onMouseEnter={(ev) => {
if (!disabled) (ev.currentTarget as HTMLButtonElement).style.background = '#2a2d34';
}}
onMouseLeave={(ev) => {
(ev.currentTarget as HTMLButtonElement).style.background = 'transparent';
}}
>
{children}
</button>
);
}
// ============================================================
// SearchBar — kept from v1.10.4
// ============================================================
interface SearchBarProps {
searchRef: React.MutableRefObject<SearchAddon | null>;
theme: typeof XTERM_THEME;
onClose: () => void;
}
function SearchBar({ searchRef, theme, onClose }: SearchBarProps) {
const [q, setQ] = useState('');
const [counts, setCounts] = useState<{ idx: number; total: number }>({ idx: -1, total: 0 });
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
const addon = searchRef.current;
if (!addon) return;
const sub = addon.onDidChangeResults(({ resultIndex, resultCount }) => {
setCounts({ idx: resultIndex, total: resultCount });
});
return () => sub.dispose();
}, [searchRef]);
useEffect(() => {
const addon = searchRef.current;
if (!addon) return;
if (q.length === 0) {
addon.clearDecorations?.();
setCounts({ idx: -1, total: 0 });
return;
}
addon.findNext(q, {
incremental: true,
decorations: {
matchBackground: theme.selectionBackground,
matchOverviewRuler: theme.cursor,
activeMatchBackground: theme.cursor,
activeMatchColorOverviewRuler: theme.cursor,
},
});
}, [q, searchRef, theme]);
function findNext(): void {
if (!q) return;
searchRef.current?.findNext(q);
}
function findPrev(): void {
if (!q) return;
searchRef.current?.findPrevious(q);
}
function onKey(ev: React.KeyboardEvent<HTMLInputElement>): void {
if (ev.key === 'Escape') {
ev.preventDefault();
onClose();
return;
}
if (ev.key === 'Enter') {
ev.preventDefault();
if (ev.shiftKey) findPrev();
else findNext();
}
}
return (
<div
style={{
position: 'absolute',
top: 8,
right: 8,
background: '#1a1d24',
border: '1px solid #2a2d34',
borderRadius: 8,
padding: 4,
display: 'flex',
alignItems: 'center',
gap: 4,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: 40,
}}
>
<input
ref={inputRef}
value={q}
onChange={(ev) => setQ(ev.target.value)}
onKeyDown={onKey}
placeholder="Search…"
style={{
background: 'transparent',
border: 0,
outline: 'none',
color: '#d6deeb',
padding: '8px 8px',
fontSize: 13,
width: 160,
minHeight: 36,
}}
/>
{q.length > 0 && (
<span
style={{
fontSize: 11,
color: counts.total === 0 ? '#ef5350' : '#575656',
minWidth: 56,
textAlign: 'right',
padding: '0 4px',
whiteSpace: 'nowrap',
}}
>
{counts.total === 0
? 'No match'
: counts.idx === -1
? `${counts.total}+`
: `${counts.idx + 1} of ${counts.total}`}
</span>
)}
<button
type="button"
onClick={findPrev}
aria-label="Previous match"
title="Previous (Shift+Enter)"
style={iconBtnStyle}
>
<ChevronUp size={16} />
</button>
<button
type="button"
onClick={findNext}
aria-label="Next match"
title="Next (Enter)"
style={iconBtnStyle}
>
<ChevronDown size={16} />
</button>
<button
type="button"
onClick={onClose}
aria-label="Close search"
title="Close (Esc)"
style={iconBtnStyle}
>
<X size={16} />
</button>
</div>
);
}
const iconBtnStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 44,
height: 44,
background: 'transparent',
border: 0,
color: '#d6deeb',
cursor: 'pointer',
borderRadius: 6,
};