diff --git a/package-lock.json b/package-lock.json index c7a9f00..2edcd6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,14 @@ "name": "ai-control", "version": "0.0.0", "dependencies": { + "@fontsource/jetbrains-mono": "^5.2.8", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-opener": "^2.5.4", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-ligatures": "^0.10.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "vue": "^3.5.39" }, "devDependencies": { @@ -103,6 +108,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -882,6 +896,40 @@ } } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-ligatures": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0.tgz", + "integrity": "sha512-/Few8ZSHMib7sGjRJoc5l7bCtEB9XJfkNofvPpOcWADxKaUl8og8P172j67OoACSNJAXqeCLIuvj8WFCBkcTxg==", + "license": "MIT", + "dependencies": { + "font-finder": "^1.1.0", + "font-ligatures": "^1.4.1" + }, + "engines": { + "node": ">8.0.0" + } + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/alien-signals": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", @@ -941,6 +989,33 @@ } } }, + "node_modules/font-finder": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/font-finder/-/font-finder-1.1.0.tgz", + "integrity": "sha512-wpCL2uIbi6GurJbU7ZlQ3nGd61Ho+dSU6U83/xJT5UPFfN35EeCW/rOtS+5k+IuEZu2SYmHzDIPL9eA5tSYRAw==", + "license": "MIT", + "dependencies": { + "get-system-fonts": "^2.0.0", + "promise-stream-reader": "^1.0.1" + }, + "engines": { + "node": ">8.0.0" + } + }, + "node_modules/font-ligatures": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/font-ligatures/-/font-ligatures-1.4.1.tgz", + "integrity": "sha512-7W6zlfyhvCqShZ5ReUWqmSd9vBaUudW0Hxis+tqUjtHhsPU+L3Grf8mcZAtCiXHTzorhwdRTId2WeH/88gdFkw==", + "license": "MIT", + "dependencies": { + "font-finder": "^1.0.3", + "lru-cache": "^6.0.0", + "opentype.js": "^0.8.0" + }, + "engines": { + "node": ">8.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -956,6 +1031,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-system-fonts": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-system-fonts/-/get-system-fonts-2.0.2.tgz", + "integrity": "sha512-zzlgaYnHMIEgHRrfC7x0Qp0Ylhw/sHpM6MHXeVBTYIsvGf5GpbnClB+Q6rAPdn+0gd2oZZIo6Tj3EaWrt4VhDQ==", + "license": "MIT", + "engines": { + "node": ">8.0.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1229,6 +1313,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1263,6 +1359,18 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/opentype.js": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.8.0.tgz", + "integrity": "sha512-FQHR4oGP+a0m/f6yHoRpBOIbn/5ZWxKd4D/djHVJu8+KpBTYrJda0b7mLcgDEMWXE9xBCJm+qb0yv6FcvPjukg==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.2" + }, + "bin": { + "ot": "bin/ot" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1317,6 +1425,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/promise-stream-reader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-stream-reader/-/promise-stream-reader-1.0.1.tgz", + "integrity": "sha512-Tnxit5trUjBAqqZCGWwjyxhmgMN4hGrtpW3Oc/tRI4bpm/O2+ej72BB08l6JBnGQgVDGCLvHFGjGgQS6vzhwXg==", + "license": "MIT", + "engines": { + "node": ">8.0.0" + } + }, "node_modules/rolldown": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", @@ -1360,6 +1477,12 @@ "node": ">=0.10.0" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1528,6 +1651,12 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" } } } diff --git a/package.json b/package.json index 6775304..b4cf7b7 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,14 @@ "tauri": "tauri" }, "dependencies": { + "@fontsource/jetbrains-mono": "^5.2.8", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-opener": "^2.5.4", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-ligatures": "^0.10.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "vue": "^3.5.39" }, "devDependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2a06b87..13b58c4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,6 +82,7 @@ dependencies = [ "base64 0.22.1", "getrandom 0.2.17", "log", + "portable-pty", "serde", "serde_json", "sha2", @@ -224,7 +225,7 @@ checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" dependencies = [ "borsh-derive", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", ] [[package]] @@ -441,6 +442,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -788,6 +795,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -920,6 +933,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1775,6 +1799,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libappindicator" version = "0.9.0" @@ -1961,6 +1991,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2372,6 +2414,27 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3023,6 +3086,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3065,6 +3139,22 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3e19170..9369c63 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,3 +28,4 @@ ureq = { version = "2", features = ["json"] } base64 = "0.22" sha2 = "0.10" getrandom = "0.2" +portable-pty = "0.9" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 29234a7..a0cc551 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,10 +3,12 @@ "identifier": "default", "description": "enables the default permissions", "windows": [ - "main" + "main", + "term-*" ], "permissions": [ "core:default", + "core:window:allow-start-dragging", "autostart:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index edc892c..bbe54f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +mod terminal; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -75,6 +77,10 @@ fn bundle_id(project: &str) -> String { } fn is_running(project: &str) -> bool { + ghostty_running(project) || !terminal_pids(project).is_empty() +} + +fn ghostty_running(project: &str) -> bool { let out = Command::new("lsappinfo") .args(["find", &format!("bundleid={}", bundle_id(project))]) .output(); @@ -84,6 +90,20 @@ fn is_running(project: &str) -> bool { } } +/// PIDs der eingebauten Terminal-Prozesse (`app --terminal `). +fn terminal_pids(project: &str) -> Vec { + let out = Command::new("pgrep") + .args(["-f", "--", &format!("--terminal {project}$")]) + .output(); + match out { + Ok(o) => String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|l| l.trim().parse().ok()) + .collect(), + Err(_) => Vec::new(), + } +} + fn check_name(name: &str) -> Result<(), String> { if name.trim().is_empty() || name.contains('/') || name.contains("..") { return Err(format!("ungültiger Name: {name}")); @@ -402,6 +422,37 @@ fn start_project(project: String) -> Result<(), String> { open_project(&project) } +/// Beendet den laufenden Prozess eines Projekts: das eingebaute Terminal per +/// SIGTERM auf die exakte PID (nie über die Bundle-ID — die würde alle +/// Instanzen inkl. der Haupt-App treffen), sonst die Ghostty-App per Quit. +#[tauri::command] +fn stop_project(project: String) -> Result<(), String> { + let pids = terminal_pids(&project); + if !pids.is_empty() { + for pid in pids { + let out = Command::new("kill") + .arg(pid.to_string()) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + } + return Ok(()); + } + if ghostty_running(&project) { + let out = Command::new("osascript") + .args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + return Ok(()); + } + Err(format!("{project} läuft nicht")) +} + /// Beendet die Ghostty-App des Projekts (regulärer Quit, Ghostty darf noch /// nachfragen), wartet auf das Prozessende und startet sie neu. #[tauri::command] @@ -624,6 +675,22 @@ fn pct_decode(s: &str) -> String { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let mut args = std::env::args().skip(1); + let builder = match args.next().as_deref() { + // `app --terminal `: eigener Prozess pro Terminal-Fenster, + // damit jedes Terminal ein eigenes Dock-Icon bekommt. + Some("--terminal") => { + terminal_builder(args.next().expect("--terminal braucht einen Projektnamen")) + } + _ => main_builder(), + }; + builder + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +/// Haupt-App: reine Tray-App ohne Dock-Eintrag. +fn main_builder() -> tauri::Builder { use tauri::Manager; tauri::Builder::default() @@ -644,6 +711,14 @@ pub fn run() { #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); + // Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess + // (gleiches Binary, gleiche Config) kein main-Fenster anlegt. + tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default()) + .title("AI Control") + .inner_size(800.0, 600.0) + .visible(false) + .build()?; + let open = tauri::menu::MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?; let quit = tauri::menu::MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?; let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?; @@ -663,7 +738,7 @@ pub fn run() { Ok(()) }) - // Fenster schließen versteckt nur; Beenden geht übers Tray-Menü. + // Hauptfenster schließen versteckt nur; Beenden geht übers Tray-Menü. .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); @@ -681,13 +756,38 @@ pub fn run() { assign_pool, unassign_pool, start_project, + stop_project, restart_project, oauth_login, oauth_refresh, - set_apikey + set_apikey, + terminal::open_terminal + ]) +} + +/// Terminal-Prozess: ein Fenster mit eigener PTY; Activation-Policy bleibt +/// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal. +fn terminal_builder(project: String) -> tauri::Builder { + tauri::Builder::default() + .manage(terminal::Terminals::default()) + .setup(move |app| { + terminal::build_window(app.handle(), &project)?; + Ok(()) + }) + // Fenster zu → PTY-Session abräumen; danach endet der Prozess. + .on_window_event(|window, event| { + if let tauri::WindowEvent::Destroyed = event { + terminal::close(window); + } + }) + .invoke_handler(tauri::generate_handler![ + terminal::term_start, + terminal::term_log, + terminal::term_write, + terminal::term_resize, + // Pool-Chip im Terminal-Header braucht die Projektliste auch hier. + list_projects ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); } // ---------- Tests ---------- diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs new file mode 100644 index 0000000..7415228 --- /dev/null +++ b/src-tauri/src/terminal.rs @@ -0,0 +1,159 @@ +use base64::{engine::general_purpose::STANDARD, Engine}; +use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; + +/// 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. +#[tauri::command] +pub fn open_terminal(project: String) -> Result<(), String> { + let home = std::env::var("HOME").map_err(|e| e.to_string())?; + let dir = PathBuf::from(home).join("claude-projects").join(&project); + if !dir.is_dir() { + return Err(format!("Projektordner fehlt: {}", dir.display())); + } + 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(()) +} + +/// 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) -> tauri::Result<()> { + let builder = WebviewWindowBuilder::new( + app, + format!("term-{project}"), + WebviewUrl::App(format!("terminal.html?project={project}").into()), + ) + .title(format!("{project} — Session")) + .inner_size(980.0, 640.0) + // Theme-Hintergrund (Catppuccin Mocha), sonst blitzt beim Öffnen Weiß auf. + .background_color(tauri::window::Color(0x1e, 0x1e, 0x2e, 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); + + builder.build()?; + Ok(()) +} + +/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner. +/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht +/// claude-sync selbst — identisch zum Ghostty-Starter. +#[tauri::command] +pub fn term_start( + window: tauri::WebviewWindow, + terminals: State, + project: String, + rows: u16, + cols: u16, +) -> Result<(), String> { + let home = std::env::var("HOME").map_err(|e| e.to_string())?; + let cwd = PathBuf::from(home).join("claude-projects").join(&project); + + let pty = native_pty_system() + .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) + .map_err(|e| e.to_string())?; + + let mut cmd = CommandBuilder::new("/bin/zsh"); + cmd.args(["-ic", "claude-sync"]); + cmd.cwd(&cwd); + cmd.env("TERM", "xterm-256color"); + + 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(); + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + let _ = app.emit_to(&label, "pty-output", STANDARD.encode(&buf[..n])); + } + } + } + let _ = child.wait(); + let _ = app.emit_to(&label, "pty-exit", ()); + }); + + terminals.0.lock().unwrap().insert( + window.label().to_string(), + Session { writer, master: pty.master, killer }, + ); + 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 wie beim +/// Fenster-X in Ghostty. +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(); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e59f351..cbf84b1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,16 +10,7 @@ "beforeBuildCommand": "npm run build" }, "app": { - "windows": [ - { - "title": "AI Control", - "width": 800, - "height": 600, - "resizable": true, - "fullscreen": false, - "visible": false - } - ], + "windows": [], "security": { "csp": null } diff --git a/src/components/ProjectList.vue b/src/components/ProjectList.vue index 02531dc..b5f8062 100644 --- a/src/components/ProjectList.vue +++ b/src/components/ProjectList.vue @@ -29,9 +29,18 @@ async function refresh() { } } -async function start(project: Project) { +async function stop(project: Project) { try { - await invoke("start_project", { project: project.name }); + await invoke("stop_project", { project: project.name }); + await refresh(); + } catch (e) { + error.value = String(e); + } +} + +async function openTerminal(project: Project) { + try { + await invoke("open_terminal", { project: project.name }); await refresh(); } catch (e) { error.value = String(e); @@ -112,9 +121,8 @@ onUnmounted(() => window.clearInterval(timer)); - + + diff --git a/src/style.css b/src/style.css index 4c8cece..3477b18 100644 --- a/src/style.css +++ b/src/style.css @@ -95,13 +95,31 @@ td small { width: 10px; height: 10px; border-radius: 50%; - background: #555; + background: transparent; } .dot.on { background: #3ecf6a; } +button.start { + background: #2f9e4f; + border-color: #2f9e4f; +} + +button.start:hover { + background: #37b85c; +} + +button.stop { + background: #c0392b; + border-color: #c0392b; +} + +button.stop:hover { + background: #d64434; +} + .error { color: #ff6b6b; white-space: pre-wrap; diff --git a/src/terminal.ts b/src/terminal.ts new file mode 100644 index 0000000..67b1142 --- /dev/null +++ b/src/terminal.ts @@ -0,0 +1,117 @@ +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { WebglAddon } from "@xterm/addon-webgl"; +import { LigaturesAddon } from "@xterm/addon-ligatures"; +import "@xterm/xterm/css/xterm.css"; +import "@fontsource/jetbrains-mono/400.css"; +import "@fontsource/jetbrains-mono/500.css"; +import "@fontsource/jetbrains-mono/700.css"; +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; + +// Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln. +function showError(msg: string) { + const el = document.createElement("pre"); + el.style.cssText = + "color:#f38ba8;padding:16px;font:12px Menlo,monospace;white-space:pre-wrap"; + el.textContent = msg; + document.body.appendChild(el); + invoke("term_log", { msg }); +} +window.addEventListener("error", (e) => + showError(`error: ${e.message}\n${e.error?.stack ?? ""}`), +); +window.addEventListener("unhandledrejection", (e) => + showError(`rejection: ${e.reason}\n${e.reason?.stack ?? ""}`), +); + +const project = new URLSearchParams(location.search).get("project")!; +const win = getCurrentWebviewWindow(); + +document.getElementById("project-name")!.textContent = project; + +// Catppuccin Mocha +const theme = { + background: "#1e1e2e", + foreground: "#cdd6f4", + cursor: "#f5e0dc", + cursorAccent: "#1e1e2e", + selectionBackground: "#585b7080", + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#f5c2e7", + cyan: "#94e2d5", + white: "#bac2de", + brightBlack: "#585b70", + brightRed: "#f38ba8", + brightGreen: "#a6e3a1", + brightYellow: "#f9e2af", + brightBlue: "#89b4fa", + brightMagenta: "#f5c2e7", + brightCyan: "#94e2d5", + brightWhite: "#a6adc8", +}; + +const term = new Terminal({ + // Ligatur-Addon nutzt die Character-Joiner-API (Proposed API) + allowProposedApi: true, + fontFamily: '"JetBrains Mono", Menlo, monospace', + fontSize: 13, + fontWeightBold: "600", + lineHeight: 1.25, + letterSpacing: 0, + cursorBlink: true, + cursorStyle: "bar", + scrollback: 10000, + theme, +}); +const fit = new FitAddon(); +term.loadAddon(fit); + +// Shift+Enter als CSI-u-Sequenz senden (Zeilenumbruch in Claude Code, +// auch bei nicht-leerer Zeile). Neben keydown muss auch keypress unter- +// drückt werden, sonst sendet xterm.js zusätzlich \r und submittet damit. +term.attachCustomKeyEventHandler((e) => { + if (e.key === "Enter" && e.shiftKey) { + if (e.type === "keydown") invoke("term_write", { data: "\x1b[13;2u" }); + return false; + } + return true; +}); + +// Font muss geladen sein, bevor WebGL den Glyphen-Atlas aufbaut. +await document.fonts.load('13px "JetBrains Mono"'); +term.open(document.getElementById("term")!); +// Reihenfolge laut Addon-Doku: Ligaturen vor WebGL aktivieren. +term.loadAddon(new LigaturesAddon()); +term.loadAddon(new WebglAddon()); +fit.fit(); + +function b64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +await win.listen("pty-output", (e) => term.write(b64ToBytes(e.payload))); +await win.listen("pty-exit", () => term.write("\r\n\x1b[90m[Prozess beendet]\x1b[0m\r\n")); + +term.onData((data) => invoke("term_write", { data })); +term.onResize(({ rows, cols }) => invoke("term_resize", { rows, cols })); +window.addEventListener("resize", () => fit.fit()); + +await invoke("term_start", { project, rows: term.rows, cols: term.cols }); +term.focus(); + +// Pool-Chip im Titelbalken +interface Project { + name: string; + pool: string | null; +} +const projects = await invoke("list_projects"); +const pool = projects.find((p) => p.name === project)?.pool; +if (pool) document.getElementById("pool")!.textContent = pool; diff --git a/terminal.html b/terminal.html new file mode 100644 index 0000000..92850c6 --- /dev/null +++ b/terminal.html @@ -0,0 +1,73 @@ + + + + + Terminal + + + +
+ + +
+
+ + + diff --git a/vite.config.ts b/vite.config.ts index 9542e40..ddb8191 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,15 @@ export default defineConfig(async () => ({ // // 1. prevent Vite from obscuring rust errors clearScreen: false, + // Zweite Seite: Terminal-Fenster (terminal.html) + build: { + rollupOptions: { + input: { + main: new URL("./index.html", import.meta.url).pathname, + terminal: new URL("./terminal.html", import.meta.url).pathname, + }, + }, + }, // 2. tauri expects a fixed port, fail if that port is not available server: { port: 1420,