- terminal: on workspaceId change call terminal.clear() and terminal.reset() so the previous workspace's scrollback is wiped before the new shell's output appears. - terminal: set macOptionIsMeta: false. With the default (true), Mac Option+key sequences (e.g. Option+[ emits ESC) leak a literal 0x1b into the shell's stdin, which crashes downstream REPLs like Python's with "invalid non-printable character U+001B".
195 lines
5.7 KiB
TypeScript
195 lines
5.7 KiB
TypeScript
import { FitAddon } from "@xterm/addon-fit";
|
|
import { Terminal } from "@xterm/xterm";
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
useShellStart,
|
|
useShellStatus,
|
|
useShellWebSocket,
|
|
} from "@/lib/api/process";
|
|
|
|
interface TerminalPanelProps {
|
|
workspaceId: string | null;
|
|
}
|
|
|
|
export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const terminalRef = useRef<Terminal | null>(null);
|
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
const hasWelcomedRef = useRef(false);
|
|
const isDeadRef = useRef(false);
|
|
const [failed, setFailed] = useState(false);
|
|
const [isDead, setIsDead] = useState(false);
|
|
const { data: shellStatus } = useShellStatus(workspaceId);
|
|
const shellRunning = shellStatus?.running === true;
|
|
const { status, send, onData } = useShellWebSocket(workspaceId, shellRunning);
|
|
const startShell = useShellStart();
|
|
|
|
// Initialize xterm once and keep it alive across WS status changes.
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
let terminal: Terminal | undefined;
|
|
let fitAddon: FitAddon | undefined;
|
|
|
|
try {
|
|
terminal = new Terminal({
|
|
cursorBlink: true,
|
|
convertEol: true,
|
|
// Prevent Mac Option+key from emitting ESC. Without this, typing
|
|
// Option+[ etc. leaks a literal 0x1b into the shell's stdin, which
|
|
// can break downstream programs that don't interpret escape
|
|
// sequences (e.g. Python's REPL reports "invalid non-printable
|
|
// character U+001B").
|
|
macOptionIsMeta: false,
|
|
fontFamily:
|
|
'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
|
fontSize: 12,
|
|
theme: {
|
|
background: "#171717",
|
|
foreground: "#e5e5e5",
|
|
},
|
|
});
|
|
fitAddon = new FitAddon();
|
|
terminal.loadAddon(fitAddon);
|
|
terminal.open(container);
|
|
terminalRef.current = terminal;
|
|
fitAddonRef.current = fitAddon;
|
|
} catch {
|
|
setFailed(true);
|
|
terminal?.dispose();
|
|
fitAddon?.dispose();
|
|
return;
|
|
}
|
|
|
|
let rafId: number | undefined;
|
|
const scheduleFit = () => {
|
|
if (rafId !== undefined) return;
|
|
rafId = requestAnimationFrame(() => {
|
|
rafId = undefined;
|
|
try {
|
|
fitAddon?.fit();
|
|
} catch {
|
|
// fit() throws when the container has zero dimensions; ignore
|
|
// and wait for the next ResizeObserver tick.
|
|
}
|
|
});
|
|
};
|
|
|
|
const resizeObserver = new ResizeObserver(scheduleFit);
|
|
resizeObserver.observe(container);
|
|
|
|
const handleResize = () => {
|
|
scheduleFit();
|
|
};
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
return () => {
|
|
if (rafId !== undefined) {
|
|
cancelAnimationFrame(rafId);
|
|
}
|
|
resizeObserver.disconnect();
|
|
window.removeEventListener("resize", handleResize);
|
|
terminal?.dispose();
|
|
fitAddon?.dispose();
|
|
terminalRef.current = null;
|
|
fitAddonRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
// Reset per-workspace state and wire the active socket to xterm.
|
|
useEffect(() => {
|
|
const terminal = terminalRef.current;
|
|
if (!terminal || !workspaceId) return;
|
|
|
|
hasWelcomedRef.current = false;
|
|
isDeadRef.current = false;
|
|
setIsDead(false);
|
|
// Clear the previous workspace's scrollback so the user does not see
|
|
// shell output from another workspace in the new one.
|
|
terminal.clear();
|
|
terminal.reset();
|
|
|
|
const removeInputHandler = terminal.onData((input) => {
|
|
if (!isDeadRef.current) {
|
|
send(input);
|
|
}
|
|
}).dispose;
|
|
|
|
const unsubscribeData = onData((chunk) => {
|
|
terminal.write(chunk);
|
|
if (chunk.startsWith("[process exited")) {
|
|
isDeadRef.current = true;
|
|
setIsDead(true);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
removeInputHandler();
|
|
unsubscribeData();
|
|
};
|
|
}, [workspaceId, send, onData]);
|
|
|
|
// Print a one-time welcome banner the first time the socket opens.
|
|
useEffect(() => {
|
|
const terminal = terminalRef.current;
|
|
if (!terminal || !workspaceId || status !== "open") return;
|
|
if (hasWelcomedRef.current) return;
|
|
hasWelcomedRef.current = true;
|
|
terminal.write(`\r\n[connected to ${workspaceId}]\r\n$ `);
|
|
}, [status, workspaceId]);
|
|
|
|
if (!workspaceId) {
|
|
return (
|
|
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
|
|
Select a workspace to use the terminal.
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (failed) {
|
|
return (
|
|
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
|
|
Terminal failed to initialize
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const statusHint = isDead
|
|
? "shell exited"
|
|
: status === "error"
|
|
? "terminal disconnected"
|
|
: status === "connecting"
|
|
? "connecting…"
|
|
: null;
|
|
|
|
const hint = shellRunning
|
|
? statusHint
|
|
: "shell not running - start it to use the terminal";
|
|
|
|
return (
|
|
<section className="flex h-full w-full flex-col overflow-hidden bg-[#171717]">
|
|
{hint && (
|
|
<div className="flex h-6 shrink-0 items-center gap-2 px-2 text-[11px] text-muted-foreground">
|
|
{hint}
|
|
{!shellRunning && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1.5 text-[11px]"
|
|
onClick={() => workspaceId && startShell.mutate(workspaceId)}
|
|
disabled={!workspaceId || startShell.isPending}
|
|
>
|
|
{startShell.isPending ? "starting…" : "Start shell"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div ref={containerRef} className="min-h-0 flex-1 p-2" />
|
|
</section>
|
|
);
|
|
}
|