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:
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user