ecbbb4dd8f
- Chat view: an attached image's path is pasted on its own after the text (promptForPane). claude turns a paste that is only an image path into [Image #n], but left a Windows path inside a paste of text as a plain path. Other files and @-mentions stay in the text as before. - Plugin bootstrap: depsHash covers only what npm installs (no release version), so a version bump no longer reinstalls runtime deps; the client's installed tree is kept while its manifests are unchanged (no npm ci per update); the user-side build runs vite only (tsc -b cost ~25 s / ~900 MB); clientBuild is recorded only after a build that did not fail, so a failed one is retried; npm output goes to runtime/npm-install.log. - Server: a port held by another program ends with a clear message and exit 1 instead of an uncaught 'error' stack trace. - Client: the onboarding wizard is lazy and rendered only before onboarding; usePrefersReducedMotion moved to hooks/ so PetSettings no longer pulls the Sagi chat/pane stack into the entry chunk (884 KB -> 431 KB, gzip 268 -> 133 KB).
884 lines
39 KiB
TypeScript
884 lines
39 KiB
TypeScript
/**
|
||
* @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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
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;;<b64>`), 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://<lan-ip>`, 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<ReturnType<typeof importXterm>>;
|
||
let xtermLoaded: Xterm | null = null;
|
||
let xtermLoading: Promise<Xterm> | null = null;
|
||
export function loadXterm(): Promise<Xterm> {
|
||
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<HTMLDivElement>(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<Terminal | null>(null);
|
||
// The current attach: a paste into a socket that is not open would be dropped.
|
||
const wsRef = useRef<WebSocket | null>(null);
|
||
const { t } = useTranslation("run");
|
||
const tRef = useRef(t);
|
||
tRef.current = t;
|
||
const [drop, setDrop] = useState<DropState | null>(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<string | null>(null);
|
||
const searchRef = useRef<SearchAddon | null>(null);
|
||
const findInput = useRef<HTMLInputElement>(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<ReturnType<typeof setTimeout> | 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<HTMLElement>(".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<typeof setTimeout> | null = null;
|
||
const sendSize = () => {
|
||
if (sizeTimer) clearTimeout(sizeTimer);
|
||
sizeTimer = setTimeout(() => {
|
||
sizeTimer = null;
|
||
sendSizeNow();
|
||
}, 150);
|
||
};
|
||
let retryTimer: ReturnType<typeof setTimeout> | 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 (
|
||
<div
|
||
// The gutter around the terminal is the terminal's own colour, so a
|
||
// dark pane never sits in a light-page frame.
|
||
className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5"
|
||
style={{ backgroundColor: palette.background }}
|
||
onDragEnter={(e) => {
|
||
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)}
|
||
>
|
||
<div
|
||
ref={containerRef}
|
||
className="h-full w-full"
|
||
style={covered ? { transform: "translateX(-300vw)" } : undefined}
|
||
data-testid="terminal-view"
|
||
/>
|
||
<p id={hintId} className="sr-only">
|
||
{t("terminal.keyboardHint")}
|
||
</p>
|
||
{tabMoves && (
|
||
<span data-testid="terminal-tab-moves" role="status" className={`${chip} bottom-2 right-3`}>
|
||
{t("terminal.tabMoves")}
|
||
</span>
|
||
)}
|
||
{selectHint && !reconnecting && (
|
||
<span className={`${chip} bottom-2 left-3 flex items-center gap-2`}>
|
||
{t("terminal.selectHint")}
|
||
<button
|
||
type="button"
|
||
onClick={dismissSelectHint}
|
||
aria-label={t("terminal.dismissHint")}
|
||
title={t("terminal.dismissHint")}
|
||
className="rounded p-0.5 hover:bg-white/15"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</span>
|
||
)}
|
||
{find !== null && (
|
||
<div
|
||
data-testid="terminal-find"
|
||
role="search"
|
||
className={`${chip} right-3 top-2 flex items-center gap-1 !p-1`}
|
||
>
|
||
<input
|
||
ref={findInput}
|
||
value={find}
|
||
aria-label={t("terminal.find")}
|
||
placeholder={t("terminal.find")}
|
||
onChange={(e) => {
|
||
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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => runFind(true)}
|
||
aria-label={t("terminal.findPrev")}
|
||
title={t("terminal.findPrev")}
|
||
className="rounded p-0.5 hover:bg-white/15"
|
||
>
|
||
<ArrowUp className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => runFind()}
|
||
aria-label={t("terminal.findNext")}
|
||
title={t("terminal.findNext")}
|
||
className="rounded p-0.5 hover:bg-white/15"
|
||
>
|
||
<ArrowDown className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={closeFind}
|
||
aria-label={t("terminal.findClose")}
|
||
title={t("terminal.findClose")}
|
||
className="rounded p-0.5 hover:bg-white/15"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
{reconnecting && (
|
||
<div
|
||
data-testid="terminal-reconnecting"
|
||
role="status"
|
||
className="absolute inset-0 z-20 flex items-center justify-center bg-black/60 text-sm text-white"
|
||
>
|
||
{t("terminal.reconnecting")}
|
||
</div>
|
||
)}
|
||
{drop && (
|
||
// pointer-events-none: the drop must land on the terminal, not on this.
|
||
<div
|
||
data-testid="terminal-drop"
|
||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
|
||
}`}
|
||
>
|
||
{t(`drop.${drop}`)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|