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:
@@ -1,4 +1,6 @@
|
||||
use alacritty_terminal::event::VoidListener;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use alacritty_terminal::event::{Event, EventListener};
|
||||
use alacritty_terminal::grid::Dimensions;
|
||||
use alacritty_terminal::index::{Column, Line, Point};
|
||||
use alacritty_terminal::term::{Config, Term};
|
||||
@@ -23,24 +25,55 @@ impl Dimensions for GridSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the escape sequences the terminal model wants written back to the PTY
|
||||
/// (Primary/Secondary Device Attributes, DSR cursor/status reports, etc.). Programs
|
||||
/// like fish block on these replies at startup; with a void listener they hang ~2s
|
||||
/// and then warn ("could not read response to Primary Device Attribute query").
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ReplyCollector {
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl EventListener for ReplyCollector {
|
||||
fn send_event(&self, event: Event) {
|
||||
if let Event::PtyWrite(text) = event {
|
||||
if let Ok(mut b) = self.buf.lock() {
|
||||
b.extend_from_slice(text.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns an alacritty terminal model and feeds raw PTY bytes into it.
|
||||
pub struct GridSurface {
|
||||
term: Term<VoidListener>,
|
||||
term: Term<ReplyCollector>,
|
||||
parser: Processor,
|
||||
size: GridSize,
|
||||
replies: ReplyCollector,
|
||||
}
|
||||
|
||||
impl GridSurface {
|
||||
pub fn new(cols: u16, rows: u16) -> Self {
|
||||
let size = GridSize { cols: cols as usize, lines: rows as usize };
|
||||
let term = Term::new(Config::default(), &size, VoidListener);
|
||||
Self { term, parser: Processor::new(), size }
|
||||
let replies = ReplyCollector::default();
|
||||
let term = Term::new(Config::default(), &size, replies.clone());
|
||||
Self { term, parser: Processor::new(), size, replies }
|
||||
}
|
||||
|
||||
pub fn feed(&mut self, bytes: &[u8]) {
|
||||
self.parser.advance(&mut self.term, bytes);
|
||||
}
|
||||
|
||||
/// Drain any escape sequences the model produced in response to queries fed so
|
||||
/// far. The caller must write these back to the PTY for query-driven programs
|
||||
/// (fish, vim, etc.) to proceed without timing out.
|
||||
pub fn take_replies(&mut self) -> Vec<u8> {
|
||||
match self.replies.buf.lock() {
|
||||
Ok(mut b) => std::mem::take(&mut *b),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, cols: u16, rows: u16) {
|
||||
self.size = GridSize { cols: cols as usize, lines: rows as usize };
|
||||
self.term.resize(self.size);
|
||||
@@ -56,7 +89,7 @@ impl GridSurface {
|
||||
self.term.grid()[point].c
|
||||
}
|
||||
|
||||
pub fn term(&self) -> &Term<VoidListener> {
|
||||
pub fn term(&self) -> &Term<ReplyCollector> {
|
||||
&self.term
|
||||
}
|
||||
|
||||
@@ -97,4 +130,15 @@ mod tests {
|
||||
assert_eq!(g.char_at(0, 0), 'a');
|
||||
assert_eq!(g.char_at(1, 0), 'c');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_device_attribute_query_gets_a_reply() {
|
||||
// fish (and friends) send ESC[c at startup and block on the response.
|
||||
let mut g = GridSurface::new(20, 5);
|
||||
g.feed(b"\x1b[c");
|
||||
let reply = g.take_replies();
|
||||
assert!(reply.starts_with(b"\x1b[?"), "expected a DA1 reply, got {reply:?}");
|
||||
// Replies are drained, not duplicated.
|
||||
assert!(g.take_replies().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,8 @@ pub enum Cmd {
|
||||
surface_id: Option<SurfaceId>,
|
||||
},
|
||||
Health,
|
||||
/// Which of the given CLI candidates are actually installed on the spawn PATH.
|
||||
WhichAgents { candidates: Vec<String> },
|
||||
Status,
|
||||
Shutdown,
|
||||
GetConfig,
|
||||
|
||||
@@ -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