- Terminals and file managers as tabs of a chat; chat terminals keep running on the server (no idle timeout) and re-attach from any device - "Just chat" in New session and a built-in chat mode with a lean prompt and only web tools; custom modes with kind (coding/conversation), read-only and tool picker - Mode selector as a dropdown - The chat shows running compactions (automatic: tokens and share of the limit) and labels summaries - Docker image builds and serves the terminal client binaries again - Fix: atomic session updates; background title/usage writes no longer undo each other (auto compaction triggered one turn late) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
8.5 KiB
TypeScript
269 lines
8.5 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Terminal as XTerm, type ITheme } from "@xterm/xterm";
|
|
import { FitAddon } from "@xterm/addon-fit";
|
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
import "@xterm/xterm/css/xterm.css";
|
|
import { del, get, post } from "../api";
|
|
import { useT } from "../i18n";
|
|
import { navigate } from "../router";
|
|
import { onThemeChange } from "../theme";
|
|
import { toast } from "../dialogs";
|
|
import { Icon } from "./Icon";
|
|
|
|
export interface TermInfo {
|
|
id: string;
|
|
cwd: string;
|
|
session_id?: string | null;
|
|
created_at: number;
|
|
alive: boolean;
|
|
attached: boolean;
|
|
}
|
|
|
|
/** Where the next new terminal opens (set by "open terminal here" buttons). */
|
|
export const TERM_CWD = "codecraft.termCwd";
|
|
|
|
/** xterm colours from the active theme's CSS variables. */
|
|
function themeColors(): ITheme {
|
|
const css = getComputedStyle(document.documentElement);
|
|
const v = (name: string) => css.getPropertyValue(name).trim();
|
|
return {
|
|
background: v("--bg0"),
|
|
foreground: v("--fg1"),
|
|
cursor: v("--accent"),
|
|
cursorAccent: v("--bg0"),
|
|
selectionBackground: `${v("--blue")}55`,
|
|
black: v("--bg2"),
|
|
red: v("--red"),
|
|
green: v("--green"),
|
|
yellow: v("--accent"),
|
|
blue: v("--blue"),
|
|
magenta: v("--purple"),
|
|
cyan: v("--aqua"),
|
|
white: v("--fg2"),
|
|
brightBlack: v("--muted"),
|
|
brightRed: v("--red"),
|
|
brightGreen: v("--green"),
|
|
brightYellow: v("--accent2"),
|
|
brightBlue: v("--blue"),
|
|
brightMagenta: v("--purple"),
|
|
brightCyan: v("--aqua"),
|
|
brightWhite: v("--fg0"),
|
|
};
|
|
}
|
|
|
|
const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/";
|
|
|
|
export function TerminalPage({ id, onMenu }: { id?: string; onMenu: () => void }) {
|
|
const t = useT();
|
|
const [list, setList] = useState<TermInfo[]>([]);
|
|
const [error, setError] = useState<string>();
|
|
|
|
const reload = () =>
|
|
get<TermInfo[]>("/terminals")
|
|
.then(setList)
|
|
.catch((e) => setError(e.message));
|
|
|
|
useEffect(() => {
|
|
void reload();
|
|
}, [id]);
|
|
|
|
const open = async () => {
|
|
try {
|
|
const cwd = sessionStorage.getItem(TERM_CWD) ?? localStorage.getItem("codecraft.lastDir") ?? undefined;
|
|
sessionStorage.removeItem(TERM_CWD);
|
|
const info = await post<TermInfo>("/terminals", { cwd, cols: 100, rows: 30 });
|
|
await reload();
|
|
navigate(`/terminal/${info.id}`);
|
|
} catch (e: any) {
|
|
toast(e.message);
|
|
}
|
|
};
|
|
|
|
// /terminal/new opens a fresh one; /terminal shows the newest.
|
|
useEffect(() => {
|
|
if (id === "new") void open();
|
|
else if (!id && list.length) navigate(`/terminal/${list[list.length - 1].id}`);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [id, list.length]);
|
|
|
|
const close = async (tid: string) => {
|
|
await del(`/terminals/${tid}`).catch(() => undefined);
|
|
const rest = list.filter((x) => x.id !== tid);
|
|
setList(rest);
|
|
navigate(rest.length ? `/terminal/${rest[rest.length - 1].id}` : "/terminal");
|
|
};
|
|
|
|
const active = list.find((x) => x.id === id);
|
|
|
|
return (
|
|
<>
|
|
<div className="topbar">
|
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
|
<Icon name="menu" />
|
|
</button>
|
|
<div className="grow term-tabs">
|
|
{list.map((x) => (
|
|
<span key={x.id} className={`term-tab ${x.id === id ? "active" : ""} ${x.alive ? "" : "dead"}`}>
|
|
<button className="ghost small" onClick={() => navigate(`/terminal/${x.id}`)} title={x.cwd}>
|
|
<Icon name="terminal" /> {basename(x.cwd)}
|
|
</button>
|
|
<button className="ghost small" onClick={() => close(x.id)} aria-label={t("terminal.close")} title={t("terminal.close")}>
|
|
<Icon name="close" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<button className="ghost small" onClick={open} title={t("terminal.new")} aria-label={t("terminal.new")}>
|
|
<Icon name="plus" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{error && <div className="notice err">{error}</div>}
|
|
{active ? (
|
|
<TermView key={active.id} info={active} />
|
|
) : (
|
|
<div className="empty">
|
|
{list.length === 0 && id !== "new" && (
|
|
<button className="primary" onClick={open}>
|
|
<Icon name="terminal" /> {t("terminal.new")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function TermView({ info }: { info: TermInfo }) {
|
|
const t = useT();
|
|
const host = useRef<HTMLDivElement>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const termRef = useRef<XTerm | null>(null);
|
|
const [ctrl, setCtrl] = useState(false);
|
|
const ctrlRef = useRef(false);
|
|
const [state, setState] = useState<"connecting" | "open" | "closed">("connecting");
|
|
|
|
useEffect(() => {
|
|
const term = new XTerm({
|
|
fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--mono").trim() || "monospace",
|
|
fontSize: matchMedia("(max-width: 700px)").matches ? 12 : 14,
|
|
cursorBlink: true,
|
|
scrollback: 5000,
|
|
theme: themeColors(),
|
|
allowProposedApi: false,
|
|
});
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
term.loadAddon(new WebLinksAddon());
|
|
term.open(host.current!);
|
|
termRef.current = term;
|
|
const offTheme = onThemeChange(() => {
|
|
term.options.theme = themeColors();
|
|
});
|
|
|
|
let ws: WebSocket;
|
|
let stopped = false;
|
|
let retry: ReturnType<typeof setTimeout> | undefined;
|
|
const enc = new TextEncoder();
|
|
const sendResize = () => {
|
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
|
};
|
|
const connect = () => {
|
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
ws = new WebSocket(`${proto}://${location.host}/api/terminals/${info.id}/ws`);
|
|
ws.binaryType = "arraybuffer";
|
|
wsRef.current = ws;
|
|
let first = true;
|
|
ws.onopen = () => {
|
|
setState("open");
|
|
fit.fit();
|
|
sendResize();
|
|
};
|
|
ws.onmessage = (m) => {
|
|
if (typeof m.data === "string") return; // hello / control
|
|
if (first) {
|
|
// Re-attached: the server replays recent output.
|
|
term.reset();
|
|
first = false;
|
|
}
|
|
term.write(new Uint8Array(m.data));
|
|
};
|
|
ws.onclose = () => {
|
|
setState("closed");
|
|
if (!stopped) retry = setTimeout(connect, 1500);
|
|
};
|
|
};
|
|
connect();
|
|
|
|
term.onData((d) => {
|
|
let data = d;
|
|
// On-screen Ctrl toggle (mobile): turn the next letter into a control code.
|
|
if (ctrlRef.current && d.length === 1) {
|
|
const c = d.toUpperCase().charCodeAt(0);
|
|
if (c >= 64 && c <= 95) data = String.fromCharCode(c - 64);
|
|
ctrlRef.current = false;
|
|
setCtrl(false);
|
|
}
|
|
if (ws.readyState === WebSocket.OPEN) ws.send(enc.encode(data));
|
|
});
|
|
const ro = new ResizeObserver(() => {
|
|
try {
|
|
fit.fit();
|
|
sendResize();
|
|
} catch {
|
|
/* not visible */
|
|
}
|
|
});
|
|
ro.observe(host.current!);
|
|
term.focus();
|
|
|
|
return () => {
|
|
stopped = true;
|
|
clearTimeout(retry);
|
|
ro.disconnect();
|
|
offTheme();
|
|
ws?.close();
|
|
term.dispose();
|
|
};
|
|
}, [info.id]);
|
|
|
|
const key = (seq: string) => {
|
|
const ws = wsRef.current;
|
|
if (ws?.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(seq));
|
|
termRef.current?.focus();
|
|
};
|
|
|
|
return (
|
|
<div className="term-wrap">
|
|
<div className="term-host" ref={host} />
|
|
{state !== "open" && <div className="term-state">{t(state === "connecting" ? "terminal.connecting" : "terminal.reconnecting")}</div>}
|
|
<div className="term-keys" aria-label={t("terminal.keys")}>
|
|
<button onClick={() => key("\x1b")}>Esc</button>
|
|
<button onClick={() => key("\t")}>Tab</button>
|
|
<button
|
|
className={ctrl ? "active" : ""}
|
|
onClick={() => {
|
|
ctrlRef.current = !ctrlRef.current;
|
|
setCtrl(ctrlRef.current);
|
|
termRef.current?.focus();
|
|
}}
|
|
>
|
|
Ctrl
|
|
</button>
|
|
<button onClick={() => key("\x03")}>^C</button>
|
|
<button onClick={() => key("\x1b[A")} aria-label="Up">
|
|
<Icon name="arrow-up" />
|
|
</button>
|
|
<button onClick={() => key("\x1b[B")} aria-label="Down">
|
|
<Icon name="arrow-down" />
|
|
</button>
|
|
<button onClick={() => key("\x1b[D")} aria-label="Left">
|
|
<Icon name="arrow-left" />
|
|
</button>
|
|
<button onClick={() => key("\x1b[C")} aria-label="Right">
|
|
<Icon name="arrow-right" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|