/** * @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 — as `@` mentions in a `claude` pane, which * reads them when the prompt is sent — an image as its bare path, which claude * shows as `[Image #n]` like a native drop — except a PASTED image in a * `claude` pane of a local-mode dashboard opened on its own machine, which is * sent as its image-paste key (^V; Alt+V on Windows) so `claude` reads the * desktop clipboard itself, uploading nothing — and a file manager's files * pasted or dropped on that machine go in by the paths it holds them under * (its clipboard / its selection: `POST /api/run/clipboard-files`, * `selected-files`), with no upload and no size limit * (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own * machine pastes a file manager's files (dropped, or copied: `file://` text) * by their own paths instead, uploading nothing. 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`). Cells are drawn by * the WebGL addon (the DOM renderer after a lost context), and Alt+arrow keeps * the word-move keys xterm 5 sent (`altArrowSeq`). * @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 { WebglAddon } from "@xterm/addon-webgl"; 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, notePaneOutput, registerPane } from "../../lib/panePaste"; import { claudePastes, copiedFilePaths, isClipboardBitmap, droppedFilePaths, fileManagerPaths, isLocalBrowser, localCandidates, sameFilesHere, } from "../../lib/promptAttach"; import { attachImeInput } from "../../lib/imeInput"; import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste"; import { usePermissions } from "../AuthGate"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize"; type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste"; /** 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. */ /** The atlas a WebGL addon draws from: @xterm/addon-webgl 0.19 keeps these * fields unminified in its build. Undefined on any other shape. */ type AtlasPages = { pages: { version: number }[] }; const atlasOf = (addon: WebglAddon): AtlasPages | undefined => { const atlas = (addon as unknown as { _renderer?: { _charAtlas?: AtlasPages } })._renderer ?._charAtlas; return Array.isArray(atlas?.pages) ? atlas : undefined; }; /** Last version handed out by `restampAtlas`, shared by every pane. */ let atlasStamp = 0; /** * Gives every page of the addon's glyph atlas a version no texture slot has * uploaded, so EVERY renderer sharing that atlas re-uploads every page from * its canvas on its next draw — what addon-webgl 0.20 does with globally * monotonic page versions. 0.19 counts per page, so after a merge shifts the * pages down a slot can hold a stale page whose counter happens to match, and * glyphs are sampled squeezed or foreign. Steps of 2^24 stay clear of the * per-glyph increments a page makes before the next restamp. False when the * addon is not the shape this knows. */ const restampAtlas = (addon: WebglAddon): boolean => { const atlas = atlasOf(addon); if (!atlas) return false; for (const page of atlas.pages) { atlasStamp += 2 ** 24; page.version = atlasStamp; } return true; }; 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, webgl, clipboard, search, links] = await Promise.all([ import("@xterm/xterm"), import("@xterm/addon-fit"), import("@xterm/addon-webgl"), import("@xterm/addon-clipboard"), import("@xterm/addon-search"), import("@xterm/addon-web-links"), ]); return { Terminal: xterm.Terminal, FitAddon: fit.FitAddon, WebglAddon: webgl.WebglAddon, 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; } const IS_MAC = /^Mac/.test(typeof navigator === "undefined" ? "" : navigator.platform); /** What xterm 5 sent for a bare Alt+arrow (its Keyboard.ts "HACK"s, removed * in xterm 6): Ctrl+arrow's sequence, except macOS's left/right, which are * readline's backward-word / forward-word and whose up/down stay Alt's. */ export function altArrowSeq(key: string, mac = IS_MAC): string | null { switch (key) { case "ArrowLeft": return mac ? "\x1bb" : "\x1b[1;5D"; case "ArrowRight": return mac ? "\x1bf" : "\x1b[1;5C"; case "ArrowUp": return mac ? null : "\x1b[1;5A"; case "ArrowDown": return mac ? null : "\x1b[1;5B"; default: return null; } } /** 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; /** The pane's AI tool: a `claude` pane is handed a pasted or dropped image * as its bare path and any other file as an `@` mention (`claudePastes`), * any other tool its plain path. */ tool?: string; } /** 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, tool, }: 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 server's own limit, from its ETOOBIG refusal (never a copy of it here). const [limitMb, setLimitMb] = useState(0); // 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, ms = 2500) => { setDrop(state); if (flashTimer.current) clearTimeout(flashTimer.current); flashTimer.current = setTimeout(() => setDrop(null), ms); }; 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 { local: localMode } = usePermissions(); // Ask early whether `^V` can reach this machine's clipboard, so the first // pasted screenshot already takes the right way. useEffect(() => { if (tool === "claude" && localMode && isLocalBrowser()) nativePasteWorks(); }, [tool, localMode]); const onDrop = (e: DragEvent) => { if (hasText(e)) { e.preventDefault(); dragDepth.current = 0; setDrop(null); const local = isLocalBrowser() ? droppedFilePaths(e.dataTransfer) : null; const text = e.dataTransfer.getData("text/plain"); if (local) pastePaths(local); else if (text) termRef.current?.paste(text + " "); termRef.current?.focus(); return; } if (!hasFiles(e)) return; e.preventDefault(); dragDepth.current = 0; const files = Array.from(e.dataTransfer.files); void attachFiles(files, localCandidates(e.dataTransfer, files), "drop"); }; // 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 ?? []); // A file manager's copied files reach a browser as `file://` text only. const local = files.length === 0 && isLocalBrowser() ? copiedFilePaths(e.clipboardData) : null; if (local) { e.preventDefault(); e.stopPropagation(); pastePaths(local); return; } if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return; // Cells from a spreadsheet, a page's selection: text WITH a picture of // it. A terminal pastes the text there, so does this. if (files.every(isClipboardBitmap) && e.clipboardData?.getData("text/plain")?.trim()) return; e.preventDefault(); e.stopPropagation(); // On the dashboard's own machine the browser's clipboard IS the pane's: // `claude` reads a pasted image itself on ^V, as in a native terminal — // nothing uploaded (lib/nativePaste.ts; off for an SSH tunnel's browser). const nativeImage = tool === "claude" && localMode && isLocalBrowser() && readNativePaste() && nativePasteWorks() && files.length > 0 && files.every(isClipboardBitmap); if (nativeImage) { termRef.current?.input(nativePasteKey(), true); return; } if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files), "paste"); else termRef.current?.input("\x16", true); }; /** Pastes files' paths as a local terminal hands them over — paste(), not * a raw send: it honours bracketed-paste mode, so the CLI sees a paste * rather than typed keystrokes. A `claude` pane gets its images as bare * paths, which it shows as `[Image #n]`, and other files as `@` mentions * (`claudePastes`). */ const pastePaths = (paths: string[]) => { const term = termRef.current; if (!term) return; for (const text of tool === "claude" ? claudePastes(paths) : [paths.join(" ")]) term.paste(text + " "); term.focus(); }; /** Pastes the files' paths: their own (`local`, the `file://` paths a * browser on the dashboard's machine is handed beside the bytes) once the * server finds the same files there — nothing uploaded —, else the paths * that machine's file manager holds them under (a paste: its clipboard; a * drop: its selection, what the drag carries — no upload, no size limit, * as a native terminal), else the paths the uploads were saved at. A * refused file does not cost the others. */ const attachFiles = async (files: File[], local: string[] | null, from: "paste" | "drop") => { if (files.length === 0) return; setDrop("uploading"); let paths = local && (await sameFilesHere(local, files)) ? local : null; paths ??= await fileManagerPaths(files, from); const errors: unknown[] = []; if (!paths) { const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId))); paths = results.flatMap((r) => { if (r.status === "fulfilled") return [r.value.path]; errors.push(r.reason); return []; }); } if (paths.length) pastePaths(paths); // Over the server's limit (ETOOBIG): refused before the body is sent. const tooBig = errors .map((e) => e as { code?: string; maxBytes?: number } | null) .find((e) => e?.code === "ETOOBIG"); if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024)); if (!errors.length) setDrop(null); else if (!tooBig) flash("failed"); // A terminal has no + menu: on this machine, a paste goes by path. else flash(isLocalBrowser() ? "tooBigLocalPaste" : "tooBig", 6000); // a sentence to read }; useEffect(() => { let disposed = false; const attach = ({ Terminal, FitAddon, WebglAddon, 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 WebGL 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. xterm 6 dropped its canvas renderer, so the // cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2 // keeps the DOM renderer rather than losing the terminal, and so does a // pane whose context the browser takes back (GPU reset, or past the // browser's cap of live contexts with many panes open) — disposing the // addon hands the drawing back to the DOM renderer. let gpu: WebglAddon | null = null; try { const addon = new WebglAddon(); term.loadAddon(addon); gpu = addon; addon.onContextLoss(() => { addon.dispose(); if (gpu === addon) gpu = null; }); } catch { gpu = null; } // A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`): // fitting it there shrinks xterm to its 12×6 minimum, and that size would // reach tmux with the attach. It stays unfitted until it is shown. const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight; if (hasBox()) 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; } // Alt+arrow moves by word, as it did before xterm 6 dropped its own // mapping (xterm.js #5346): Ctrl+arrow's sequence, or readline's // ESC b / ESC f for left/right on macOS — the same keys tmux, // bash and claude's prompt have always been handed from this pane. const altArrow = ev.altKey && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey ? altArrowSeq(ev.key) : null; if (altArrow) { ev.preventDefault(); term.input(altArrow, 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 (!hasBox() || 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. // A hidden terminal sends none: the server attaches it at the window's // own size, so a pane on a background tab is never resized by it. const size = hasBox() && 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); notePaneOutput(runId); } }; 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 // A tab shown for the first time may fit to the very size xterm already // had (no onResize), yet tmux has never heard it: report it anyway. sendSize(); }; 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); // @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal // with the same font. When it fills up it merges four pages into one // larger page and shifts the rest down, and a GPU texture is re-uploaded // only when its page's `version` differs from the uploaded one — after a // shift those per-page counters can coincide, so a slot keeps a stale // page of another size and every glyph is drawn squeezed or foreign // while the buffer stays correct (fixed upstream only in 0.20 betas). // Any change to the page list therefore restamps every page's version // (`restampAtlas`) synchronously, before the next draw of ANY pane on // that atlas. `clearTextureAtlas` is no fix: it empties the SHARED atlas // but redraws only this pane, leaving its siblings slicing reused space. // An addon of another shape falls back to toggling the family's // trailing space on the next frame, which makes each renderer re-acquire // its atlas (as onFontLoaded does). let atlasFrame = 0; const rebuildAtlas = () => { if (gpu && restampAtlas(gpu)) return true; if (atlasFrame) return false; // Never inside the render that is rasterizing the merging glyph. atlasFrame = requestAnimationFrame(() => { atlasFrame = 0; const family = term.options.fontFamily ?? ""; term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `; }); return false; }; const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas); const atlasGrew = gpu?.onAddTextureAtlasCanvas(rebuildAtlas); // The browser may also drop the textures without a context-loss event // (sleep/resume, a long-backgrounded tab): re-upload when the page is // back, and repaint, since nothing else asks this pane to draw. const onPageShown = () => { if (document.visibilityState === "visible" && rebuildAtlas()) { term.refresh(0, term.rows - 1); } }; document.addEventListener("visibilitychange", onPageShown); window.addEventListener("focus", onPageShown); // 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); if (atlasFrame) cancelAnimationFrame(atlasFrame); atlasMerged?.dispose(); atlasGrew?.dispose(); document.removeEventListener("visibilitychange", onPageShown); window.removeEventListener("focus", onPageShown); window.removeEventListener(FONT_SIZE_EVENT, onFontPref); term.textarea?.removeEventListener("focus", reassert); detachIme(); resizeDisposable.dispose(); dataDisposable.dispose(); ws?.close(); gpu?.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 }; }, // Plain text of the rows on screen, dim cells kept (a status line's // `esc to interrupt` is dim): the chat's busy read. () => { const term = termRef.current; if (!term) return []; const buf = term.buffer.active; const rows: string[] = []; for (let y = 0; y < term.rows; y++) rows.push(buf.getLine(buf.baseY + y)?.translateToString(true) ?? ""); return rows; } ), [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}`, { mb: limitMb })}
)}
); });