Files
ai-control/src-tauri/src/terminal.rs
T
marcus hinz 9a99bca466 Modul-Split: Fachlogik nach domain/, OS-Code nach platform/, Tauri-Verdrahtung in app.rs/commands.rs
- lib.rs nur noch Prozessrollen-Dispatch + Panic-Tracer
- KWin-Script als KDE-Pendant zur GNOME-Extension (Popup ans Zeiger-
  Rechteck), wird von deb/rpm mit ausgeliefert
- SNI-Tray auf ksni (pure Rust), appindicator-Depends entfallen
2026-07-15 20:10:33 +02:00

295 lines
10 KiB
Rust

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<dyn Write + Send>,
master: Box<dyn MasterPty + Send>,
killer: Box<dyn ChildKiller + Send + Sync>,
}
#[derive(Default)]
pub struct Terminals(pub Mutex<HashMap<String, Session>>);
/// Öffnet ein Terminal für ein Projekt: startet das eigene Binary als
/// Terminal-Prozess (`--terminal <projekt>`), 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<Terminals>,
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::<Vec<u8>>();
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<Terminals>,
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<Terminals>,
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::<Terminals>();
let session = terminals.0.lock().unwrap().remove(window.label());
if let Some(mut s) = session {
let _ = s.killer.kill();
}
}