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
+13 -5
View File
@@ -29,9 +29,18 @@ async function refresh() {
}
}
async function start(project: Project) {
async function stop(project: Project) {
try {
await invoke("start_project", { project: project.name });
await invoke("stop_project", { project: project.name });
await refresh();
} catch (e) {
error.value = String(e);
}
}
async function openTerminal(project: Project) {
try {
await invoke("open_terminal", { project: project.name });
await refresh();
} catch (e) {
error.value = String(e);
@@ -112,9 +121,8 @@ onUnmounted(() => window.clearInterval(timer));
</select>
</td>
<td>
<button @click="start(p)">
{{ p.running ? "Aktivieren" : "Starten" }}
</button>
<button v-if="p.running" class="stop" @click="stop(p)">Beenden</button>
<button v-else class="start" @click="openTerminal(p)">Starten</button>
</td>
</tr>
</tbody>
+19 -1
View File
@@ -95,13 +95,31 @@ td small {
width: 10px;
height: 10px;
border-radius: 50%;
background: #555;
background: transparent;
}
.dot.on {
background: #3ecf6a;
}
button.start {
background: #2f9e4f;
border-color: #2f9e4f;
}
button.start:hover {
background: #37b85c;
}
button.stop {
background: #c0392b;
border-color: #c0392b;
}
button.stop:hover {
background: #d64434;
}
.error {
color: #ff6b6b;
white-space: pre-wrap;
+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;