Entwurfs-Panel mit MCP-Tool write_panel

- Andockbares Panel im Terminal-Fenster: Markdown-Rendering, Copy-Button,
  normale Selektion; in eigenes Fenster ablösbar und wieder andockbar
  (Splitter, panel-detached/panel-attached).
- MCP-stdio-Server (app --mcp-panel) mit Tool write_panel; schreibt in die
  Datei aus AI_CONTROL_PANEL, Watcher zieht sie ins Panel.
- Provisionierung je Pool: Server-Registrierung in .claude.json (alwaysLoad),
  Freigabe mcp__text-panel__write_panel in settings.json, Panel-Skill; bei
  App-Start (alle Pools) und Pool-Anlage. Alte tee-Freigaben werden entfernt.
- Capability deckt panel-*-Fenster ab.
This commit is contained in:
marcus.hinz
2026-07-11 12:44:51 +02:00
parent c39330bffb
commit 8aa758ae64
13 changed files with 823 additions and 8 deletions
+15 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ai-control", "name": "ai-control",
"version": "0.1.1", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ai-control", "name": "ai-control",
"version": "0.1.1", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
@@ -18,6 +18,7 @@
"@xterm/addon-ligatures": "^0.10.0", "@xterm/addon-ligatures": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"marked": "^16.4.2",
"vue": "^3.5.39", "vue": "^3.5.39",
"vue-i18n": "^11.4.6" "vue-i18n": "^11.4.6"
}, },
@@ -1368,6 +1369,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/muggle-string": { "node_modules/muggle-string": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+1
View File
@@ -20,6 +20,7 @@
"@xterm/addon-ligatures": "^0.10.0", "@xterm/addon-ligatures": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"marked": "^16.4.2",
"vue": "^3.5.39", "vue": "^3.5.39",
"vue-i18n": "^11.4.6" "vue-i18n": "^11.4.6"
}, },
+143
View File
@@ -0,0 +1,143 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Entwurf</title>
<style>
html,
body {
height: 100%;
margin: 0;
background: #1e1e2e;
color: #cdd6f4;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
}
.panel-head {
flex: none;
height: 38px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px 0 14px;
background: #181825;
border-bottom: 1px solid #313244;
font:
500 12px/1 -apple-system,
system-ui,
sans-serif;
user-select: none;
-webkit-user-select: none;
}
.panel-title {
margin-right: auto;
color: #a6adc8;
}
.panel-btn {
border: none;
background: transparent;
color: #a6adc8;
cursor: default;
border-radius: 6px;
padding: 6px 9px;
font: inherit;
display: flex;
align-items: center;
}
.panel-btn:hover {
background: #313244;
color: #cdd6f4;
}
.panel-btn.copied {
color: #a6e3a1;
}
#panel-content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 20px;
user-select: text;
-webkit-user-select: text;
cursor: text;
font-size: 13px;
line-height: 1.55;
}
#panel-content::-webkit-scrollbar {
width: 8px;
}
#panel-content::-webkit-scrollbar-thumb {
background: #313244;
border-radius: 4px;
}
#panel-content.raw {
white-space: pre-wrap;
word-break: break-word;
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 12px;
}
#panel-content.md h1,
#panel-content.md h2,
#panel-content.md h3 {
line-height: 1.25;
margin: 1em 0 0.4em;
}
#panel-content.md h1 {
font-size: 1.4em;
}
#panel-content.md h2 {
font-size: 1.2em;
}
#panel-content.md h3 {
font-size: 1.05em;
}
#panel-content.md :first-child {
margin-top: 0;
}
#panel-content.md p,
#panel-content.md ul,
#panel-content.md ol,
#panel-content.md pre,
#panel-content.md blockquote {
margin: 0 0 0.7em;
}
#panel-content.md code {
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 0.9em;
background: #313244;
border-radius: 4px;
padding: 1px 5px;
}
#panel-content.md pre {
background: #11111b;
border-radius: 8px;
padding: 12px 14px;
overflow: auto;
}
#panel-content.md pre code {
background: none;
padding: 0;
}
#panel-content.md blockquote {
border-left: 3px solid #313244;
padding-left: 12px;
color: #a6adc8;
}
#panel-content.md a {
color: #89b4fa;
}
</style>
</head>
<body>
<div class="panel-head" data-tauri-drag-region>
<span class="panel-title" data-tauri-drag-region>Entwurf</span>
<button class="panel-btn" id="panel-mode" title="Rohtext / gerendert">MD</button>
<button class="panel-btn" id="panel-copy" title="In die Zwischenablage">Kopieren</button>
<button class="panel-btn" id="panel-dock" title="Wieder andocken">Andocken</button>
</div>
<div id="panel-content" class="md"></div>
<script type="module" src="/src/panel.ts"></script>
</body>
</html>
+2 -1
View File
@@ -5,7 +5,8 @@
"windows": [ "windows": [
"main", "main",
"popup", "popup",
"term-*" "term-*",
"panel-*"
], ],
"permissions": [ "permissions": [
"core:default", "core:default",
+21
View File
@@ -0,0 +1,21 @@
---
name: panel
description: Beim Entwerfen längerer Texte (ADR, E-Mail, Dokument, Spezifikation, Commit-Message, Textbaustein) das MCP-Tool write_panel nutzen, statt den Text als Fließtext in den Chat zu schreiben — dann ist er im ai-control-Panel mit Maus selektier- und kopierbar. Auch auf ausdrückliche Bitte nutzen ("ins Panel", "ins Panel schreiben").
---
# Panel
Claude Code läuft im TUI; sichtbarer Text lässt sich dort nicht sauber selektieren und kopieren. Die ai-control-App zeigt Text in einem andockbaren Panel neben dem Terminal an — selektierbar, mit Copy-Button. Der Kanal dahin ist das MCP-Tool `write_panel` (Server `aicontrol`).
## Wann
- Automatisch, sobald ein längerer Fließtext oder ein Dokument entworfen wird (ADR, E-Mail, Spezifikation, Commit-Message, Textbaustein).
- Auf ausdrückliche Bitte, etwas ins Panel zu legen.
Nicht für normale Chat-Antworten, kurze Bestätigungen oder Code im laufenden Dialog.
## Vorgehen
1. `write_panel` mit dem vollständigen Entwurf als Argument `text` aufrufen (Markdown-Rohtext, das Panel rendert ihn). Das Panel blendet sich beim Schreiben automatisch ein.
2. Den Text **nicht** zusätzlich als Fließtext in den Chat schreiben — im Chat nur kurz bestätigen (z. B. „Entwurf im Panel.").
3. Ist das Tool nicht verfügbar (Terminal außerhalb von ai-control), den Text normal im Chat ausgeben.
+161 -4
View File
@@ -1,3 +1,4 @@
mod mcp;
mod terminal; mod terminal;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -57,6 +58,18 @@ impl Paths {
fn pool_dir(&self, pool: &str) -> PathBuf { fn pool_dir(&self, pool: &str) -> PathBuf {
self.pools_dir().join(pool) self.pools_dir().join(pool)
} }
/// Panel-Dateien pro Projekt: der Skill schreibt seinen Entwurf hier hinein,
/// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel.
fn panels_dir(&self) -> PathBuf {
self.config_dir().join("panels")
}
}
/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
/// AI_CONTROL_PANEL in der PTY-Umgebung.
pub(crate) fn panel_file(project: &str) -> PathBuf {
Paths::real().panels_dir().join(format!("{project}.md"))
} }
// ---------- Projekt-Registry ---------- // ---------- Projekt-Registry ----------
@@ -1194,6 +1207,7 @@ fn init_pool_config(
let mut settings = serde_json::json!({ let mut settings = serde_json::json!({
"promptSuggestionEnabled": false, "promptSuggestionEnabled": false,
"awaySummaryEnabled": false, "awaySummaryEnabled": false,
"permissions": { "allow": [PANEL_WRITE_PERMISSION] },
}); });
let base = settings.as_object_mut().unwrap(); let base = settings.as_object_mut().unwrap();
if let Some(obj) = extra.as_object() { if let Some(obj) = extra.as_object() {
@@ -1208,9 +1222,130 @@ fn init_pool_config(
if !claude_md.is_file() { if !claude_md.is_file() {
fs::write(&claude_md, "").map_err(|e| e.to_string())?; fs::write(&claude_md, "").map_err(|e| e.to_string())?;
} }
install_panel_skill(dir);
register_mcp_server(dir);
Ok(()) Ok(())
} }
/// Skill, der das MCP-Tool `write_panel` für Entwürfe anweist. Liegt im
/// Pool-Ordner (= CLAUDE_CONFIG_DIR), damit claude ihn als User-Skill findet.
const PANEL_SKILL: &str = include_str!("../resources/panel-skill.md");
/// MCP-Server-Key — zugleich das Label, das claude im Tool-Call anzeigt
/// („text panel"). Bestimmt den Tool-Namespace `mcp__<key>__<tool>`.
const PANEL_MCP_SERVER: &str = "text-panel";
/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
/// läuft. Namensschema: `mcp__<server>__<tool>`.
const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel";
/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine
/// evtl. ältere, tee-basierte Fassung).
fn install_panel_skill(pool_dir: &std::path::Path) {
let skill_dir = pool_dir.join("skills").join("panel");
if fs::create_dir_all(&skill_dir).is_ok() {
let _ = fs::write(skill_dir.join("SKILL.md"), PANEL_SKILL);
}
}
/// Frühere Panel-Freigaben (Bash- und alter MCP-Key), die aus den Pool-Settings
/// entfernt werden.
const STALE_PANEL_PERMISSIONS: [&str; 3] =
["Bash(tee:*)", "Bash(cat:*)", "mcp__aicontrol__write_panel"];
/// Trägt die MCP-Freigabe in die settings.json eines Pools ein und entfernt
/// die alten Bash-Freigaben — idempotent, ohne sonstige Einträge zu verändern.
fn ensure_panel_permission(pool_dir: &std::path::Path) {
let sp = pool_dir.join("settings.json");
let Ok(raw) = fs::read_to_string(&sp) else {
return;
};
let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&raw) else {
return;
};
let Some(obj) = v.as_object_mut() else { return };
let Some(perms) = obj
.entry("permissions")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
else {
return;
};
let Some(allow) = perms
.entry("allow")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
else {
return;
};
let before = allow.len();
allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
let has = allow.iter().any(|e| e.as_str() == Some(PANEL_WRITE_PERMISSION));
if !has {
allow.push(serde_json::json!(PANEL_WRITE_PERMISSION));
}
if has && allow.len() == before {
return; // nichts geändert
}
if let Ok(out) = serde_json::to_string_pretty(&v) {
let _ = fs::write(&sp, out + "\n");
}
}
/// Registriert den MCP-Server `aicontrol` (dieses Binary mit `--mcp-panel`) in
/// der `.claude.json` des Pools (= CLAUDE_CONFIG_DIR, User-Scope für alle
/// Projekte des Pools). Vorhandene .claude.json wird gemergt, nicht ersetzt.
fn register_mcp_server(pool_dir: &std::path::Path) {
let Ok(exe) = std::env::current_exe() else {
return;
};
let cfg = pool_dir.join(".claude.json");
let mut v = fs::read_to_string(&cfg)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
let Some(obj) = v.as_object_mut() else { return };
let Some(servers) = obj
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
else {
return;
};
servers.remove("aicontrol"); // alter Key vor Umbenennung
servers.insert(
PANEL_MCP_SERVER.into(),
serde_json::json!({
"type": "stdio",
"command": exe.to_string_lossy(),
"args": ["--mcp-panel"],
// Tool nicht deferren, sonst sieht das Modell write_panel nicht und
// schreibt den Entwurf in den Chat statt ins Panel.
"alwaysLoad": true,
}),
);
if let Ok(out) = serde_json::to_string_pretty(&v) {
let _ = fs::write(&cfg, out + "\n");
}
}
/// Panel-Skill, MCP-Server-Registrierung und Tool-Freigabe in einen Pool
/// bringen. install_panel_skill überschreibt eine evtl. alte tee-Fassung.
fn provision_pool(pool_dir: &std::path::Path) {
install_panel_skill(pool_dir);
register_mcp_server(pool_dir);
ensure_panel_permission(pool_dir);
}
/// Panel-MCP in alle vorhandenen Pools bringen (Migration beim App-Start).
fn provision_pools_for_panel(paths: &Paths) {
if let Ok(pools) = pool_names(paths) {
for (id, _) in pools {
provision_pool(&paths.pool_dir(&id));
}
}
}
/// Runtime, die pro Pool ins synced Repo gelinkt wird: Transkripte, Todos, /// Runtime, die pro Pool ins synced Repo gelinkt wird: Transkripte, Todos,
/// Prompt-Historie. (name, ist_ordner) /// Prompt-Historie. (name, ist_ordner)
const SYNCED_RUNTIME: [(&str, bool); 3] = const SYNCED_RUNTIME: [(&str, bool); 3] =
@@ -1869,10 +2004,19 @@ fn link_pool_runtime(pool: String) -> Result<(), String> {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let mut args = std::env::args().skip(1);
let first = args.next();
// `app --mcp-panel`: reiner MCP-stdio-Server (write_panel), kein Tauri/GTK.
// Von claude als Tool-Server gestartet.
if first.as_deref() == Some("--mcp-panel") {
mcp::run_mcp_panel();
return;
}
// generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST). // generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST).
let context = tauri::generate_context!(); let context = tauri::generate_context!();
let mut args = std::env::args().skip(1); match first.as_deref() {
match args.next().as_deref() {
// `app --terminal <projekt>`: eigener Prozess pro Terminal-Fenster, // `app --terminal <projekt>`: eigener Prozess pro Terminal-Fenster,
// damit jedes Terminal ein eigenes Dock-Icon bekommt. // damit jedes Terminal ein eigenes Dock-Icon bekommt.
Some("--terminal") => { Some("--terminal") => {
@@ -2020,6 +2164,10 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
// Session-Watcher: synct bei Session-Ende (Prozess verschwindet). // Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
spawn_session_watcher(); spawn_session_watcher();
// Panel-MCP-Server + Tool-Freigabe in alle Pools legen (auch bestehende),
// damit write_panel in jedem CLAUDE_CONFIG_DIR ohne Rückfrage bereitsteht.
provision_pools_for_panel(&Paths::real());
// Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen, // Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen,
// damit sie da sind, bevor ein Terminal startet. // damit sie da sind, bevor ein Terminal startet.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -2182,10 +2330,17 @@ fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
terminal::build_window(app.handle(), &project, &cfg)?; terminal::build_window(app.handle(), &project, &cfg)?;
Ok(()) Ok(())
}) })
// Fenster zu → PTY-Session abräumen; danach endet der Prozess. // Fenster zu → PTY-Session abräumen; danach endet der Prozess. Das
// abgelöste Panel-Fenster hat keine PTY: sein Schließen dockt das Panel
// wieder an (panel-attached), der Prozess läuft weiter.
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let tauri::WindowEvent::Destroyed = event { if let tauri::WindowEvent::Destroyed = event {
terminal::close(window); if window.label().starts_with("panel-") {
use tauri::{Emitter, Manager};
let _ = window.app_handle().emit("panel-attached", ());
} else {
terminal::close(window);
}
} }
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@@ -2193,6 +2348,8 @@ fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
terminal::term_log, terminal::term_log,
terminal::term_write, terminal::term_write,
terminal::term_resize, terminal::term_resize,
terminal::panel_read,
terminal::open_panel_window,
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name. // Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
list_projects, list_projects,
project_icon, project_icon,
+109
View File
@@ -0,0 +1,109 @@
//! Minimaler MCP-stdio-Server (`app --mcp-panel`), den claude als Tool-Server
//! startet. Er stellt genau ein Tool bereit: `write_panel(text)` schreibt den
//! Text in die Panel-Datei aus `AI_CONTROL_PANEL` (dieselbe Env, die der
//! Terminal-Prozess der PTY mitgibt und die claude an seine MCP-Kinder vererbt).
//! Der bestehende Datei-Watcher im Terminal-Prozess zieht den Inhalt ins Panel.
//!
//! Protokoll: JSON-RPC 2.0, newline-getrennt (ein Objekt pro Zeile).
use serde_json::{json, Value};
use std::io::{BufRead, Write};
pub fn run_mcp_panel() {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
let mut reader = stdin.lock();
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break; // stdin geschlossen -> claude hat den Server beendet
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(req) = serde_json::from_str::<Value>(trimmed) else {
continue;
};
let id = req.get("id").cloned();
let method = req.get("method").and_then(Value::as_str).unwrap_or("");
let result = handle(method, &req);
// Nur Requests (mit id) bekommen eine Antwort; Notifications nicht.
let (Some(id), Some(result)) = (id, result) else {
continue;
};
let resp = json!({ "jsonrpc": "2.0", "id": id, "result": result });
if writeln!(stdout, "{resp}").is_err() || stdout.flush().is_err() {
break;
}
}
}
/// Liefert das `result` für einen Request, oder None für Notifications/Unbekanntes.
fn handle(method: &str, req: &Value) -> Option<Value> {
match method {
"initialize" => {
// Vom Client vorgeschlagene Protokollversion übernehmen.
let protocol = req["params"]["protocolVersion"]
.as_str()
.unwrap_or("2024-11-05");
Some(json!({
"protocolVersion": protocol,
"capabilities": { "tools": {} },
"serverInfo": { "name": "text-panel", "version": env!("CARGO_PKG_VERSION") },
}))
}
"tools/list" => Some(json!({
"tools": [{
"name": "write_panel",
"description":
"Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
aufrufen und im Chat nur kurz bestätigen.",
"inputSchema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Vollständiger Entwurf als Markdown-Rohtext.",
}
},
"required": ["text"],
},
}],
})),
"tools/call" => Some(call_tool(req)),
"ping" => Some(json!({})),
_ => None,
}
}
fn call_tool(req: &Value) -> Value {
let name = req["params"]["name"].as_str().unwrap_or("");
if name != "write_panel" {
return json!({
"content": [{ "type": "text", "text": format!("Unbekanntes Tool: {name}") }],
"isError": true,
});
}
let text = req["params"]["arguments"]["text"].as_str().unwrap_or("");
match std::env::var("AI_CONTROL_PANEL") {
Ok(path) => match std::fs::write(&path, text) {
Ok(()) => json!({ "content": [{ "type": "text", "text": "Entwurf ins Panel geschrieben." }] }),
Err(e) => json!({
"content": [{ "type": "text", "text": format!("Panel-Datei nicht schreibbar: {e}") }],
"isError": true,
}),
},
Err(_) => json!({
"content": [{ "type": "text", "text": "AI_CONTROL_PANEL nicht gesetzt (Terminal außerhalb ai-control)." }],
"isError": true,
}),
}
}
+66
View File
@@ -186,10 +186,20 @@ pub fn term_start(
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Panel-Kanal: leere Datei anlegen (definierter Startzustand für den
// Watcher) und ihren Pfad als AI_CONTROL_PANEL in die PTY geben — der Skill
// schreibt seinen Entwurf dorthin.
let panel_path = crate::panel_file(&project);
if let Some(parent) = panel_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&panel_path, "");
let mut cmd = CommandBuilder::new("/bin/zsh"); let mut cmd = CommandBuilder::new("/bin/zsh");
cmd.args(["-ic", &crate::claude_command(&paths)]); cmd.args(["-ic", &crate::claude_command(&paths)]);
cmd.cwd(&cwd); cmd.cwd(&cwd);
cmd.env("TERM", "xterm-256color"); cmd.env("TERM", "xterm-256color");
cmd.env("AI_CONTROL_PANEL", &panel_path);
if let Some(pool_dir) = crate::project_pool_dir(&project)? { if let Some(pool_dir) = crate::project_pool_dir(&project)? {
cmd.env("CLAUDE_CONFIG_DIR", pool_dir); cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
} }
@@ -246,6 +256,8 @@ pub fn term_start(
let _ = app.emit_to(&label, "pty-exit", ()); let _ = app.emit_to(&label, "pty-exit", ());
}); });
spawn_panel_watcher(window.app_handle().clone(), panel_path);
terminals.0.lock().unwrap().insert( terminals.0.lock().unwrap().insert(
window.label().to_string(), window.label().to_string(),
Session { writer, master: pty.master, killer }, Session { writer, master: pty.master, killer },
@@ -253,6 +265,60 @@ pub fn term_start(
Ok(()) Ok(())
} }
/// Beobachtet die Panel-Datei des Projekts und schickt neuen Inhalt als
/// `panel-update` an alle Fenster dieses Terminal-Prozesses (angedocktes Panel
/// und ein evtl. abgelöstes Panel-Fenster). Pollt per mtime — kein notify-Crate,
/// die Datei ändert sich nur, wenn ein Entwurf geschrieben wird. Der Thread
/// endet mit dem Prozess (Fenster zu).
fn spawn_panel_watcher(app: AppHandle, path: std::path::PathBuf) {
std::thread::spawn(move || {
let mtime = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();
let mut last = mtime();
loop {
std::thread::sleep(std::time::Duration::from_millis(200));
let now = mtime();
if now != last {
last = now;
if let Ok(content) = std::fs::read_to_string(&path) {
let _ = app.emit("panel-update", content);
}
}
}
});
}
/// Aktueller Panel-Inhalt (Erstbefüllung eines gerade geöffneten Panel-Fensters).
#[tauri::command]
pub fn panel_read(project: String) -> String {
std::fs::read_to_string(crate::panel_file(&project)).unwrap_or_default()
}
/// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach
/// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus.
#[tauri::command]
pub fn open_panel_window(app: AppHandle, project: String) -> Result<(), String> {
let label = format!("panel-{project}");
if let Some(w) = app.get_webview_window(&label) {
let _ = w.set_focus();
return Ok(());
}
let cfg = crate::terminal_config(&project)?;
let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default());
let title = cfg.title.as_deref().unwrap_or(project.as_str());
WebviewWindowBuilder::new(
&app,
&label,
WebviewUrl::App(format!("panel.html?project={project}").into()),
)
.title(format!("{title} — Entwurf"))
.inner_size(480.0, 640.0)
.background_color(tauri::window::Color(r, g, b, 0xff))
.build()
.map_err(|e| e.to_string())?;
app.emit("panel-detached", ()).map_err(|e| e.to_string())?;
Ok(())
}
/// Debug: JS-Fehler aus dem Terminal-Fenster ins Dev-Log. /// Debug: JS-Fehler aus dem Terminal-Fenster ins Dev-Log.
#[tauri::command] #[tauri::command]
pub fn term_log(window: tauri::WebviewWindow, msg: String) { pub fn term_log(window: tauri::WebviewWindow, msg: String) {
+53
View File
@@ -0,0 +1,53 @@
import { marked } from "marked";
export interface PanelView {
set(text: string): void;
raw(): string;
}
/// Verkabelt einen Panel-Inhaltsbereich: MD/Roh-Umschalter und Copy-Button.
/// Der Rohtext bleibt die Quelle für „Kopieren"; die Ansicht rendert Markdown.
export function initPanelView(opts: {
content: HTMLElement;
copyBtn: HTMLElement;
modeBtn: HTMLElement;
}): PanelView {
let rawText = "";
let rendered = true;
function draw() {
if (rendered) {
opts.content.className = "md";
opts.content.innerHTML = marked.parse(rawText, { async: false });
} else {
opts.content.className = "raw";
opts.content.textContent = rawText;
}
opts.modeBtn.textContent = rendered ? "MD" : "Roh";
}
opts.modeBtn.addEventListener("click", () => {
rendered = !rendered;
draw();
});
opts.copyBtn.addEventListener("click", async () => {
await navigator.clipboard.writeText(rawText);
opts.copyBtn.classList.add("copied");
const label = opts.copyBtn.textContent;
opts.copyBtn.textContent = "Kopiert";
setTimeout(() => {
opts.copyBtn.classList.remove("copied");
opts.copyBtn.textContent = label;
}, 1200);
});
draw();
return {
set(text: string) {
rawText = text;
draw();
},
raw: () => rawText,
};
}
+27
View File
@@ -0,0 +1,27 @@
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { listen } from "@tauri-apps/api/event";
import { initPanelView } from "./panel-view";
// Abgelöstes Panel-Fenster: liest den aktuellen Entwurf einmal ein und folgt
// danach denselben `panel-update`-Events wie das angedockte Panel.
const project = new URLSearchParams(location.search).get("project")!;
const win = getCurrentWebviewWindow();
const view = initPanelView({
content: document.getElementById("panel-content")!,
copyBtn: document.getElementById("panel-copy")!,
modeBtn: document.getElementById("panel-mode")!,
});
view.set(await invoke<string>("panel_read", { project }));
await listen<string>("panel-update", (e) => view.set(e.payload));
// „Andocken": Fenster schließen — terminal.rs meldet `panel-attached`, das
// angedockte Panel im Terminal-Fenster erscheint wieder.
document
.getElementById("panel-dock")!
.addEventListener("click", () => win.close());
+72
View File
@@ -9,6 +9,8 @@ import "@fontsource/jetbrains-mono/600.css";
import "@fontsource/jetbrains-mono/700.css"; import "@fontsource/jetbrains-mono/700.css";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { listen } from "@tauri-apps/api/event";
import { initPanelView } from "./panel-view";
// Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln. // Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln.
function showError(msg: string) { function showError(msg: string) {
@@ -529,3 +531,73 @@ window.addEventListener("resize", () => fit.fit());
await invoke("term_start", { project, rows: term.rows, cols: term.cols }); await invoke("term_start", { project, rows: term.rows, cols: term.cols });
term.focus(); term.focus();
// --- Andockbares Entwurfs-Panel -------------------------------------------
// Der Skill schreibt Entwürfe in eine Datei; terminal.rs meldet neuen Inhalt
// per `panel-update`. Das Panel blendet sich dann ein — außer es ist gerade in
// ein eigenes Fenster abgelöst (`panel-detached`).
const panel = document.getElementById("panel")!;
const splitter = document.getElementById("splitter")!;
const panelContent = document.getElementById("panel-content")!;
// Panelfarben ans Theme koppeln.
panel.style.background = picked.header;
panel.style.color = theme.foreground;
splitter.style.background = picked.border;
(panel.querySelector(".panel-head") as HTMLElement).style.borderBottomColor =
picked.border;
const view = initPanelView({
content: panelContent,
copyBtn: document.getElementById("panel-copy")!,
modeBtn: document.getElementById("panel-mode")!,
});
let detached = false;
let hasContent = false;
function showPanel() {
panel.hidden = false;
splitter.hidden = false;
fit.fit();
}
function hidePanel() {
panel.hidden = true;
splitter.hidden = true;
fit.fit();
}
await listen<string>("panel-update", (e) => {
view.set(e.payload);
hasContent = true;
if (!detached) showPanel();
});
await listen("panel-detached", () => {
detached = true;
hidePanel();
});
await listen("panel-attached", () => {
detached = false;
if (hasContent) showPanel();
});
document
.getElementById("panel-detach")!
.addEventListener("click", () => invoke("open_panel_window", { project }));
document.getElementById("panel-hide")!.addEventListener("click", hidePanel);
// Splitter: Panelbreite ziehen.
splitter.addEventListener("mousedown", (e) => {
e.preventDefault();
const move = (ev: MouseEvent) => {
const w = Math.max(240, Math.min(window.innerWidth - 200, window.innerWidth - ev.clientX));
panel.style.width = `${w}px`;
fit.fit();
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
});
+152 -1
View File
@@ -168,8 +168,15 @@
cursor: nwse-resize; cursor: nwse-resize;
z-index: 101; z-index: 101;
} }
#body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
}
#term { #term {
flex: 1; flex: 1;
min-width: 0;
min-height: 0; min-height: 0;
padding: 12px 4px 12px 14px; padding: 12px 4px 12px 14px;
} }
@@ -183,6 +190,137 @@
#term .xterm-viewport::-webkit-scrollbar-track { #term .xterm-viewport::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
/* Andockbares Panel rechts (nur sichtbar, wenn ein Entwurf ankommt). */
#splitter {
flex: none;
width: 5px;
cursor: col-resize;
background: #313244;
}
#panel {
flex: none;
width: 380px;
min-width: 0;
display: flex;
flex-direction: column;
background: #181825;
color: #cdd6f4;
}
#panel[hidden],
#splitter[hidden] {
display: none;
}
.panel-head {
flex: none;
height: 36px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px 0 12px;
border-bottom: 1px solid #313244;
font:
500 12px/1 -apple-system,
system-ui,
sans-serif;
user-select: none;
-webkit-user-select: none;
}
.panel-title {
margin-right: auto;
color: #a6adc8;
}
.panel-btn {
border: none;
background: transparent;
color: #a6adc8;
cursor: default;
border-radius: 6px;
padding: 5px 8px;
font: inherit;
display: flex;
align-items: center;
}
.panel-btn:hover {
background: #313244;
color: #cdd6f4;
}
.panel-btn.copied {
color: #a6e3a1;
}
#panel-content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 14px 16px;
user-select: text;
-webkit-user-select: text;
cursor: text;
font-size: 13px;
line-height: 1.55;
}
#panel-content::-webkit-scrollbar {
width: 8px;
}
#panel-content::-webkit-scrollbar-thumb {
background: #313244;
border-radius: 4px;
}
#panel-content.raw {
white-space: pre-wrap;
word-break: break-word;
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 12px;
}
#panel-content.md h1,
#panel-content.md h2,
#panel-content.md h3 {
line-height: 1.25;
margin: 1em 0 0.4em;
}
#panel-content.md h1 {
font-size: 1.4em;
}
#panel-content.md h2 {
font-size: 1.2em;
}
#panel-content.md h3 {
font-size: 1.05em;
}
#panel-content.md :first-child {
margin-top: 0;
}
#panel-content.md p,
#panel-content.md ul,
#panel-content.md ol,
#panel-content.md pre,
#panel-content.md blockquote {
margin: 0 0 0.7em;
}
#panel-content.md code {
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 0.9em;
background: #313244;
border-radius: 4px;
padding: 1px 5px;
}
#panel-content.md pre {
background: #11111b;
border-radius: 8px;
padding: 12px 14px;
overflow: auto;
}
#panel-content.md pre code {
background: none;
padding: 0;
}
#panel-content.md blockquote {
border-left: 3px solid #313244;
padding-left: 12px;
color: #a6adc8;
}
#panel-content.md a {
color: #89b4fa;
}
</style> </style>
</head> </head>
<body> <body>
@@ -202,7 +340,20 @@
</button> </button>
</div> </div>
</header> </header>
<div id="term"></div> <div id="body">
<div id="term"></div>
<div id="splitter" hidden></div>
<aside id="panel" hidden>
<div class="panel-head">
<span class="panel-title">Entwurf</span>
<button class="panel-btn" id="panel-mode" title="Rohtext / gerendert">MD</button>
<button class="panel-btn" id="panel-copy" title="In die Zwischenablage">Kopieren</button>
<button class="panel-btn" id="panel-detach" title="In eigenes Fenster ablösen"></button>
<button class="panel-btn" id="panel-hide" title="Panel ausblenden"></button>
</div>
<div id="panel-content" class="md"></div>
</aside>
</div>
<div id="resize-grips" aria-hidden="true"> <div id="resize-grips" aria-hidden="true">
<div class="grip n" data-dir="North"></div> <div class="grip n" data-dir="North"></div>
<div class="grip s" data-dir="South"></div> <div class="grip s" data-dir="South"></div>
+1
View File
@@ -18,6 +18,7 @@ export default defineConfig(async () => ({
main: new URL("./index.html", import.meta.url).pathname, main: new URL("./index.html", import.meta.url).pathname,
terminal: new URL("./terminal.html", import.meta.url).pathname, terminal: new URL("./terminal.html", import.meta.url).pathname,
popup: new URL("./popup.html", import.meta.url).pathname, popup: new URL("./popup.html", import.meta.url).pathname,
panel: new URL("./panel.html", import.meta.url).pathname,
}, },
}, },
}, },