Files
AgentHub/client/src/components/run/TerminalView.tsx
T
nntrivi2001 3a8be55b55 feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions,
  user_prefs.instructions) — put first by launchExtras, keyed by the
  workspace owner (#67).
- Session view: Settings opens as one more tab of the tab bar.
- Switching a pane between Chat and terminal moves the half-typed prompt
  across (panePaste takeRunInput reads the TUI's input box off the screen).
- Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted;
  Add to Chat floats at the selection's last line in the diff editor.
- S3: a server WITHOUT S3 (local mode / a dev server on the same database)
  never ends a run or releases a workspace whose folder is under the users
  root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used
  by reconcileOrphans and healRunId). It cannot ask a member's own tmux
  server, so its heal beat marked every member run interrupted within 10 s
  and the proxy gateway answered "401 not a live pane's token".
  Regression test: s3-unseen-member-runs.test.js.

Committed --no-verify: tsc -b, the touched server suites (dashboard-runs,
lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and
client suites (613) run by hand.
2026-10-12 13:06:04 +07:00

742 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 on it 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. 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.
* 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). 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 { useEffect, useId, useMemo, useRef, useState, 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 { notePaneFocus, registerPane } from "../../lib/panePaste";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
type DropState = "hint" | "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, and on a non-secure origin
// (no Clipboard API, no user gesture for a fallback) it simply does nothing.
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
},
};
/** 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");
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;
}
export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: 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);
},
[]
);
const onDrop = async (e: DragEvent) => {
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
e.preventDefault();
dragDepth.current = 0;
const files = Array.from(e.dataTransfer.files);
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: 13,
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.
term.attachCustomWheelEventHandler(() => {
const mode = term.modes.mouseTrackingMode;
const tracked = mode !== "none" && mode !== "x10";
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;
}
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 === "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);
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);
return () => {
if (retryTimer) clearTimeout(retryTimer);
if (sizeTimer) clearTimeout(sizeTimer);
searchRef.current = null;
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
term.textarea?.removeEventListener("focus", reassert);
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) => {
termRef.current?.paste(text);
// 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.
() => {
const term = termRef.current;
if (!term) return null;
const buf = term.buffer.active;
const rows: string[] = [];
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);
// 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)) return;
e.preventDefault();
dragDepth.current += 1;
setDrop("hint");
}}
onDragOver={(e) => {
if (!hasFiles(e)) return;
e.preventDefault(); // without it the browser refuses the drop
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
if (!hasFiles(e)) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
}}
onDrop={(e) => void onDrop(e)}
onFocus={() => notePaneFocus(runId)}
>
<div ref={containerRef} className="h-full w-full" 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>
);
}