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 @@
-
+
diff --git a/vite.config.ts b/vite.config.ts
index 6fbc1f6..4f23e8a 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -18,6 +18,7 @@ export default defineConfig(async () => ({
main: new URL("./index.html", import.meta.url).pathname,
terminal: new URL("./terminal.html", import.meta.url).pathname,
popup: new URL("./popup.html", import.meta.url).pathname,
+ panel: new URL("./panel.html", import.meta.url).pathname,
},
},
},