This repository has been archived on 2026-07-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
codespace/web/src/components/terminal/TerminalPanel.tsx
T
tao.chen 041c3dc002 fix(web): clear terminal on workspace change; disable Mac Option as Meta
- 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".
2026-07-06 13:39:20 +08:00

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>
);
}