wip: in-progress changes (grid, config, wizard, settings, pty) before session-persistence

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:28:19 +07:00
parent e37faf49d3
commit 4419f5660e
18 changed files with 365 additions and 42 deletions
+92
View File
@@ -110,6 +110,98 @@ pub fn default_shell() -> String {
"/bin/sh".into()
}
/// The user's full PATH for spawning panels: their login-shell PATH (which sources
/// `.zprofile`/`.zshrc`), merged with the daemon's current PATH and common install
/// dirs. Cached for the daemon's lifetime.
///
/// Why: when the GUI launches the daemon (Finder/launchd), the inherited PATH is
/// minimal (`/usr/bin:/bin:…`), so agents like `claude`, `codex`, `gemini` — installed
/// in `~/.local/bin`, npm-global, or Homebrew — aren't found and the panel exits
/// immediately with "Process exited". A bare `/bin/zsh` still works, which is why
/// shells launched fine but agents didn't.
pub fn enriched_path() -> String {
use std::collections::HashSet;
use std::sync::OnceLock;
static CACHE: OnceLock<String> = OnceLock::new();
CACHE
.get_or_init(|| {
let mut dirs: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut merge = |src: &str| {
for d in src.split(':') {
if !d.is_empty() && seen.insert(d.to_string()) {
dirs.push(d.to_string());
}
}
};
if let Some(p) = login_shell_path() {
merge(&p);
}
if let Ok(p) = std::env::var("PATH") {
merge(&p);
}
merge(&fallback_path_dirs());
dirs.join(":")
})
.clone()
}
/// Whether `cmd` is an executable resolvable on the spawn PATH (or an existing path
/// if it contains a slash). Used to only offer agents the user actually has installed.
pub fn is_installed(cmd: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
if cmd.is_empty() {
return false;
}
let is_exec = |p: &std::path::Path| {
p.metadata().map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false)
};
if cmd.contains('/') {
return is_exec(std::path::Path::new(cmd));
}
enriched_path().split(':').any(|dir| !dir.is_empty() && is_exec(&std::path::Path::new(dir).join(cmd)))
}
/// Common locations user-installed CLIs land in, as a colon-joined fallback.
fn fallback_path_dirs() -> String {
let mut v = vec![
"/opt/homebrew/bin".to_string(),
"/usr/local/bin".to_string(),
"/usr/bin".to_string(),
"/bin".to_string(),
"/usr/sbin".to_string(),
"/sbin".to_string(),
];
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() {
for d in [".local/bin", ".npm-global/bin", ".cargo/bin", ".bun/bin", ".deno/bin", ".volta/bin", "go/bin"] {
v.push(format!("{home}/{d}"));
}
}
}
v.join(":")
}
/// Capture PATH from the user's login+interactive shell so rc-file PATH edits apply.
/// Parses `env` output (the exported PATH is colon-joined regardless of shell, so this
/// works for fish too, where `$PATH` would otherwise print space-separated).
#[cfg(unix)]
fn login_shell_path() -> Option<String> {
let shell = login_shell().or_else(|| std::env::var("SHELL").ok())?;
let out = std::process::Command::new(&shell)
.args(["-lic", "env"])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.find_map(|l| l.strip_prefix("PATH="))
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
}
#[cfg(not(unix))]
fn login_shell_path() -> Option<String> { None }
/// The current user's login shell from the passwd database (`getpwuid`).
#[cfg(unix)]
fn login_shell() -> Option<String> {
+12 -1
View File
@@ -241,7 +241,7 @@ fn err(id: u64, code: &str, msg: &str) -> Envelope {
/// Compute spawn env (hooks for claude agents, zsh integration for zsh shells)
/// and whether a deterministic hook source is active.
fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (Vec<(String, String)>, bool) {
if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
let (mut env, active) = if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
let env = crate::hooks::prepare(sid, &crate::hooks::spacesh_bin());
let active = !env.is_empty();
(env, active)
@@ -249,7 +249,13 @@ fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (
(crate::hooks::shell_env(sid), false)
} else {
(vec![], false)
};
// Ensure the child sees the user's full PATH; the GUI/launchd-launched daemon
// otherwise can't find agents (claude/codex/gemini) and the panel exits at once.
if !env.iter().any(|(k, _)| k == "PATH") {
env.push(("PATH".to_string(), crate::config::enriched_path()));
}
(env, active)
}
/// Emit a `layout_changed` event for a workspace's current tree.
@@ -628,6 +634,11 @@ async fn handle_request(
}))).await;
}
Cmd::WhichAgents { candidates } => {
let available: Vec<String> = candidates.into_iter().filter(|c| crate::config::is_installed(c)).collect();
let _ = out.send(ok(id, serde_json::json!({ "available": available }))).await;
}
Cmd::Status => {
let (groups, workspaces) = reg.status();
let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await;
+13 -5
View File
@@ -215,18 +215,20 @@ async fn run_actor(
flush_deadline = Some(Instant::now() + FLUSH_INTERVAL);
}
if pending.len() >= FLUSH_BYTES {
flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
let replies = flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
if !replies.is_empty() { let _ = pty.write_input(&replies); }
flush_deadline = None;
}
}
None => {
flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
let _ = flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
break;
}
}
}
_ = timer => {
flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
let replies = flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
if !replies.is_empty() { let _ = pty.write_input(&replies); }
flush_deadline = None;
}
}
@@ -238,6 +240,8 @@ async fn run_actor(
/// Feed pending bytes into the grid, run detectors, broadcast output, and emit a
/// state change (if any). No-op when pending is empty.
/// Returns escape-sequence replies the terminal model produced (DA/DSR answers) that
/// the caller must write back to the PTY. Empty when there's nothing to feed or reply.
#[allow(clippy::too_many_arguments)]
fn flush(
pending: &mut Vec<u8>,
@@ -248,9 +252,9 @@ fn flush(
id: &SurfaceId,
bcast: &broadcast::Sender<Vec<u8>>,
state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
) {
) -> Vec<u8> {
if pending.is_empty() {
return;
return Vec::new();
}
// Deterministic source: OSC 133 markers in this chunk.
// Emit each distinct state transition immediately so no marker is dropped
@@ -265,6 +269,9 @@ fn flush(
}
}
grid.feed(&pending[..]);
// Answers to device-attribute / status queries the model just parsed; the actor
// writes these back to the PTY so query-blocking programs (fish) don't time out.
let replies = grid.take_replies();
// Best-effort fallback only when no deterministic source is active.
if !had_osc && !*deterministic {
if let Some(st) = FallbackScanner::scan(&grid.tail_text(6)) {
@@ -275,6 +282,7 @@ fn flush(
}
}
let _ = bcast.send(std::mem::take(pending));
replies
}
#[cfg(test)]