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:
Generated
+15
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+143
@@ -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>
|
||||
@@ -5,7 +5,8 @@
|
||||
"windows": [
|
||||
"main",
|
||||
"popup",
|
||||
"term-*"
|
||||
"term-*",
|
||||
"panel-*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
|
||||
@@ -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.
|
||||
+160
-3
@@ -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,17 +2330,26 @@ 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 {
|
||||
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![
|
||||
terminal::term_start,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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<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);
|
||||
});
|
||||
|
||||
+151
@@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -202,7 +340,20 @@
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<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 class="grip n" data-dir="North"></div>
|
||||
<div class="grip s" data-dir="South"></div>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user