use base64::{engine::general_purpose::STANDARD, Engine}; use portable_pty::{native_pty_system, ChildKiller, MasterPty, PtySize}; use std::collections::HashMap; use std::io::{Read, Write}; use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use crate::domain::paths::{panel_file, Paths}; use crate::domain::project::{project_pool_dir, terminal_config, TerminalConfig}; use crate::domain::registry::project_dir; use crate::domain::settings::claude_command; /// Eine laufende PTY-Session, gekoppelt an ein Terminal-Fenster (Key = Fenster-Label). pub struct Session { writer: Box, master: Box, killer: Box, } #[derive(Default)] pub struct Terminals(pub Mutex>); /// Öffnet ein Terminal für ein Projekt: startet das eigene Binary als /// Terminal-Prozess (`--terminal `), damit jedes Terminal ein /// eigenes Dock-Icon bekommt. Vorher tritt der rufende Prozess die /// Aktivierung ab — Aktivierung ist seit macOS 14 kooperativ, sonst öffnet /// das Terminal-Fenster hinter der aktiven App. #[tauri::command] pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> { let dir = project_dir(&Paths::real(), &project)?; if !dir.is_dir() { return Err(format!("Projektordner fehlt: {}", dir.display())); } let bundle_id = app.config().identifier.clone(); app .run_on_main_thread(move || crate::platform::yield_activation(&bundle_id)) .map_err(|e| e.to_string())?; let exe = std::env::current_exe().map_err(|e| e.to_string())?; std::process::Command::new(exe) .args(["--terminal", &project]) .spawn() .map_err(|e| e.to_string())?; Ok(()) } /// Fenster-Hintergrund je Theme — muss zu den Theme-Definitionen in /// terminal.ts passen, sonst blitzt beim Öffnen die falsche Farbe auf. fn theme_background(theme: &str) -> (u8, u8, u8) { match theme { "dracula" => (0x28, 0x2a, 0x36), "solarized-dark" => (0x00, 0x2b, 0x36), "gruvbox" => (0x28, 0x28, 0x28), "one-dark" => (0x28, 0x2c, 0x34), _ => (0x1e, 0x1e, 0x2e), // Catppuccin Mocha } } /// Baut das Terminal-Fenster des Terminal-Prozesses. Die PTY entsteht erst, /// wenn das Fenster geladen ist und `term_start` ruft — so gehen keine /// Ausgaben verloren, bevor der Event-Listener steht. pub fn build_window( app: &AppHandle, project: &str, cfg: &TerminalConfig, ) -> tauri::Result<()> { let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default()); let title = cfg.title.as_deref().unwrap_or(project); let builder = WebviewWindowBuilder::new( app, format!("term-{project}"), WebviewUrl::App(format!("terminal.html?project={project}").into()), ) .title(format!("{title} — Session")) .inner_size(980.0, 640.0) .background_color(tauri::window::Color(r, g, b, 0xff)); // Titelbar liegt über dem Inhalt; der Header in terminal.html ist Drag-Region. #[cfg(target_os = "macos")] let builder = builder .title_bar_style(tauri::TitleBarStyle::Overlay) .hidden_title(true); // Linux/GNOME: keine GTK-Deko — eigene Titelleiste im Header (terminal.html). #[cfg(target_os = "linux")] let builder = builder.decorations(false); builder.build()?; Ok(()) } /// Startet die PTY für das rufende Fenster: das konfigurierte Claude-Kommando /// (settings.json: claudeCommand, Default claude) im Projektordner, mit dem /// Pool-Verzeichnis als CLAUDE_CONFIG_DIR. Die Login-Shell aus $SHELL (-l) /// baut den PATH aus ihren Profil-Dateien auf — shell-agnostisch. #[tauri::command] pub fn term_start( window: tauri::WebviewWindow, terminals: State, project: String, rows: u16, cols: u16, ) -> Result<(), String> { let paths = Paths::real(); let cwd = project_dir(&paths, &project)?; let pty = native_pty_system() .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 = 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 = crate::platform::shell_command(&claude_command(&paths)); cmd.cwd(&cwd); cmd.env("TERM", "xterm-256color"); cmd.env("AI_CONTROL_PANEL", &panel_path); cmd.env("AI_CONTROL_PROJECT", &project); if let Some(pool_dir) = project_pool_dir(&project)? { cmd.env("CLAUDE_CONFIG_DIR", pool_dir); } let mut child = pty.slave.spawn_command(cmd).map_err(|e| e.to_string())?; drop(pty.slave); let killer = child.clone_killer(); let writer = pty.master.take_writer().map_err(|e| e.to_string())?; let mut reader = pty.master.try_clone_reader().map_err(|e| e.to_string())?; let label = window.label().to_string(); let app = window.app_handle().clone(); // Output gebündelt emittieren statt pro read(): der Reader schiebt Chunks // in einen Channel, der Emitter sammelt alles, was innerhalb von 8 ms // ankommt, zu einem Event zusammen. Bei Output-Fluten (Scrollen, TUI- // Redraws) sinkt die Zahl der IPC-Events um Größenordnungen; die 8 ms // Zusatzlatenz beim Tasten-Echo liegen unter einem Frame. let (tx, rx) = std::sync::mpsc::channel::>(); std::thread::spawn(move || { let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => { if tx.send(buf[..n].to_vec()).is_err() { break; } } } } let _ = child.wait(); // tx wird hier gedroppt — der Emitter sieht den geschlossenen Channel // erst, nachdem er alle Rest-Chunks emittiert hat. }); std::thread::spawn(move || { const FLUSH: std::time::Duration = std::time::Duration::from_millis(8); while let Ok(first) = rx.recv() { let mut chunk = first; let deadline = std::time::Instant::now() + FLUSH; loop { let now = std::time::Instant::now(); if now >= deadline { break; } match rx.recv_timeout(deadline - now) { Ok(more) => chunk.extend_from_slice(&more), Err(_) => break, } } let _ = app.emit_to(&label, "pty-output", STANDARD.encode(&chunk)); } 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 }, ); 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(panel_file(&project)).unwrap_or_default() } /// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die /// Änderung als `panel-update` an alle Fenster. #[tauri::command] pub fn panel_set(project: String, text: String) -> Result<(), String> { std::fs::write(panel_file(&project), text).map_err(|e| e.to_string()) } /// 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 = 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) { eprintln!("[{}] {msg}", window.label()); } /// Tastatureingaben des Fensters in die PTY. #[tauri::command] pub fn term_write( window: tauri::WebviewWindow, terminals: State, data: String, ) -> Result<(), String> { let mut map = terminals.0.lock().unwrap(); let s = map .get_mut(window.label()) .ok_or("keine Session für dieses Fenster")?; s.writer.write_all(data.as_bytes()).map_err(|e| e.to_string()) } #[tauri::command] pub fn term_resize( window: tauri::WebviewWindow, terminals: State, rows: u16, cols: u16, ) -> Result<(), String> { let map = terminals.0.lock().unwrap(); let s = map .get(window.label()) .ok_or("keine Session für dieses Fenster")?; s.master .resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) .map_err(|e| e.to_string()) } /// Beim Schließen eines Terminal-Fensters: Kind killen, Session verwerfen. /// Das Droppen des Masters schließt die PTY — claude bekommt HUP. pub fn close(window: &tauri::Window) { let terminals = window.state::(); let session = terminals.0.lock().unwrap().remove(window.label()); if let Some(mut s) = session { let _ = s.killer.kill(); } }