/** * @file TerminalView.tsx * @description Renders one lane's live terminal — a real `xterm.js` instance * attached via WebSocket to the server's `/ws-pty/:runId` path (see * server/lib/pty-attach.js), which is itself a `node-pty`-backed * `tmux attach-session`. Binary WS frames are raw PTY bytes in both * directions; a JSON text frame carries the initial `resize` on mount and * the server's one-shot `exit` notice when the pane process ends. * The socket auto-reconnects: a server restart kills the attach PTY, and * without a retry the pane silently freezes on its last painted frame — it * still LOOKS live, but no keystroke or mouse report reaches tmux again. * Retries back off from 1.5 s to 30 s (reset once a socket opens) and stop on * close code 1008. xterm and its addons are loaded on the first terminal * (`loadXterm`), not bundled with the Workspace chunk. * Always painted dark, in the palette of the user's chosen DARK theme * (`lib/terminalTheme.ts`), recoloured live without re-attaching. * Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server * (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a * local terminal does on a drop, and how `claude` attaches an image or reads * a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with * `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be * pasted into whichever of the workspace's panes was focused last. * Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission * mode on Shift+Tab), so Ctrl+M toggles "Tab moves focus" — VS Code's escape * from its terminal — announced by a hint the textarea is described by; * Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode * (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's. * An input method that edits the textarea without composition events (macOS * Vietnamese Telex) is typed through `lib/imeInput.ts` — xterm alone * dropped its capitals and the words it rewrites. * URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies * (OSC 52 — tmux's default `set-clipboard external` passes its own * copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser * clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may * READ the user's clipboard) — over plain HTTP too, through `writeClipboard`'s * textarea fallback. While the socket is down a "Reconnecting…" * overlay says keystrokes are not reaching the pane; resizes reach tmux at * most every 150 ms. Several browsers on one pane share ONE size: the server * names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes * the driver's grid (`size`), scaled down to fit, until a click or a keystroke * here takes the lead back (`resize` with `drive: true`). * @author Nguyễn Ngọc Trí Vĩ */ import { memo, useEffect, useId, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, } from "react"; import { useTranslation } from "react-i18next"; import type { Terminal } from "@xterm/xterm"; import type { CanvasAddon } from "@xterm/addon-canvas"; import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard"; import type { SearchAddon } from "@xterm/addon-search"; import { ArrowDown, ArrowUp, X } from "lucide-react"; import "@xterm/xterm/css/xterm.css"; import { useTheme } from "../../hooks/useTheme"; import { terminalThemeFor } from "../../lib/terminalTheme"; import { api } from "../../lib/api"; import { writeClipboard } from "../../lib/clipboard"; import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste"; import { attachImeInput } from "../../lib/imeInput"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize"; type DropState = "hint" | "paste" | "uploading" | "failed"; /** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */ export const SCREEN_READER_KEY = "agenthub.terminal.screenReader"; /** localStorage flag: the "Shift+drag selects text" hint was dismissed. */ const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen"; const readFlag = (key: string) => { try { return window.localStorage.getItem(key) === "1"; } catch { return false; } }; /** OSC 52 from the pane may WRITE the clipboard (a tmux selection, a CLI's * copy) but never read it: a read would hand the user's clipboard to * whatever runs in the pane. */ const clipboardProvider: IClipboardProvider = { readText: () => "", // tmux names no selection (`52;;`), a CLI usually `c`: both are the // clipboard. An empty write (a "clear") is ignored. writeText: (_selection, text) => { // Silent and best effort: no toast per copy. On a non-secure origin // (`http://`, no Clipboard API) writeClipboard falls back to the // textarea copy, which the browser allows while the drag that made the // selection still counts as the user's gesture. if (text) void writeClipboard(text); }, }; /** xterm and its addons (~400 KB) load with the first terminal, not with the * Workspace chunk. The result is cached, so every later pane (and a test that * awaits `loadXterm()` first) sets up synchronously. */ const importXterm = async () => { const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([ import("@xterm/xterm"), import("@xterm/addon-fit"), import("@xterm/addon-canvas"), import("@xterm/addon-clipboard"), import("@xterm/addon-search"), import("@xterm/addon-web-links"), ]); return { Terminal: xterm.Terminal, FitAddon: fit.FitAddon, CanvasAddon: canvas.CanvasAddon, SearchAddon: search.SearchAddon, WebLinksAddon: links.WebLinksAddon, // The addon's typings declare one argument, its code takes (base64, provider). ClipboardWithProvider: clipboard.ClipboardAddon as unknown as new ( base64: undefined, provider: IClipboardProvider ) => ClipboardAddon, }; }; type Xterm = Awaited>; let xtermLoaded: Xterm | null = null; let xtermLoading: Promise | null = null; export function loadXterm(): Promise { xtermLoading ??= importXterm().then( (x) => (xtermLoaded = x), (err) => { xtermLoading = null; // a failed chunk fetch may be retried by the next pane throw err; } ); return xtermLoading; } /** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */ const RETRY_MIN_MS = 1500; const RETRY_MAX_MS = 30_000; const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files"); /** A text drag (a Files/Changes row, a skill, selected text): xterm.js has no * drop handling of its own, so the pane pastes it, as a local terminal does. */ const hasText = (e: DragEvent) => !hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain"); interface TerminalViewProps { runId: string; wsBaseUrl: string; /** The pane's process ended (a double Ctrl+C, `exit`, a `claude` that quit). * There is nothing left to attach to, so the owner swaps this terminal back * out for its run setup form instead of leaving a dead pane on screen. */ onExit?: () => void; /** Take the keyboard when it appears (default). Off for a split pane that * is not the focused one, or keys would land in whichever mounted last. */ autoFocus?: boolean; /** Another layer (the pane's chat) covers it. xterm stops drawing only when * its IntersectionObserver says off screen, and a covered terminal is not — * so it is moved out of the viewport with a transform, which keeps its * layout size (no PTY resize; the chat still reads and answers its menus). */ covered?: boolean; } /** Memoized: the Workspace re-renders its pane tree on every lane_update, and * a terminal only cares about its own props (its owner keeps `onExit` stable). */ export const TerminalView = memo(function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true, covered = false, }: TerminalViewProps) { const containerRef = useRef(null); // Read through a ref: an inline `onExit` from the parent changes identity on // every render, and as an effect dep it would tear the terminal down and // re-attach on each one. const onExitRef = useRef(onExit); onExitRef.current = onExit; const autoFocusRef = useRef(autoFocus); autoFocusRef.current = autoFocus; // Always the DARK theme's palette, even in light mode: Claude Code's TUI // assumes a dark background. Read through a ref at construction so a theme // change never re-runs the attach effect; the effect below recolours live. const darkTheme = useTheme().themeFor.dark; const palette = useMemo(() => terminalThemeFor(darkTheme), [darkTheme]); const paletteRef = useRef(palette); paletteRef.current = palette; const termRef = useRef(null); // The current attach: a paste into a socket that is not open would be dropped. const wsRef = useRef(null); const { t } = useTranslation("run"); const tRef = useRef(t); tRef.current = t; const [drop, setDrop] = useState(null); // The socket dropped after it had been up: keystrokes are not reaching tmux. const [reconnecting, setReconnecting] = useState(false); // Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane. const [tabMoves, setTabMoves] = useState(false); const tabMovesRef = useRef(false); const [find, setFind] = useState(null); const searchRef = useRef(null); const findInput = useRef(null); const [selectHint, setSelectHint] = useState(() => !readFlag(SELECT_HINT_KEY)); const hintId = useId(); // dragenter/dragleave fire for every child crossed, so count them: the hint // only goes away when the pointer leaves the terminal itself. const dragDepth = useRef(0); const flashTimer = useRef | null>(null); const flash = (state: DropState) => { setDrop(state); if (flashTimer.current) clearTimeout(flashTimer.current); flashTimer.current = setTimeout(() => setDrop(null), 2500); }; useEffect( () => () => { if (flashTimer.current) clearTimeout(flashTimer.current); }, [] ); // Ctrl+V was pressed and its paste has not arrived yet (see the key handler). const ctrlVRef = useRef(false); const onDrop = (e: DragEvent) => { if (hasText(e)) { e.preventDefault(); dragDepth.current = 0; setDrop(null); const text = e.dataTransfer.getData("text/plain"); if (text) termRef.current?.paste(text + " "); termRef.current?.focus(); return; } if (!hasFiles(e)) return; e.preventDefault(); dragDepth.current = 0; void attachFiles(Array.from(e.dataTransfer.files)); }; // A pasted image (a screenshot) goes the way of a drop: xterm only reads // text/plain and would paste nothing. Capture phase, because xterm's own // paste listener stops the event. Text is xterm's to paste, Ctrl+V's too; // only a Ctrl+V with nothing on the clipboard is the pane's ^V again // (vim's block select, readline's quoted insert). const onPaste = (e: ClipboardEvent) => { const ctrlV = ctrlVRef.current; ctrlVRef.current = false; const files = Array.from(e.clipboardData?.files ?? []); if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return; e.preventDefault(); e.stopPropagation(); if (files.length > 0) void attachFiles(files); else termRef.current?.input("\x16", true); }; const attachFiles = async (files: File[]) => { if (files.length === 0) return; setDrop("uploading"); try { const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId))); // paste(), not a raw send: it honours bracketed-paste mode, so claude // sees pasted paths rather than typed keystrokes. termRef.current?.paste(saved.map((r) => r.path).join(" ") + " "); termRef.current?.focus(); setDrop(null); } catch { flash("failed"); } }; useEffect(() => { let disposed = false; const attach = ({ Terminal, FitAddon, CanvasAddon, SearchAddon, WebLinksAddon, ClipboardWithProvider, }: Xterm) => { // An explicit fontFamily pins every glyph to one bundled webfont. Left // unset, xterm falls back to the browser/OS default (`courier-new`), // which on Windows lacks Vietnamese precomposed diacritics — the OS then // substitutes those specific glyphs from a different face with a // mismatched advance width, corrupting the cell it's drawn into even // under the canvas renderer (which only stops a glyph from shoving its // *neighbours*, not from overflowing its own cell). JetBrains Mono is // already bundled for the rest of the UI; main.tsx additionally imports // its `vietnamese` subset so this covers the diacritics too. Settings → // Appearance may swap it for the rounded Maple Mono (also complete) or // the browser's own `monospace` (lib/terminalFont.ts). // NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the // cursor to column 0 on a line feed — and xterm's own docs say not to use // it on a PTY, where termios already does that translation for the streams // that want it. tmux scrolls a pane by emitting a bare LF and then keeps // drawing at the column it was on; with this on, that draw landed at // column 0 instead, stamping the first characters of the line into the // left margin of the rows below. That is the torn left edge of issue #33, // and why it only showed up once content scrolled. const term = new Terminal({ fontSize: readTerminalFontSize(), cursorBlink: true, fontFamily: TERMINAL_FONTS[readTerminalFont()], // A step heavier than stock, like the rest of the UI (main.tsx loads these weights). fontWeight: 500, fontWeightBold: 700, theme: paletteRef.current, screenReaderMode: readFlag(SCREEN_READER_KEY), }); termRef.current = term; const fit = new FitAddon(); term.loadAddon(fit); const search = new SearchAddon(); term.loadAddon(search); searchRef.current = search; term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider)); term.loadAddon( new WebLinksAddon((_e, uri) => { window.open(uri, "_blank", "noopener,noreferrer"); }) ); if (containerRef.current) term.open(containerRef.current); term.textarea?.setAttribute("aria-describedby", hintId); // Draw each cell at a fixed position instead of letting the browser lay the // row out as text. xterm's default DOM renderer aligns a row with a single // `letter-spacing`, which only holds while every glyph comes from one // monospace font — Vietnamese diacritics and CJK fall through to a fallback // face whose advance width differs, so glyphs shove their neighbours along // and the row's tail spills onto the row below. The buffer is correct // throughout (copying the line yields clean text); only the painting drifts, // and no CSS can correct it. Best-effort: a browser that cannot give a 2D // context keeps the DOM renderer rather than losing the terminal. let canvas: CanvasAddon | null = null; try { canvas = new CanvasAddon(); term.loadAddon(canvas); } catch { canvas = null; } fit.fit(); // Take the keyboard as soon as the pane is on screen: the terminal IS the // page here, and having to click it first meant the first thing typed went // nowhere. A click elsewhere still moves focus away, as in any editor. if (autoFocusRef.current) term.focus(); // When the pane's program has NOT enabled wheel reporting, xterm falls back // to converting each wheel notch on an alt-screen buffer into a cursor-key // press (ESC[A / ESC[B). In a `claude` pane that reads as arrow up/down — // the wheel silently walks the prompt history instead of scrolling. Kill // ONLY that branch: xterm runs this same handler before sending a real SGR // mouse report too (`bindMouse`'s wheel case consults it), so a blanket // `false` also blocks tmux's own wheel scrolling. `x10` counts as off here // because that protocol reports button presses only, never the wheel — so // xterm takes the emulation path for it as well. // psmux (Windows) never turns tracking on, so xterm never reports the // wheel there; on the server's `{type:"wheel"}` this pane sends the SGR // wheel report itself — psmux scrolls, or hands it to the pane's app. // ponytail: at 1;1, which is all psmux reports to the app anyway. let ownWheel = false; term.attachCustomWheelEventHandler((ev) => { const mode = term.modes.mouseTrackingMode; const tracked = mode !== "none" && mode !== "x10"; if (!tracked && ownWheel) { if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`); } return false; } return tracked || term.buffer.active.type !== "alternate"; }); // xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the // event cancelled, so the browser still runs its default action and moves // focus out of the terminal. The pane then only ever sees the first press, // which is why `shift+tab to cycle` (permission mode in claude) looked dead // here while working in a real terminal. Returning true keeps xterm's own // handling; the preventDefault only stops focus from escaping. // Ctrl+M toggles "Tab moves focus" (VS Code's way out of its terminal); // in that mode Tab and Shift+Tab are not the pane's, so the browser moves // focus. Cmd+F / Ctrl+Shift+F open the find bar. Returning false keeps xterm from // handling — and from cancelling — the key. term.attachCustomKeyEventHandler((ev) => { if (ev.type !== "keydown") return true; const key = ev.key.toLowerCase(); if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "m") { ev.preventDefault(); tabMovesRef.current = !tabMovesRef.current; setTabMoves(tabMovesRef.current); return false; } // Find: Cmd+F on macOS, Ctrl+Shift+F elsewhere — never plain Ctrl+F, // which is readline's forward-char (and bound by many TUIs). const findChord = !ev.altKey && key === "f" && ((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) || (ev.ctrlKey && ev.shiftKey && !ev.metaKey)); if (findChord) { ev.preventDefault(); setFind((f) => f ?? ""); requestAnimationFrame(() => findInput.current?.focus()); return false; } // Ctrl+V: xterm would send ^V, and claude would then read the // SERVER's clipboard, never this browser's. Let the browser paste // instead (onPaste: a file's path, or the text); a browser that fires // no paste, or one with nothing on the clipboard, still sends ^V. if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "v") { ctrlVRef.current = true; setTimeout(() => { if (!ctrlVRef.current) return; ctrlVRef.current = false; term.input("\x16", true); }, 0); return false; } // Ctrl+Z: a terminal sends ^Z (SIGTSTP), which SUSPENDS claude or // codex — the pane drops to its shell and the session looks closed, // with nothing on screen saying `fg` brings it back. In a browser // Ctrl+Z means undo, so it sends Ctrl+_ — undo in Claude Code's prompt // and in bash/readline — and never suspends the program. if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "z") { ev.preventDefault(); term.input("\x1f", true); return false; } if (ev.key === "Tab" && tabMovesRef.current) return false; if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault(); return true; }); const decoder = new TextDecoder(); let ws: WebSocket | null = null; // Another browser drives this pane's size: the grid it set, or null. let follow: { cols: number; rows: number } | null = null; // A follower's grid may not fit this container: shrink it (never grow). const scaleToFit = () => { const el = term.element; const box = containerRef.current; if (!el || !box) return; el.style.transformOrigin = "0 0"; el.style.transform = ""; // `.xterm` is a block as wide as the box; the grid's size is the screen's. const grid = el.querySelector(".xterm-screen"); if (!follow || !grid?.offsetWidth || !grid.offsetHeight) return; const s = Math.min( 1, box.clientWidth / grid.offsetWidth, box.clientHeight / grid.offsetHeight ); if (s < 1) el.style.transform = `scale(${s})`; }; /** * Tell the server this terminal's size. tmux draws the pane at whatever * width it last heard, and a pane WIDER than xterm is silently corrupting: * every over-long row autowraps its tail onto the next row's left margin, * and tmux — believing that row unchanged — never repaints over it. The * result is stray characters glued to the left edge of blank lines, which * only becomes visible once a scroll repaints history. Reproduced with a * one-column difference; two columns leaves two characters per line. * * A zero-size fit is never reported: the console collapses with CSS * (`display:none` leaves the container 0×0), and fitting to that would * resize the real tmux pane down to nothing. */ const sendSizeNow = (drive = false) => { if (!ws || ws.readyState !== WebSocket.OPEN) return; if (term.cols < 2 || term.rows < 2) return; ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive })); }; // This browser is the one in use now: back to its own size, and the // pane (and every other viewer) follows it. const takeLead = () => { const el = containerRef.current; if (!el || !el.clientWidth || !el.clientHeight) return; follow = null; scaleToFit(); fit.fit(); sendSizeNow(true); }; // A divider drag or a window resize fires a resize per frame; tmux and the // program in the pane redraw on each, so the LAST size is sent once things // settle (trailing, 150 ms). let sizeTimer: ReturnType | null = null; const sendSize = () => { if (sizeTimer) clearTimeout(sizeTimer); sizeTimer = setTimeout(() => { sizeTimer = null; sendSizeNow(); }, 150); }; let retryTimer: ReturnType | null = null; let ended = false; // server said the pane exited — nothing left to attach to let retryMs = RETRY_MIN_MS; const connect = () => { // The attach itself carries this terminal's size. tmux sizes the window // to the client that attached most recently, so spawning the attach at a // default 80×24 and correcting it from the first `resize` message a beat // later makes the pane's program (claude) redraw its whole UI at 80 // columns first — and everything painted at that width stays in the // scrollback as torn fragments along the left margin, visible as soon as // you scroll back (issue #33). Happens on every attach: page load, // lane switch, and each reconnect. const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : ""; const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`); ws = sock; wsRef.current = sock; // Server sends PTY bytes as binary frames — default binaryType ("blob") // would hand onmessage a Blob that the string checks below never match, // silently dropping all terminal output. "arraybuffer" keeps it sync. sock.binaryType = "arraybuffer"; sock.onopen = () => { retryMs = RETRY_MIN_MS; setReconnecting(false); sendSizeNow(); }; sock.onmessage = (event) => { const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]"; // `stream: true` carries a UTF-8 multi-byte char's trailing bytes over // to the next decode() call when a PTY chunk splits mid-character — // without it, a Vietnamese/accented char split across two WS frames // decodes to garbage on both sides (U+FFFD or stray bytes). const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer, { stream: true }) : event.data; if (typeof data === "string") { // A JSON control frame is the only thing that starts with `{"type"`. if (data.startsWith('{"type"')) { try { const msg = JSON.parse(data); if (msg.type === "size") { follow = { cols: msg.cols, rows: msg.rows }; term.resize(msg.cols, msg.rows); requestAnimationFrame(scaleToFit); } else if (msg.type === "lead") { takeLead(); } else if (msg.type === "wheel") { ownWheel = true; } else if (msg.type === "exit") { ended = true; term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`); onExitRef.current?.(); } return; } catch { /* not JSON — fall through and render as PTY output */ } } term.write(data); } }; sock.onclose = (e?: CloseEvent) => { // 1008: the server refused this attach (policy) — retrying cannot help. if (disposed || ended || e?.code === 1008) return; // tmux keeps the session alive across a server restart, so re-attaching // repaints the pane in full — no output is lost by retrying. Input is // not: the overlay says so rather than letting keystrokes vanish. // Backoff (1.5 s → 30 s, reset on open) so a server that stays down is // not hammered by every open pane. setReconnecting(true); retryTimer = setTimeout(connect, retryMs); retryMs = Math.min(retryMs * 2, RETRY_MAX_MS); }; }; connect(); const dataDisposable = term.onData((data) => { // A keystroke takes the lead; a mouse report (wheel, hover) does not. if (follow && !/^\x1b\[(<|M)/.test(data)) takeLead(); if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); }); // xterm's own size change is the event worth reporting, not the // container's: cols also move when the webfont finishes loading or the // page zooms, neither of which resizes the element ResizeObserver watches. const resizeDisposable = term.onResize(sendSize); // Re-assert on focus. Several attach clients can share one tmux session // (a second tab, another browser): coming back to this one makes it the // driver again, and the others follow its size. const reassert = takeLead; term.textarea?.addEventListener("focus", reassert); // macOS's own Vietnamese Telex (Safari and Chrome) types into the // textarea with no composition events: a capital arrives while Shift is // held and every later key REPLACES the word — both dropped by xterm // (xterm.js #5887). lib/imeInput.ts sends those edits itself. const detachIme = attachImeInput(term); const refit = () => { // Never fit a collapsed console — see sendSize. const el = containerRef.current; if (!el || !el.clientWidth || !el.clientHeight) return; if (follow) return scaleToFit(); // the driver's grid, not ours fit.fit(); // a real change fires onResize, which reports it }; const resizeObserver = new ResizeObserver(refit); if (containerRef.current) resizeObserver.observe(containerRef.current); // A webfont landing after open changes the cell, not the container, so the // observer never fires — and xterm re-measures its cell only on a font // option change or a resize, so the fit kept the fallback font's cell and // the rows/cols overflowed the pane. Re-setting the family (a trailing // space keeps it a change) makes xterm measure again before the fit. const onFontLoaded = () => { const family = term.options.fontFamily ?? ""; term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `; refit(); }; document.fonts?.addEventListener("loadingdone", onFontLoaded); // Settings → Appearance changed the terminal's font or size: an open // pane takes it now, re-measured and re-fitted like a font landing. const onFontPref = () => { const size = readTerminalFontSize(); const family = TERMINAL_FONTS[readTerminalFont()]; if (term.options.fontSize === size && term.options.fontFamily?.trimEnd() === family) return; term.options.fontSize = size; term.options.fontFamily = family; refit(); }; window.addEventListener(FONT_SIZE_EVENT, onFontPref); return () => { if (retryTimer) clearTimeout(retryTimer); if (sizeTimer) clearTimeout(sizeTimer); searchRef.current = null; resizeObserver.disconnect(); document.fonts?.removeEventListener("loadingdone", onFontLoaded); window.removeEventListener(FONT_SIZE_EVENT, onFontPref); term.textarea?.removeEventListener("focus", reassert); detachIme(); resizeDisposable.dispose(); dataDisposable.dispose(); ws?.close(); canvas?.dispose(); term.dispose(); termRef.current = null; }; }; // Synchronous once xterm is loaded; the first pane waits for the chunk, and // `disposed` drops a setup that lands after this effect was cleaned up. let teardown: (() => void) | undefined; const start = (x: Xterm) => { if (!disposed) teardown = attach(x); }; if (xtermLoaded) start(xtermLoaded); else loadXterm().then(start, () => undefined); return () => { disposed = true; teardown?.(); }; }, [runId, wsBaseUrl]); useEffect(() => { if (termRef.current) termRef.current.options.theme = palette; }, [palette]); // Other parts of the page (a diff-line comment) paste into this pane. useEffect( () => registerPane( runId, (text, submit, files = []) => { const term = termRef.current; // The text, then each image's path in a paste of its own — the one // shape `claude` turns into `[Image #n]` on every platform. if (text) term?.paste(files.length ? `${text} ` : text); files.forEach((f, i) => term?.paste(i < files.length - 1 ? `${f} ` : f)); // Enter a beat after the paste, so a TUI that times its paste // detection never reads it as a newline inside the paste. A submit // comes from the pane's Chat view, which keeps the keyboard. if (submit) setTimeout(() => { if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send("\r"); }, 100); else termRef.current?.focus(); }, // On screen (a console hidden with CSS measures 0×0) AND attached: // `onData` drops input while the socket is connecting or reconnecting, // so a caller waits or says the paste failed instead. () => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN, (data) => { if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data); }, // The rows on screen, dim cells (a TUI's placeholder) left out. // `menuOnly` (the chat's 700 ms menu poll): plain text first, and the // cell-by-cell read only when a row could be a menu's cursor row. (menuOnly = false) => { const term = termRef.current; if (!term) return null; const buf = term.buffer.active; if (menuOnly) { let maybe = false; for (let y = 0; y < term.rows && !maybe; y++) maybe = MENU_CURSOR_ROW.test( buf.getLine(buf.baseY + y)?.translateToString(true) ?? "" ); if (!maybe) return null; } const rows: string[] = []; const reuse = buf.getNullCell(); for (let y = 0; y < term.rows; y++) { const line = buf.getLine(buf.baseY + y); let row = ""; for (let x = 0; line && x < line.length; x++) { const cell = line.getCell(x, reuse); // Width 0: the right half of a wide character. if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " "; } rows.push(row); } return { rows, cols: term.cols }; } ), [runId] ); // The Workspace's focused pane takes those pastes even when the focus moved // without a click inside the terminal (a tab, Alt+1–4, the sidebar). useEffect(() => { if (autoFocus) notePaneFocus(runId); }, [autoFocus, runId]); const runFind = (backwards = false) => { const q = find ?? ""; if (!q) return; if (backwards) searchRef.current?.findPrevious(q); else searchRef.current?.findNext(q); }; const closeFind = () => { setFind(null); searchRef.current?.clearDecorations(); termRef.current?.focus(); }; const dismissSelectHint = () => { setSelectHint(false); try { window.localStorage.setItem(SELECT_HINT_KEY, "1"); } catch { /* shown again next time */ } }; const chip = "absolute z-10 rounded-md border border-white/15 bg-black/70 px-2 py-1 text-[11px] text-white/90"; return (
{ if (!hasFiles(e) && !hasText(e)) return; e.preventDefault(); dragDepth.current += 1; setDrop(hasFiles(e) ? "hint" : "paste"); }} onDragOver={(e) => { if (!hasFiles(e) && !hasText(e)) return; e.preventDefault(); // without it the browser refuses the drop e.dataTransfer.dropEffect = "copy"; }} onDragLeave={(e) => { if (!hasFiles(e) && !hasText(e)) return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (dragDepth.current === 0) setDrop((d) => (d === "hint" || d === "paste" ? null : d)); }} onDrop={onDrop} onPasteCapture={onPaste} onFocus={() => notePaneFocus(runId)} >

{t("terminal.keyboardHint")}

{tabMoves && ( {t("terminal.tabMoves")} )} {selectHint && !reconnecting && ( {t("terminal.selectHint")} )} {find !== null && (
{ setFind(e.target.value); if (e.target.value) searchRef.current?.findNext(e.target.value, { incremental: true }); }} onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); closeFind(); } else if (e.key === "Enter" && !e.nativeEvent.isComposing) { e.preventDefault(); runFind(e.shiftKey); } }} className="w-44 rounded bg-white/10 px-1.5 py-0.5 text-white placeholder:text-white/50 focus:outline-none" />
)} {reconnecting && (
{t("terminal.reconnecting")}
)} {drop && ( // pointer-events-none: the drop must land on the terminal, not on this.
{t(`drop.${drop}`)}
)}
); });