From 8aa758ae64db086b56ac793b70a415acf6eee1a7 Mon Sep 17 00:00:00 2001 From: "marcus.hinz" Date: Sat, 11 Jul 2026 12:44:51 +0200 Subject: [PATCH] Entwurfs-Panel mit MCP-Tool write_panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- package-lock.json | 17 ++- package.json | 1 + panel.html | 143 ++++++++++++++++++++++++ src-tauri/capabilities/default.json | 3 +- src-tauri/resources/panel-skill.md | 21 ++++ src-tauri/src/lib.rs | 165 +++++++++++++++++++++++++++- src-tauri/src/mcp.rs | 109 ++++++++++++++++++ src-tauri/src/terminal.rs | 66 +++++++++++ src/panel-view.ts | 53 +++++++++ src/panel.ts | 27 +++++ src/terminal.ts | 72 ++++++++++++ terminal.html | 153 +++++++++++++++++++++++++- vite.config.ts | 1 + 13 files changed, 823 insertions(+), 8 deletions(-) create mode 100644 panel.html create mode 100644 src-tauri/resources/panel-skill.md create mode 100644 src-tauri/src/mcp.rs create mode 100644 src/panel-view.ts create mode 100644 src/panel.ts diff --git a/package-lock.json b/package-lock.json index 462e818..73acc0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-control", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-control", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "dependencies": { "@fontsource/jetbrains-mono": "^5.2.8", @@ -18,6 +18,7 @@ "@xterm/addon-ligatures": "^0.10.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", + "marked": "^16.4.2", "vue": "^3.5.39", "vue-i18n": "^11.4.6" }, @@ -1368,6 +1369,18 @@ "@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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", diff --git a/package.json b/package.json index 515da01..4ce78c8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@xterm/addon-ligatures": "^0.10.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", + "marked": "^16.4.2", "vue": "^3.5.39", "vue-i18n": "^11.4.6" }, diff --git a/panel.html b/panel.html new file mode 100644 index 0000000..c12ee15 --- /dev/null +++ b/panel.html @@ -0,0 +1,143 @@ + + + + + Entwurf + + + +
+ Entwurf + + + +
+
+ + + diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 6a6513d..0d6021c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,7 +5,8 @@ "windows": [ "main", "popup", - "term-*" + "term-*", + "panel-*" ], "permissions": [ "core:default", diff --git a/src-tauri/resources/panel-skill.md b/src-tauri/resources/panel-skill.md new file mode 100644 index 0000000..3fb5695 --- /dev/null +++ b/src-tauri/resources/panel-skill.md @@ -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. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 86d72f7..00a3c80 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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____`. +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____`. +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::(&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::(&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 `: eigener Prozess pro Terminal-Fenster, // damit jedes Terminal ein eigenes Dock-Icon bekommt. Some("--terminal") => { @@ -2020,6 +2164,10 @@ fn main_builder() -> tauri::Builder { // 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 { 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 { 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, diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs new file mode 100644 index 0000000..97a85f4 --- /dev/null +++ b/src-tauri/src/mcp.rs @@ -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::(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 { + 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, + }), + } +} diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index e81570c..7820e6e 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -186,10 +186,20 @@ pub fn term_start( .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) .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"); cmd.args(["-ic", &crate::claude_command(&paths)]); cmd.cwd(&cwd); cmd.env("TERM", "xterm-256color"); + cmd.env("AI_CONTROL_PANEL", &panel_path); if let Some(pool_dir) = crate::project_pool_dir(&project)? { cmd.env("CLAUDE_CONFIG_DIR", pool_dir); } @@ -246,6 +256,8 @@ pub fn term_start( let _ = app.emit_to(&label, "pty-exit", ()); }); + spawn_panel_watcher(window.app_handle().clone(), panel_path); + terminals.0.lock().unwrap().insert( window.label().to_string(), Session { writer, master: pty.master, killer }, @@ -253,6 +265,60 @@ pub fn term_start( 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. #[tauri::command] pub fn term_log(window: tauri::WebviewWindow, msg: String) { diff --git a/src/panel-view.ts b/src/panel-view.ts new file mode 100644 index 0000000..edb154c --- /dev/null +++ b/src/panel-view.ts @@ -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, + }; +} diff --git a/src/panel.ts b/src/panel.ts new file mode 100644 index 0000000..75e8a3f --- /dev/null +++ b/src/panel.ts @@ -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("panel_read", { project })); + +await listen("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()); diff --git a/src/terminal.ts b/src/terminal.ts index e0d33ae..1940b7a 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -9,6 +9,8 @@ import "@fontsource/jetbrains-mono/600.css"; import "@fontsource/jetbrains-mono/700.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"; // Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln. function showError(msg: string) { @@ -529,3 +531,73 @@ window.addEventListener("resize", () => fit.fit()); await invoke("term_start", { project, rows: term.rows, cols: term.cols }); 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("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); +}); diff --git a/terminal.html b/terminal.html index 32711d5..0f394a2 100644 --- a/terminal.html +++ b/terminal.html @@ -168,8 +168,15 @@ cursor: nwse-resize; z-index: 101; } + #body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + } #term { flex: 1; + min-width: 0; min-height: 0; padding: 12px 4px 12px 14px; } @@ -183,6 +190,137 @@ #term .xterm-viewport::-webkit-scrollbar-track { 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; + } @@ -202,7 +340,20 @@ -
+
+
+ + +