Eingebautes Terminal (xterm.js + portable-pty) und Start/Beenden-Button

- Terminal-Fenster als eigener Prozess (--terminal <projekt>) mit eigener PTY,
  startet claude-sync im Projektordner
- Projektliste: ein Button pro Zeile — grün Starten (open_terminal) bzw.
  rot Beenden (stop_project, SIGTERM auf exakte PID / Ghostty-Quit)
- is_running erfasst Ghostty oder laufenden Terminal-Prozess
- Status-Kreis ohne Prozess unsichtbar
This commit is contained in:
marcus.hinz
2026-07-04 10:00:12 +02:00
parent 70e4898259
commit f7473797a9
13 changed files with 724 additions and 22 deletions
+117
View File
@@ -0,0 +1,117 @@
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures";
import "@xterm/xterm/css/xterm.css";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "@fontsource/jetbrains-mono/700.css";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
// Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln.
function showError(msg: string) {
const el = document.createElement("pre");
el.style.cssText =
"color:#f38ba8;padding:16px;font:12px Menlo,monospace;white-space:pre-wrap";
el.textContent = msg;
document.body.appendChild(el);
invoke("term_log", { msg });
}
window.addEventListener("error", (e) =>
showError(`error: ${e.message}\n${e.error?.stack ?? ""}`),
);
window.addEventListener("unhandledrejection", (e) =>
showError(`rejection: ${e.reason}\n${e.reason?.stack ?? ""}`),
);
const project = new URLSearchParams(location.search).get("project")!;
const win = getCurrentWebviewWindow();
document.getElementById("project-name")!.textContent = project;
// Catppuccin Mocha
const theme = {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "#585b7080",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#f5c2e7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
};
const term = new Terminal({
// Ligatur-Addon nutzt die Character-Joiner-API (Proposed API)
allowProposedApi: true,
fontFamily: '"JetBrains Mono", Menlo, monospace',
fontSize: 13,
fontWeightBold: "600",
lineHeight: 1.25,
letterSpacing: 0,
cursorBlink: true,
cursorStyle: "bar",
scrollback: 10000,
theme,
});
const fit = new FitAddon();
term.loadAddon(fit);
// Shift+Enter als CSI-u-Sequenz senden (Zeilenumbruch in Claude Code,
// auch bei nicht-leerer Zeile). Neben keydown muss auch keypress unter-
// drückt werden, sonst sendet xterm.js zusätzlich \r und submittet damit.
term.attachCustomKeyEventHandler((e) => {
if (e.key === "Enter" && e.shiftKey) {
if (e.type === "keydown") invoke("term_write", { data: "\x1b[13;2u" });
return false;
}
return true;
});
// Font muss geladen sein, bevor WebGL den Glyphen-Atlas aufbaut.
await document.fonts.load('13px "JetBrains Mono"');
term.open(document.getElementById("term")!);
// Reihenfolge laut Addon-Doku: Ligaturen vor WebGL aktivieren.
term.loadAddon(new LigaturesAddon());
term.loadAddon(new WebglAddon());
fit.fit();
function b64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
await win.listen<string>("pty-output", (e) => term.write(b64ToBytes(e.payload)));
await win.listen("pty-exit", () => term.write("\r\n\x1b[90m[Prozess beendet]\x1b[0m\r\n"));
term.onData((data) => invoke("term_write", { data }));
term.onResize(({ rows, cols }) => invoke("term_resize", { rows, cols }));
window.addEventListener("resize", () => fit.fit());
await invoke("term_start", { project, rows: term.rows, cols: term.cols });
term.focus();
// Pool-Chip im Titelbalken
interface Project {
name: string;
pool: string | null;
}
const projects = await invoke<Project[]>("list_projects");
const pool = projects.find((p) => p.name === project)?.pool;
if (pool) document.getElementById("pool")!.textContent = pool;