f77049c238
Terminal (issue #33): - `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below — the torn left edge that only showed up once content scrolled. - The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery` in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl). `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed. - The terminal takes the keyboard on mount, so typing reaches tmux without clicking the pane first. - A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console returns to the setup form instead of leaving a dead pane behind a "New run" button. Pane environment: - `server/lib/desktop-env.js` forwards the graphical session's DISPLAY / WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from `systemctl --user show-environment`, this process's own values winning) into the pane. The server normally runs as a systemd user service with none of them, so `claude` there could not read the clipboard and answered "no image found in clipboard" on every image paste. Run setup: - `auto` is a permission mode and is now the default everywhere — allowlists, the `--permission-mode` fallback, the select, and the pane's initial state. - The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`) instead of pinned ids. `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here; pricing is unaffected because it keys off the resolved id the transcript reports. - **Create/update readme file** moved from the Add-lane modal into the lane's Tools menu: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first. - The Workspace's no-lane state is a real banner; the lane strip (add tile included) renders only once a lane exists. Refs #32, #33. Tests: npm run test:server (1828 pass), client vitest (558 pass).
233 lines
11 KiB
TypeScript
233 lines
11 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.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
import { useEffect, useRef } from "react";
|
||
import { Terminal } from "@xterm/xterm";
|
||
import { FitAddon } from "@xterm/addon-fit";
|
||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||
import "@xterm/xterm/css/xterm.css";
|
||
|
||
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;
|
||
|
||
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',
|
||
});
|
||
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();
|
||
};
|
||
}, [runId, wsBaseUrl]);
|
||
|
||
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
|
||
}
|