158783d5d5
- Changes panel at full screen: the whole Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab is dropped; a working-copy file opens straight into the editor (Save/Cancel live once the text differs, Cancel restores it, Whole file toggles back to the diff), and the page is height-locked so the editor no longer collapses with no live run. - Overview splits into Staged Changes / Changes groups in the work range. - A changed file's whole tree row takes its status tint. - Drag an image onto the lane terminal: POST /api/run/drop-image saves it (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path is pasted into the pane, which is how claude attaches it. - Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40); claude-opus-5% had been pricing it as Opus 5. - server/lib/pricing-sync.js: a model family used in token_usage with no row of its own is inserted from LiteLLM's public price list, never updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly that one third-party GitHub raw URL.
337 lines
15 KiB
TypeScript
337 lines
15 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.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
import { useEffect, 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 "@xterm/xterm/css/xterm.css";
|
||
import { useTheme } from "../../hooks/useTheme";
|
||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||
import { api } from "../../lib/api";
|
||
|
||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||
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;
|
||
}
|
||
|
||
export function TerminalView({ runId, wsBaseUrl, onExit }: 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;
|
||
// 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);
|
||
const { t } = useTranslation("run");
|
||
const [drop, setDrop] = useState<DropState | null>(null);
|
||
// 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,
|
||
});
|
||
termRef.current = term;
|
||
const fit = new FitAddon();
|
||
term.loadAddon(fit);
|
||
if (containerRef.current) term.open(containerRef.current);
|
||
|
||
// 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.
|
||
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.
|
||
term.attachCustomKeyEventHandler((ev) => {
|
||
if (ev.type === "keydown" && 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 sendSize = () => {
|
||
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 }));
|
||
};
|
||
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;
|
||
// 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 = () => {
|
||
sendSize();
|
||
};
|
||
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[session ended, exit 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.
|
||
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 = () => sendSize();
|
||
term.textarea?.addEventListener("focus", reassert);
|
||
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
// 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
|
||
});
|
||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||
|
||
return () => {
|
||
disposed = true;
|
||
if (retryTimer) clearTimeout(retryTimer);
|
||
resizeObserver.disconnect();
|
||
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]);
|
||
|
||
return (
|
||
<div
|
||
className="relative h-full w-full"
|
||
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)}
|
||
>
|
||
<div
|
||
ref={containerRef}
|
||
className="h-full w-full"
|
||
style={{ backgroundColor: palette.background }}
|
||
data-testid="terminal-view"
|
||
/>
|
||
{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>
|
||
);
|
||
}
|