import { useRef, useEffect, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { CanvasAddon } from "@xterm/addon-canvas"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import SearchBar from "./SearchBar"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { readText as clipboardReadText, writeText as clipboardWriteText, } from "@tauri-apps/plugin-clipboard-manager"; import { openUrl } from "@tauri-apps/plugin-opener"; import { spawnPane, writeToPane, resizePane, killPane, onPaneData, onPaneExit, getPaneRing, claimPane, type PaneId, type SpawnSpec, } from "../ipc"; import type { NavigateIntent } from "../lib/layout/orchestration"; // --------------------------------------------------------------------------- // base64 helpers (private to this module) // --------------------------------------------------------------------------- function b64ToBytes(b64: string): Uint8Array { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } function bytesToB64(bytes: Uint8Array): string { let s = ""; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return btoa(s); } function stringToB64(s: string): string { // xterm.js's onData emits a JS string; UTF-8 encode before base64. return bytesToB64(new TextEncoder().encode(s)); } // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- interface XtermPaneProps { /** Spec describing what to spawn into this pane's PTY. Read once at mount; * changing it later does NOT respawn — callers force a respawn by * changing the React `key` (see Pane.svelte / LeafPane). */ spec: SpawnSpec; /** Attach to an existing PTY (transferred from another window) instead of * spawning a new one. When set: spec is ignored at the spawn step, the * scrollback ring is replayed into xterm.js, the live data listener is * attached, and the transfer refcount is claimed (decremented) so the * source window's killPane is no longer suppressed. */ existingPaneId?: PaneId; onStatus?: (msg: string, ok: boolean) => void; /** Fired once when the backend PTY is alive and we have its PaneId. */ onSpawn?: (paneId: PaneId) => void; /** Fired AFTER each writeToPane on user keypress. Used by broadcasting. */ onInput?: (dataB64: string) => void; /** Fired whenever output arrives from the PTY. Used for idle detection. */ onDataReceived?: () => void; /** Fired when xterm's textarea gains focus (i.e., user clicked here). */ onFocus?: () => void; /** Increment to refocus the terminal programmatically (palette etc.). */ focusTrigger?: number; /** Absolute font size in px. Changes are applied live (fit + PTY resize). */ fontSize?: number; /** Called when the user presses a tiling-WM navigation chord inside the * terminal. XtermPane only emits the intent; the parent (LeafPane/App) * resolves the target leaf from the current layout and sets it active. * Defined as an optional callback so single-pane windows don't require * wiring it up. */ onNavigate?: (intent: NavigateIntent) => void; } const DEFAULT_XTERM_FONT_SIZE = 13; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export default function XtermPane({ spec, existingPaneId, onStatus, onSpawn, onInput, onDataReceived, onFocus, focusTrigger = 0, fontSize, onNavigate, }: XtermPaneProps) { const containerRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const paneIdRef = useRef(null); const searchAddonRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); // Stash the most recent `fontSize` prop so the mount effect can pick // up the initial value without re-running when it changes (the secondary // effect below handles dynamic updates). const initialFontSizeRef = useRef(fontSize); // Stable refs for callbacks so the mount effect doesn't need to re-run when // parents pass new inline functions, while still always calling the latest version. const onStatusRef = useRef(onStatus); const onSpawnRef = useRef(onSpawn); const onInputRef = useRef(onInput); const onDataReceivedRef = useRef(onDataReceived); const onFocusRef = useRef(onFocus); const onNavigateRef = useRef(onNavigate); // Stable ref for setSearchOpen so it can be called from inside the // attachCustomKeyEventHandler closure without the closure going stale. const setSearchOpenRef = useRef<(v: boolean) => void>(setSearchOpen); useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]); useEffect(() => { onSpawnRef.current = onSpawn; }, [onSpawn]); useEffect(() => { onInputRef.current = onInput; }, [onInput]); useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]); useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]); useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]); useEffect(() => { setSearchOpenRef.current = setSearchOpen; }, [setSearchOpen]); // ------------------------------------------------------------------------- // Mount / unmount: create terminal, spawn PTY, wire listeners // ------------------------------------------------------------------------- useEffect(() => { const container = containerRef.current; if (!container) return; let term: Terminal | null = new Terminal({ fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace', fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE, cursorBlink: true, theme: { background: "#0c0c0c", foreground: "#e6e6e6", }, scrollback: 5000, convertEol: false, allowProposedApi: true, }); termRef.current = term; const fit = new FitAddon(); fitRef.current = fit; term.loadAddon(fit); // Underlines http(s) URLs in the terminal output and routes clicks // through Tauri's opener plugin so they open in the user's default // browser (WebView2 won't navigate on a plain window.open). term.loadAddon( new WebLinksAddon((_event, uri) => { void openUrl(uri).catch((err) => console.warn("openUrl failed:", err), ); }), ); term.open(container); // Use the canvas renderer instead of xterm's default DOM renderer. // The DOM renderer draws the cursor as a separate layered element and, // under the Claude TUI's rapid hide/show (\x1b[?25l/h) + cursorBlink, // leaves a stale cursor block frozen where the cursor used to be (the // "stuck white marker"). The canvas renderer composites the cursor into // the same surface as the text, so hide/show transitions clear cleanly. // Chosen over the WebGL addon because tiletopia runs many panes at once // and Chromium/WebView2 caps live WebGL contexts (~16) — canvas has no // such hard limit. Loaded after open() so the core renderer exists. try { term.loadAddon(new CanvasAddon()); } catch (e) { // If canvas init fails for any reason, xterm falls back to the DOM // renderer on its own — degrade gracefully rather than blank the pane. console.warn("CanvasAddon load failed; using DOM renderer:", e); } // Load Unicode 11 addon for correct width handling of emoji, CJK, and // box-drawing characters. This prevents cursor drift in TUIs that rely on // Unicode 11 character widths. Loaded after CanvasAddon so the renderer // surface is set before width calculations begin. try { term.loadAddon(new Unicode11Addon()); term.unicode.activeVersion = "11"; } catch (e) { console.warn("Unicode11Addon load failed:", e); } // Load the search addon so find-in-scrollback works. Must be loaded // after open() so the terminal viewport exists for decoration rendering, // and after CanvasAddon since it decorates the same canvas surface. const search = new SearchAddon(); searchAddonRef.current = search; term.loadAddon(search); // Initial size — fit before asking the PTY for its dimensions. fit.fit(); let paneId: PaneId | null = null; let unlistenData: UnlistenFn | null = null; let unlistenExit: UnlistenFn | null = null; let ro: ResizeObserver | null = null; let destroyed = false; (async () => { const cols = term!.cols; const rows = term!.rows; if (existingPaneId != null) { // Adoption path: a window-transfer landed us here with an existing // PTY id. Don't spawn — replay the scrollback ring first (so the // user sees recent output like a thinking Claude session), then // attach the live listener, resize the PTY to this window's grid, // and release the transfer-refcount. paneId = existingPaneId; paneIdRef.current = paneId; onStatusRef.current?.(`pane ${paneId} adopted`, true); onSpawnRef.current?.(paneId); try { const ringB64 = await getPaneRing(paneId); if (destroyed) return; if (ringB64) { term?.write(b64ToBytes(ringB64)); } } catch (e) { console.warn("getPaneRing failed:", e); } if (destroyed) return; unlistenData = await onPaneData(paneId, (b64) => { term?.write(b64ToBytes(b64)); onDataReceivedRef.current?.(); }); // `destroyed` may have flipped during the await — the sync cleanup // already ran and captured a null unlisten, so unlisten here or the // subscription leaks. if (destroyed) { unlistenData?.(); return; } unlistenExit = await onPaneExit(paneId, () => { term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n"); onStatusRef.current?.(`pane ${paneId} exited`, false); }); if (destroyed) { unlistenData?.(); unlistenExit?.(); return; } // Match the PTY to our cell grid (the source window may have had // different dimensions). try { await resizePane(paneId, cols, rows); } catch (e) { console.warn("resizePane on adopt failed:", e); } // Release the transfer refcount so future killPane calls on this // id are no longer suppressed. try { await claimPane(paneId); } catch (e) { console.warn("claimPane failed:", e); } } else { try { paneId = await spawnPane({ spec, cols, rows }); if (destroyed) { void killPane(paneId); return; } paneIdRef.current = paneId; onStatusRef.current?.(`pane ${paneId} alive`, true); onSpawnRef.current?.(paneId); } catch (e) { if (destroyed) return; const msg = `spawn_pane failed: ${e}`; term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`); onStatusRef.current?.(msg, false); return; } unlistenData = await onPaneData(paneId, (b64) => { term?.write(b64ToBytes(b64)); onDataReceivedRef.current?.(); }); if (destroyed) { unlistenData?.(); return; } unlistenExit = await onPaneExit(paneId, () => { term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n"); onStatusRef.current?.(`pane ${paneId} exited`, false); }); if (destroyed) { unlistenData?.(); unlistenExit?.(); return; } } term?.onData((data) => { if (paneId == null) return; const b64 = stringToB64(data); void writeToPane(paneId, b64); onInputRef.current?.(b64); }); // Intercept tiling-WM chords before the PTY sees them. All families // share ONE attachCustomKeyEventHandler call — xterm.js replaces the // previous handler on every call, so a second call anywhere would // silently discard all earlier interceptions. // // Family 1: Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste. // Uses tauri-plugin-clipboard-manager so WebView2 never shows its // native "Allow clipboard access?" prompt. term.paste() routes // through onData → writeToPane so broadcasting + bracketed paste // keep working for free. // // Family 2: Ctrl+Shift+F — open/focus the find-in-scrollback bar. // Swallowed before xterm or the PTY sees the raw keypress. Uses the // stable setSearchOpenRef so the closure never goes stale. // // Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L — spatial pane focus. // XtermPane emits onNavigate({ kind: "direction", dir }) and returns // false so the chord is swallowed before it reaches the PTY. The // parent (LeafPane → App) resolves the neighbour and bumps // focusTrigger on the new active pane. // // Family 4: Alt+1..9 — index-based pane focus. // Emits onNavigate({ kind: "index", n }) and swallows. Note: bare // Alt+digit is used by some shells (readline digit-argument, vim/nvim) // — this interception is an accepted v1 trade-off (see shortcuts.ts). term?.attachCustomKeyEventHandler((e) => { if (e.type !== "keydown") return true; // --- Family 1 & 2: Ctrl+Shift+* (no Alt) --------------------------- if (e.ctrlKey && e.shiftKey && !e.altKey) { if (e.code === "KeyF") { // Ctrl+Shift+F — open find-in-scrollback bar. e.preventDefault(); setSearchOpenRef.current(true); return false; } if (e.code === "KeyC") { // Ctrl+Shift+C — copy selection to clipboard. const sel = term?.getSelection(); if (sel) { void clipboardWriteText(sel).catch((err) => console.warn("clipboard write failed:", err), ); } e.preventDefault(); return false; } if (e.code === "KeyV") { // Ctrl+Shift+V — paste from clipboard via term.paste() so // broadcasting and bracketed paste work for free. e.preventDefault(); clipboardReadText() .then((text) => { if (text && term) term.paste(text); }) .catch((err) => console.warn("clipboard read failed:", err)); return false; } } // --- Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L (spatial nav) ----- if (e.ctrlKey && e.altKey && !e.shiftKey && onNavigateRef.current) { // Arrow keys const ARROW_DIR: Record = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down", }; // Vim-style HJKL const VIM_DIR: Record = { KeyH: "left", KeyJ: "down", KeyK: "up", KeyL: "right", }; const dir = ARROW_DIR[e.code] ?? VIM_DIR[e.code]; if (dir) { e.preventDefault(); onNavigateRef.current({ kind: "direction", dir }); return false; } } // --- Family 4: Alt+1..9 (index-based pane focus) ------------------- if (e.altKey && !e.ctrlKey && !e.shiftKey && onNavigateRef.current) { const digit = e.code.match(/^Digit([1-9])$/); if (digit) { e.preventDefault(); onNavigateRef.current({ kind: "index", n: parseInt(digit[1], 10) }); return false; } } return true; }); // Focus detection: xterm.js doesn't expose onFocus as a first-class event // in all versions, so try the proposed API first then fall back to the DOM. term?.onSelectionChange(() => {}); // ensure addon system is initialised; noop const termAny = term as unknown as { onFocus?: (cb: () => void) => void }; if (typeof termAny.onFocus === "function") { termAny.onFocus(() => onFocusRef.current?.()); } else { const ta = container.querySelector(".xterm-helper-textarea"); if (ta) ta.addEventListener("focus", () => onFocusRef.current?.(), true); } // Re-fit on container resize. xterm.fit() + a forced refresh run // immediately (visual must stay smooth during a drag), but the // actual PTY resize call is debounced: every SIGWINCH makes bash // redraw the prompt, and if we send 60+ of them per second during a // gutter drag, the redraws corrupt each other and the terminal // fills with garbled half-prompts. The debounce means the PTY // hears about resizes ~150 ms after you stop dragging, at the // final size — bash gets a single clean redraw. let resizeRaf: number | null = null; let resizePtyTimer: number | null = null; let lastSentCols = -1; let lastSentRows = -1; ro = new ResizeObserver(() => { if (resizeRaf != null) return; resizeRaf = requestAnimationFrame(() => { resizeRaf = null; if (!term) return; try { fit.fit(); term.refresh(0, term.rows - 1); if (resizePtyTimer != null) clearTimeout(resizePtyTimer); resizePtyTimer = window.setTimeout(() => { resizePtyTimer = null; if (paneId == null || !term) return; // Skip if the cell grid didn't actually change — saves a // pointless SIGWINCH that would make bash redraw its prompt // (which feeds back into onDataReceived and causes the idle // indicator to flap; see the analysis around v0.2.2). if (term.cols === lastSentCols && term.rows === lastSentRows) { return; } lastSentCols = term.cols; lastSentRows = term.rows; void resizePane(paneId, term.cols, term.rows); }, 150); } catch (e) { console.warn("resize failed", e); } }); }); ro.observe(container); // Focus so typing immediately lands in the terminal — but ONLY if the // host container is actually visible. With multiple tabs (workspaces), // a pane in a hidden tab still mounts and spawns; we must not yank // focus into a tab the user can't see. CSS `visibility: hidden` is // inherited, so the computed style on the container reflects whether // any ancestor (workspace-layer) is hiding us. if ( container.isConnected && getComputedStyle(container).visibility !== "hidden" ) { term?.focus(); } })(); return () => { destroyed = true; ro?.disconnect(); unlistenData?.(); unlistenExit?.(); if (paneId != null) void killPane(paneId); term?.dispose(); term = null; termRef.current = null; fitRef.current = null; searchAddonRef.current = null; paneIdRef.current = null; }; // spec is read once at mount; intentionally omitted from deps so we // don't remount on parent re-renders. Callers force a respawn by // bumping the React `key` (changeShell swaps the leaf id for that). // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ------------------------------------------------------------------------- // focusTrigger: programmatic refocus from parent (palette navigation etc.) // ------------------------------------------------------------------------- useEffect(() => { if (focusTrigger > 0 && containerRef.current) { const ta = containerRef.current.querySelector( ".xterm-helper-textarea", ); ta?.focus(); } }, [focusTrigger]); // ------------------------------------------------------------------------- // Live font-size changes (Ctrl+Shift+= / - / 0). // // Setting term.options.fontSize re-rasterises glyphs immediately, but the // cols/rows the terminal thinks it has are still based on the OLD cell // size — so we have to fit() to recompute, refresh() to repaint, then // ship the new dimensions to the PTY so bash redraws the prompt at the // right width. // ------------------------------------------------------------------------- useEffect(() => { const term = termRef.current; const fit = fitRef.current; if (!term || !fit) return; const target = fontSize ?? DEFAULT_XTERM_FONT_SIZE; if (term.options.fontSize === target) return; try { term.options.fontSize = target; fit.fit(); term.refresh(0, term.rows - 1); const paneId = paneIdRef.current; if (paneId != null) void resizePane(paneId, term.cols, term.rows); } catch (e) { console.warn("font-size apply failed", e); } }, [fontSize]); // Close the search bar and return focus to the xterm textarea so the user // can resume typing immediately. Queries the well-known xterm helper // textarea selector — the same pattern used in the focusTrigger effect. function closeSearch() { setSearchOpen(false); const ta = containerRef.current?.querySelector( ".xterm-helper-textarea", ); ta?.focus(); } // The outer wrapper is position:relative so the absolutely-positioned // SearchBar anchors inside the pane without escaping to a positioned // ancestor further up the tree. The FitAddon measures containerRef's div // (the inner one), which still fills 100% of the wrapper — no sizing break. return (
{searchOpen && searchAddonRef.current && ( )}
); }