198fbb7980
- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js, workflow-stats.js); idx_events_session restored, idx_events_tool added - compat-sqlite gains function/pluck/readonly so a plugin install without a better-sqlite3 build boots again; antigravity-usage uses db.js's Database - install-hooks adds no checkout hooks while the agenthub plugin is enabled (events were doubled) - terminal attach runs the live tmux server's own binary; the pane scrollbar gets a visible 2-column style, and claude panes start with CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll - Codex sessions' conversation in GET /api/sessions/:id/transcript - Codex/Antigravity sessions claimed by their workspace without hooks - UI-audit follow-ups, docs and OpenAPI
597 lines
26 KiB
TypeScript
597 lines
26 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.
|
||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||
* and how `claude` attaches an image. 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.
|
||
* @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 { Terminal } from "@xterm/xterm";
|
||
import { FitAddon } from "@xterm/addon-fit";
|
||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||
import { ClipboardAddon, type IClipboardProvider } from "@xterm/addon-clipboard";
|
||
import { SearchAddon } from "@xterm/addon-search";
|
||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||
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";
|
||
|
||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||
|
||
/** 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);
|
||
},
|
||
};
|
||
// The addon's typings declare one argument, its code takes (base64, provider).
|
||
const ClipboardWithProvider = ClipboardAddon as unknown as new (
|
||
base64: undefined,
|
||
provider: IClipboardProvider
|
||
) => ClipboardAddon;
|
||
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 images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||
if (images.length === 0) return flash("imagesOnly");
|
||
setDrop("uploading");
|
||
try {
|
||
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
|
||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||
// sees one pasted path rather than typed keystrokes.
|
||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||
termRef.current?.focus();
|
||
setDrop(null);
|
||
} catch {
|
||
flash("failed");
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
// 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.
|
||
// 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: '"JetBrains Mono", monospace',
|
||
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;
|
||
|
||
/**
|
||
* 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 = () => {
|
||
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 }));
|
||
};
|
||
// 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 disposed = false;
|
||
|
||
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 1.5 s 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 = () => {
|
||
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 === "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 = () => {
|
||
if (disposed || ended) 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.
|
||
setReconnecting(true);
|
||
retryTimer = setTimeout(connect, 1500);
|
||
};
|
||
};
|
||
connect();
|
||
|
||
const dataDisposable = term.onData((data) => {
|
||
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 lane pane), and tmux's `window-size latest` sizes
|
||
// the window to whichever client was last active — resizing this pane out
|
||
// from under a terminal that is never told. Coming back to it says so.
|
||
const reassert = () => sendSizeNow();
|
||
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;
|
||
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 () => {
|
||
disposed = true;
|
||
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;
|
||
};
|
||
}, [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) => {
|
||
termRef.current?.paste(text);
|
||
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
|
||
),
|
||
[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" || drop === "imagesOnly"
|
||
? "border-red-400 text-red-300"
|
||
: "border-sky-400 text-sky-200"
|
||
}`}
|
||
>
|
||
{t(`drop.${drop}`)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|