From 4419f5660ec03b60583bab70727cc7534540ba8f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Mon, 15 Jun 2026 15:28:19 +0700 Subject: [PATCH] wip: in-progress changes (grid, config, wizard, settings, pty) before session-persistence Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 10 ++-- Cargo.toml | 2 +- Makefile | 4 +- app/src-tauri/Cargo.lock | 15 ++++- app/src-tauri/Cargo.toml | 2 + app/src-tauri/src/bridge.rs | 24 ++++++++ app/src-tauri/src/lib.rs | 2 + app/src-tauri/tauri.conf.json | 14 ++++- app/src/Settings.tsx | 77 +++++++++++++++++++++--- app/src/TerminalView.tsx | 17 +++++- app/src/Wizard.tsx | 42 ++++++++++--- app/src/socketBridge.ts | 10 ++++ app/src/styles.css | 9 +++ crates/spacesh-core/src/grid.rs | 54 +++++++++++++++-- crates/spacesh-proto/src/message.rs | 2 + crates/spaceshd/src/config.rs | 92 +++++++++++++++++++++++++++++ crates/spaceshd/src/server.rs | 13 +++- crates/spaceshd/src/surface.rs | 18 ++++-- 18 files changed, 365 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99ddc86..49841ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,7 +869,7 @@ dependencies = [ [[package]] name = "spacesh-cli" -version = "0.1.0" +version = "0.1.2" dependencies = [ "anyhow", "clap", @@ -881,7 +881,7 @@ dependencies = [ [[package]] name = "spacesh-core" -version = "0.1.0" +version = "0.1.2" dependencies = [ "alacritty_terminal", "serde", @@ -890,7 +890,7 @@ dependencies = [ [[package]] name = "spacesh-proto" -version = "0.1.0" +version = "0.1.2" dependencies = [ "bytes", "serde", @@ -902,7 +902,7 @@ dependencies = [ [[package]] name = "spacesh-pty" -version = "0.1.0" +version = "0.1.2" dependencies = [ "anyhow", "bytes", @@ -912,7 +912,7 @@ dependencies = [ [[package]] name = "spaceshd" -version = "0.1.0" +version = "0.1.2" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index f22dd74..4dc131e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ [workspace.package] edition = "2021" -version = "0.1.0" +version = "0.1.2" [workspace.dependencies] tokio = { version = "1", features = ["full"] } diff --git a/Makefile b/Makefile index 9d9cf04..a6be9dc 100644 --- a/Makefile +++ b/Makefile @@ -43,8 +43,8 @@ targets: ## add rust targets for the universal build rustup target add aarch64-apple-darwin x86_64-apple-darwin .PHONY: bump -bump: ## increment the patch version in tauri.conf.json (single source of truth) - @node -e "const f='$(APP_DIR)/src-tauri/tauri.conf.json';const fs=require('fs');const j=JSON.parse(fs.readFileSync(f));const p=j.version.split('.').map(Number);p[2]=(p[2]||0)+1;j.version=p.join('.');fs.writeFileSync(f, JSON.stringify(j,null,2)+'\n');console.log('version → '+j.version)" +bump: ## increment the patch version for BOTH the GUI (tauri.conf.json) and the daemon (workspace Cargo.toml) + @node scripts/bump_version.mjs .PHONY: dmg dmg: bump targets ## bump version + build the universal (Intel + Apple Silicon) .dmg — UNSIGNED diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 7bb5373..07fe3e3 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -540,6 +540,17 @@ dependencies = [ "libc", ] +[[package]] +name = "core-text" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7" +dependencies = [ + "core-foundation", + "core-graphics", + "foreign-types", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -3413,6 +3424,8 @@ version = "0.1.0" dependencies = [ "anyhow", "base64 0.22.1", + "core-foundation", + "core-text", "dirs 5.0.1", "reqwest 0.12.28", "serde", @@ -3427,7 +3440,7 @@ dependencies = [ [[package]] name = "spacesh-proto" -version = "0.1.0" +version = "0.1.2" dependencies = [ "bytes", "serde", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 3a1668b..487d048 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -25,3 +25,5 @@ anyhow = "1" dirs = "5" # rustls (no openssl) so the universal-apple-darwin cross-build stays self-contained. reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +core-text = "22" +core-foundation = "0.10" diff --git a/app/src-tauri/src/bridge.rs b/app/src-tauri/src/bridge.rs index 803a877..a8394e7 100644 --- a/app/src-tauri/src/bridge.rs +++ b/app/src-tauri/src/bridge.rs @@ -422,6 +422,11 @@ pub async fn health(state: BridgeState<'_>) -> Result { data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?) } +#[tauri::command] +pub async fn which_agents(state: BridgeState<'_>, candidates: Vec) -> Result { + data_of(state.request(Cmd::WhichAgents { candidates }).await.map_err(|e| e.to_string())?) +} + // ---- Update check ---- /// Where the GUI looks for the published app version. Overridable via @@ -481,6 +486,25 @@ pub fn open_external(url: String) -> Result<(), String> { Ok(()) } +/// List the user's installed font families (CoreText) so Settings can offer any of +/// them for the terminal. Hidden system families (".SF NS" etc.) are dropped; the +/// result is de-duplicated and sorted case-insensitively. +#[tauri::command] +pub fn list_fonts() -> Vec { + use std::collections::BTreeSet; + let names = core_text::font_collection::get_family_names(); + let mut set: BTreeSet = BTreeSet::new(); + for name in names.iter() { + let s = name.to_string(); + if !s.is_empty() && !s.starts_with('.') { + set.insert(s); + } + } + let mut v: Vec = set.into_iter().collect(); + v.sort_by_key(|s| s.to_lowercase()); + v +} + // ---- Settings commands ---- #[tauri::command] diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 39b6777..b9df91c 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -54,8 +54,10 @@ pub fn run() { bridge::mark_read, bridge::clear_events, bridge::health, + bridge::which_agents, bridge::check_update, bridge::open_external, + bridge::list_fonts, bridge::get_config, bridge::set_config, bridge::shutdown_daemon, diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 83b218f..fab51a2 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "spacesh", - "version": "0.1.0", + "version": "0.1.2", "identifier": "xyz.spacesh.app", "build": { "frontendDist": "../dist", @@ -10,8 +10,16 @@ "beforeBuildCommand": "npm run build" }, "app": { - "windows": [{ "title": "spacesh", "width": 1100, "height": 720 }], - "security": { "csp": null } + "windows": [ + { + "title": "spacesh", + "width": 1100, + "height": 720 + } + ], + "security": { + "csp": null + } }, "bundle": { "active": true, diff --git a/app/src/Settings.tsx b/app/src/Settings.tsx index d9d1905..963cdfd 100644 --- a/app/src/Settings.tsx +++ b/app/src/Settings.tsx @@ -1,10 +1,74 @@ -import { useEffect, useRef, useState } from "react"; -import { X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { X, Search, Check } from "lucide-react"; import { COLORS, FONT, ACCENTS } from "./theme"; -import { setConfig, restartDaemon } from "./socketBridge"; +import { setConfig, restartDaemon, listFonts } from "./socketBridge"; import type { ConfigView, DaemonHealth } from "./socketBridge"; -const FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"]; +// Pinned defaults shown first; the rest are the user's installed families (list_fonts). +const DEFAULT_FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"]; + +/** Searchable font picker: type to filter, click to apply. Defaults pinned on top. */ +function FontPicker({ value, onPick }: { value: string; onPick: (family: string) => void }) { + const [installed, setInstalled] = useState([]); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const boxRef = useRef(null); + + useEffect(() => { void listFonts().then(setInstalled).catch(() => {}); }, []); + + // Close on outside click. + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [open]); + + const options = useMemo(() => { + const seen = new Set(); + const merged: string[] = []; + for (const f of [...DEFAULT_FONTS, ...installed]) { + const k = f.toLowerCase(); + if (!seen.has(k)) { seen.add(k); merged.push(f); } + } + const q = query.trim().toLowerCase(); + return q ? merged.filter((f) => f.toLowerCase().includes(q)) : merged; + }, [installed, query]); + + return ( +
+
+ + { setOpen(true); setQuery(""); }} + onChange={(e) => { setQuery(e.target.value); setOpen(true); }} + style={{ width: "100%", padding: "8px 8px 8px 30px", background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${open ? COLORS.accent : COLORS.borderStrong}`, borderRadius: 8, fontFamily: FONT.ui }} + /> +
+ {open && ( +
+ {options.length === 0 &&
Ничего не найдено
} + {options.map((f) => { + const isDefault = DEFAULT_FONTS.some((d) => d.toLowerCase() === f.toLowerCase()); + return ( + + ); + })} +
+ )} +
+ ); +} export function Settings({ config, health, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void; onReload: () => void }) { const ref = useRef(null); @@ -30,10 +94,7 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
Terminal font
- + void setConfig({ font_family: f })} />
Size {sizeLocal} + family ? `'${family}', ${NERD_FALLBACK}, monospace` + : `'JetBrains Mono Variable', 'JetBrains Mono', ${NERD_FALLBACK}, monospace`; + function xtermTheme(p: Record) { return { background: p["bg-panel"], @@ -30,7 +37,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; // call registerMarker/registerDecoration (proposed API). Without it findNext // throws and the scrollback search counter never updates. const term = new Terminal({ - fontFamily: font ? `'${font.family}', monospace` : "'JetBrains Mono Variable', 'JetBrains Mono', monospace", + fontFamily: fontStack(font?.family ?? null), fontSize: font?.size ?? 13, convertEol: false, scrollback: 10000, @@ -81,6 +88,12 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; let disposed = false; + // The Nerd Font fallback may finish loading after the first paint; once it's + // ready, drop the WebGL glyph atlas so cached blank cells re-rasterize with icons. + void document.fonts.load("16px 'Symbols Nerd Font Mono'").then(() => { + if (!disposed) webglRef.current?.clearTextureAtlas(); + }).catch(() => {}); + // Attach: fresh xterm instance, write snapshot, then stream live output. void attachSurface(surfaceId, (bytes) => { if (!disposed) term.write(decoder.decode(bytes)); @@ -112,7 +125,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; const t = termRef.current; if (!t) return; if (font) { - t.options.fontFamily = `'${font.family}', monospace`; + t.options.fontFamily = fontStack(font.family); t.options.fontSize = font.size; // The WebGL renderer caches rasterized glyphs in a texture atlas keyed by // the old font/size; without clearing it the grid keeps rendering stale diff --git a/app/src/Wizard.tsx b/app/src/Wizard.tsx index 1e3f0ce..ea672bd 100644 --- a/app/src/Wizard.tsx +++ b/app/src/Wizard.tsx @@ -1,16 +1,22 @@ import { useEffect, useRef, useState } from "react"; import { PresetPicker, PRESETS } from "./PresetPicker"; -import { openWorkspace, applyPreset } from "./socketBridge"; +import { openWorkspace, applyPreset, whichAgents } from "./socketBridge"; + +// Agents we know about; only the installed ones are offered (probed via whichAgents). +const KNOWN_AGENTS = ["claude", "codex", "gemini"]; +const CUSTOM = "custom…"; export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) { const [path, setPath] = useState("."); const [preset, setPreset] = useState("2x2"); const [agents, setAgents] = useState([]); + const [customCmds, setCustomCmds] = useState([]); + const [installed, setInstalled] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const pathRef = useRef(null); const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1; - const agentChoices = ["shell", "claude", "codex", "gemini"]; + const agentChoices = ["shell", ...installed, CUSTOM]; // Grab focus on open — otherwise keystrokes leak to the xterm panel behind us // (its helper textarea sits at z-index 1000 and keeps the live focus). @@ -19,6 +25,9 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => pathRef.current?.select(); }, []); + // Only offer agents the user actually has installed. + useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []); + async function create() { if (busy) return; setBusy(true); @@ -27,7 +36,12 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => const ws = await openWorkspace(path); const slotSpecs = Array.from({ length: slots }, (_, i) => { const a = agents[i] ?? "shell"; - return a === "shell" ? {} : { command: a }; + if (a === "shell") return {}; + if (a === CUSTOM) { + const parts = (customCmds[i] ?? "").trim().split(/\s+/).filter(Boolean); + return parts.length ? { command: parts[0], args: parts.slice(1) } : {}; + } + return { command: a }; }); await applyPreset(ws, preset, slotSpecs); onDone(ws); @@ -61,12 +75,22 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
- {Array.from({ length: slots }, (_, i) => ( - - ))} + {Array.from({ length: slots }, (_, i) => { + const val = agents[i] ?? "shell"; + return ( +
+ + {val === CUSTOM && ( + setCustomCmds((c) => { const n = [...c]; n[i] = e.target.value; return n; })} + style={{ padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #4C8DFF", borderRadius: 6, fontFamily: "monospace", fontSize: 12 }} /> + )} +
+ ); + })}
{error &&
{error}
}
diff --git a/app/src/socketBridge.ts b/app/src/socketBridge.ts index 06612ed..684e7c9 100644 --- a/app/src/socketBridge.ts +++ b/app/src/socketBridge.ts @@ -199,6 +199,16 @@ export async function openExternal(url: string): Promise { await invoke("open_external", { url }); } +export async function listFonts(): Promise { + return await invoke("list_fonts"); +} + +/** Which of the given CLI candidates are actually installed on the daemon's spawn PATH. */ +export async function whichAgents(candidates: string[]): Promise { + const data = await invoke<{ available: string[] }>("which_agents", { candidates }); + return data.available; +} + export async function setZoom(workspaceId: string, surfaceId: string | null): Promise { await invoke("set_zoom", { workspaceId, surfaceId }); } diff --git a/app/src/styles.css b/app/src/styles.css index 5851bf2..4efb113 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -1,3 +1,12 @@ +/* Nerd Font symbols (icons, powerline, devicons) used as a fallback in the + terminal so glyphs in the Private Use Area render instead of blank boxes. + Base monospace font is untouched; this only fills missing glyphs. */ +@font-face { + font-family: "Symbols Nerd Font Mono"; + src: url("./assets/SymbolsNerdFontMono-Regular.ttf") format("truetype"); + font-display: swap; +} + :root { color-scheme: dark; } diff --git a/crates/spacesh-core/src/grid.rs b/crates/spacesh-core/src/grid.rs index a4a514d..e9d6e40 100644 --- a/crates/spacesh-core/src/grid.rs +++ b/crates/spacesh-core/src/grid.rs @@ -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>>, +} + +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, + term: Term, 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 { + 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 { + pub fn term(&self) -> &Term { &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()); + } } diff --git a/crates/spacesh-proto/src/message.rs b/crates/spacesh-proto/src/message.rs index 6a8ad93..4ef4443 100644 --- a/crates/spacesh-proto/src/message.rs +++ b/crates/spacesh-proto/src/message.rs @@ -131,6 +131,8 @@ pub enum Cmd { surface_id: Option, }, Health, + /// Which of the given CLI candidates are actually installed on the spawn PATH. + WhichAgents { candidates: Vec }, Status, Shutdown, GetConfig, diff --git a/crates/spaceshd/src/config.rs b/crates/spaceshd/src/config.rs index 3db549b..b659f10 100644 --- a/crates/spaceshd/src/config.rs +++ b/crates/spaceshd/src/config.rs @@ -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 = OnceLock::new(); + CACHE + .get_or_init(|| { + let mut dirs: Vec = Vec::new(); + let mut seen: HashSet = 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 { + 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 { None } + /// The current user's login shell from the passwd database (`getpwuid`). #[cfg(unix)] fn login_shell() -> Option { diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index a1f47cc..192fd78 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -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 = 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; diff --git a/crates/spaceshd/src/surface.rs b/crates/spaceshd/src/surface.rs index e8aea9c..8ce5c15 100644 --- a/crates/spaceshd/src/surface.rs +++ b/crates/spaceshd/src/surface.rs @@ -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, @@ -248,9 +252,9 @@ fn flush( id: &SurfaceId, bcast: &broadcast::Sender>, state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, -) { +) -> Vec { 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)]