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
+161 -4
View File
@@ -1,3 +1,4 @@
mod mcp;
mod terminal;
use serde::{Deserialize, Serialize};
@@ -57,6 +58,18 @@ impl Paths {
fn pool_dir(&self, pool: &str) -> PathBuf {
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 ----------
@@ -1194,6 +1207,7 @@ fn init_pool_config(
let mut settings = serde_json::json!({
"promptSuggestionEnabled": false,
"awaySummaryEnabled": false,
"permissions": { "allow": [PANEL_WRITE_PERMISSION] },
});
let base = settings.as_object_mut().unwrap();
if let Some(obj) = extra.as_object() {
@@ -1208,9 +1222,130 @@ fn init_pool_config(
if !claude_md.is_file() {
fs::write(&claude_md, "").map_err(|e| e.to_string())?;
}
install_panel_skill(dir);
register_mcp_server(dir);
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,
/// Prompt-Historie. (name, ist_ordner)
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)]
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).
let context = tauri::generate_context!();
let mut args = std::env::args().skip(1);
match args.next().as_deref() {
match first.as_deref() {
// `app --terminal <projekt>`: eigener Prozess pro Terminal-Fenster,
// damit jedes Terminal ein eigenes Dock-Icon bekommt.
Some("--terminal") => {
@@ -2020,6 +2164,10 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
// Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
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,
// damit sie da sind, bevor ein Terminal startet.
#[cfg(target_os = "linux")]
@@ -2182,10 +2330,17 @@ fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
terminal::build_window(app.handle(), &project, &cfg)?;
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| {
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![
@@ -2193,6 +2348,8 @@ fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
terminal::term_log,
terminal::term_write,
terminal::term_resize,
terminal::panel_read,
terminal::open_panel_window,
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
list_projects,
project_icon,