937c63361b
Add detect rules for plan/review/gate/tests/intake nodes (Agent subagent matches, TaskCreate, broader test-runner regex) so more of the pipeline lights up without an explicit ccam stage call, and fix a review/gate detect overlap where senior-gate-reviewer also matched the generic reviewer pattern. Also fix TerminalView's TextDecoder dropping the `stream: true` flag, which corrupted multi-byte UTF-8 chars split across WebSocket frames.
136 lines
5.8 KiB
TypeScript
136 lines
5.8 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 "@xterm/xterm/css/xterm.css";
|
|
|
|
interface TerminalViewProps {
|
|
runId: string;
|
|
wsBaseUrl: string;
|
|
}
|
|
|
|
export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const term = new Terminal({ convertEol: true, fontSize: 13, cursorBlink: true });
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
if (containerRef.current) term.open(containerRef.current);
|
|
fit.fit();
|
|
|
|
// 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;
|
|
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 = () => {
|
|
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
|
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 = () => {
|
|
sock.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
|
};
|
|
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`);
|
|
}
|
|
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);
|
|
});
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
fit.fit();
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
|
}
|
|
});
|
|
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
if (retryTimer) clearTimeout(retryTimer);
|
|
resizeObserver.disconnect();
|
|
dataDisposable.dispose();
|
|
ws?.close();
|
|
term.dispose();
|
|
};
|
|
}, [runId, wsBaseUrl]);
|
|
|
|
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
|
|
}
|