Eingebautes Terminal (xterm.js + portable-pty) und Start/Beenden-Button
- Terminal-Fenster als eigener Prozess (--terminal <projekt>) mit eigener PTY, startet claude-sync im Projektordner - Projektliste: ein Button pro Zeile — grün Starten (open_terminal) bzw. rot Beenden (stop_project, SIGTERM auf exakte PID / Ghostty-Quit) - is_running erfasst Ghostty oder laufenden Terminal-Prozess - Status-Kreis ohne Prozess unsichtbar
This commit is contained in:
Generated
+129
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+91
-1
@@ -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"
|
||||
|
||||
@@ -28,3 +28,4 @@ ureq = { version = "2", features = ["json"] }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
getrandom = "0.2"
|
||||
portable-pty = "0.9"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
+104
-4
@@ -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 <projekt>`).
|
||||
fn terminal_pids(project: &str) -> Vec<u32> {
|
||||
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 <projekt>`: 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<tauri::Wry> {
|
||||
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::Wry> {
|
||||
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 ----------
|
||||
|
||||
@@ -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<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.
|
||||
#[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<Terminals>,
|
||||
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<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 wie beim
|
||||
/// Fenster-X in Ghostty.
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button @click="start(p)">
|
||||
{{ p.running ? "Aktivieren" : "Starten" }}
|
||||
</button>
|
||||
<button v-if="p.running" class="stop" @click="stop(p)">Beenden</button>
|
||||
<button v-else class="start" @click="openTerminal(p)">Starten</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
+19
-1
@@ -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;
|
||||
|
||||
+117
@@ -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<string>("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<Project[]>("list_projects");
|
||||
const pool = projects.find((p) => p.name === project)?.pool;
|
||||
if (pool) document.getElementById("pool")!.textContent = pool;
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Terminal</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: #1e1e2e;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
header {
|
||||
height: 44px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
/* links Platz für die macOS-Ampel (Overlay-Titelbar) */
|
||||
padding: 0 16px 0 84px;
|
||||
background: #181825;
|
||||
border-bottom: 1px solid #313244;
|
||||
color: #cdd6f4;
|
||||
font:
|
||||
500 13px/1 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
#pool {
|
||||
margin-left: auto;
|
||||
background: #313244;
|
||||
color: #a6adc8;
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
#pool:empty {
|
||||
display: none;
|
||||
}
|
||||
#term {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px 4px 12px 14px;
|
||||
}
|
||||
#term .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
#term .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: #313244;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#term .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header data-tauri-drag-region>
|
||||
<span id="project-name" data-tauri-drag-region></span>
|
||||
<span id="pool"></span>
|
||||
</header>
|
||||
<div id="term"></div>
|
||||
<script type="module" src="/src/terminal.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user