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
Generated
+5 -5
View File
@@ -869,7 +869,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-cli" name = "spacesh-cli"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -881,7 +881,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-core" name = "spacesh-core"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"serde", "serde",
@@ -890,7 +890,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-proto" name = "spacesh-proto"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"bytes", "bytes",
"serde", "serde",
@@ -902,7 +902,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-pty" name = "spacesh-pty"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -912,7 +912,7 @@ dependencies = [
[[package]] [[package]]
name = "spaceshd" name = "spaceshd"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
+1 -1
View File
@@ -10,7 +10,7 @@ members = [
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
version = "0.1.0" version = "0.1.2"
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
+2 -2
View File
@@ -43,8 +43,8 @@ targets: ## add rust targets for the universal build
rustup target add aarch64-apple-darwin x86_64-apple-darwin rustup target add aarch64-apple-darwin x86_64-apple-darwin
.PHONY: bump .PHONY: bump
bump: ## increment the patch version in tauri.conf.json (single source of truth) bump: ## increment the patch version for BOTH the GUI (tauri.conf.json) and the daemon (workspace Cargo.toml)
@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)" @node scripts/bump_version.mjs
.PHONY: dmg .PHONY: dmg
dmg: bump targets ## bump version + build the universal (Intel + Apple Silicon) .dmg — UNSIGNED dmg: bump targets ## bump version + build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
+14 -1
View File
@@ -540,6 +540,17 @@ dependencies = [
"libc", "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]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -3413,6 +3424,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
"core-foundation",
"core-text",
"dirs 5.0.1", "dirs 5.0.1",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
@@ -3427,7 +3440,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-proto" name = "spacesh-proto"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"bytes", "bytes",
"serde", "serde",
+2
View File
@@ -25,3 +25,5 @@ anyhow = "1"
dirs = "5" dirs = "5"
# rustls (no openssl) so the universal-apple-darwin cross-build stays self-contained. # rustls (no openssl) so the universal-apple-darwin cross-build stays self-contained.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
core-text = "22"
core-foundation = "0.10"
+24
View File
@@ -422,6 +422,11 @@ pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?) data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
} }
#[tauri::command]
pub async fn which_agents(state: BridgeState<'_>, candidates: Vec<String>) -> Result<Value, String> {
data_of(state.request(Cmd::WhichAgents { candidates }).await.map_err(|e| e.to_string())?)
}
// ---- Update check ---- // ---- Update check ----
/// Where the GUI looks for the published app version. Overridable via /// Where the GUI looks for the published app version. Overridable via
@@ -481,6 +486,25 @@ pub fn open_external(url: String) -> Result<(), String> {
Ok(()) 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<String> {
use std::collections::BTreeSet;
let names = core_text::font_collection::get_family_names();
let mut set: BTreeSet<String> = 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<String> = set.into_iter().collect();
v.sort_by_key(|s| s.to_lowercase());
v
}
// ---- Settings commands ---- // ---- Settings commands ----
#[tauri::command] #[tauri::command]
+2
View File
@@ -54,8 +54,10 @@ pub fn run() {
bridge::mark_read, bridge::mark_read,
bridge::clear_events, bridge::clear_events,
bridge::health, bridge::health,
bridge::which_agents,
bridge::check_update, bridge::check_update,
bridge::open_external, bridge::open_external,
bridge::list_fonts,
bridge::get_config, bridge::get_config,
bridge::set_config, bridge::set_config,
bridge::shutdown_daemon, bridge::shutdown_daemon,
+11 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "spacesh", "productName": "spacesh",
"version": "0.1.0", "version": "0.1.2",
"identifier": "xyz.spacesh.app", "identifier": "xyz.spacesh.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -10,8 +10,16 @@
"beforeBuildCommand": "npm run build" "beforeBuildCommand": "npm run build"
}, },
"app": { "app": {
"windows": [{ "title": "spacesh", "width": 1100, "height": 720 }], "windows": [
"security": { "csp": null } {
"title": "spacesh",
"width": 1100,
"height": 720
}
],
"security": {
"csp": null
}
}, },
"bundle": { "bundle": {
"active": true, "active": true,
+69 -8
View File
@@ -1,10 +1,74 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { X } from "lucide-react"; import { X, Search, Check } from "lucide-react";
import { COLORS, FONT, ACCENTS } from "./theme"; import { COLORS, FONT, ACCENTS } from "./theme";
import { setConfig, restartDaemon } from "./socketBridge"; import { setConfig, restartDaemon, listFonts } from "./socketBridge";
import type { ConfigView, DaemonHealth } 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<string[]>([]);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(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<string>();
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 (
<div ref={boxRef} style={{ position: "relative", marginBottom: 10 }}>
<div style={{ position: "relative" }}>
<Search size={14} style={{ position: "absolute", left: 9, top: "50%", transform: "translateY(-50%)", color: COLORS.textMuted }} />
<input
value={open ? query : value}
placeholder={value}
onFocus={() => { 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 }}
/>
</div>
{open && (
<div style={{ position: "absolute", top: "calc(100% + 4px)", left: 0, right: 0, zIndex: 10, maxHeight: 240, overflowY: "auto",
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8, boxShadow: "0 8px 24px rgba(0,0,0,0.4)" }}>
{options.length === 0 && <div style={{ padding: 10, fontSize: 12, color: COLORS.textMuted }}>Ничего не найдено</div>}
{options.map((f) => {
const isDefault = DEFAULT_FONTS.some((d) => d.toLowerCase() === f.toLowerCase());
return (
<button key={f} onClick={() => { onPick(f); setOpen(false); }}
style={{ display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left", padding: "7px 10px",
background: f === value ? COLORS.bgElevated : "transparent", border: "none", color: COLORS.textPrimary,
fontFamily: `'${f}', ${FONT.mono}`, fontSize: 13 }}>
<Check size={13} style={{ opacity: f === value ? 1 : 0, color: COLORS.accent, flex: "0 0 auto" }} />
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{f}</span>
{isDefault && <span style={{ fontFamily: FONT.ui, fontSize: 10, color: COLORS.textMuted }}>default</span>}
</button>
);
})}
</div>
)}
</div>
);
}
export function Settings({ config, health, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void; onReload: () => void }) { export function Settings({ config, health, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void; onReload: () => void }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -30,10 +94,7 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
</div> </div>
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Terminal font</div> <div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Terminal font</div>
<select value={config.font_family} onChange={(e) => void setConfig({ font_family: e.target.value })} <FontPicker value={config.font_family} onPick={(f) => void setConfig({ font_family: f })} />
style={{ width: "100%", padding: 8, marginBottom: 10, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }}>
{FONTS.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18 }}> <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18 }}>
<span style={{ fontSize: 12, color: COLORS.textSecondary }}>Size {sizeLocal}</span> <span style={{ fontSize: 12, color: COLORS.textSecondary }}>Size {sizeLocal}</span>
<input type="range" min={10} max={20} value={sizeLocal} <input type="range" min={10} max={20} value={sizeLocal}
+15 -2
View File
@@ -9,6 +9,13 @@ import { registerSearch, unregisterSearch } from "./searchRegistry";
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const encoder = new TextEncoder(); const encoder = new TextEncoder();
// Appended after the user font so Nerd Font icon glyphs (Private Use Area) render
// via fallback instead of blank boxes, without changing the base monospace font.
const NERD_FALLBACK = "'Symbols Nerd Font Mono'";
const fontStack = (family: string | null) =>
family ? `'${family}', ${NERD_FALLBACK}, monospace`
: `'JetBrains Mono Variable', 'JetBrains Mono', ${NERD_FALLBACK}, monospace`;
function xtermTheme(p: Record<string, string>) { function xtermTheme(p: Record<string, string>) {
return { return {
background: p["bg-panel"], background: p["bg-panel"],
@@ -30,7 +37,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
// call registerMarker/registerDecoration (proposed API). Without it findNext // call registerMarker/registerDecoration (proposed API). Without it findNext
// throws and the scrollback search counter never updates. // throws and the scrollback search counter never updates.
const term = new Terminal({ const term = new Terminal({
fontFamily: font ? `'${font.family}', monospace` : "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontFamily: fontStack(font?.family ?? null),
fontSize: font?.size ?? 13, fontSize: font?.size ?? 13,
convertEol: false, convertEol: false,
scrollback: 10000, scrollback: 10000,
@@ -81,6 +88,12 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
let disposed = false; 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. // Attach: fresh xterm instance, write snapshot, then stream live output.
void attachSurface(surfaceId, (bytes) => { void attachSurface(surfaceId, (bytes) => {
if (!disposed) term.write(decoder.decode(bytes)); if (!disposed) term.write(decoder.decode(bytes));
@@ -112,7 +125,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
const t = termRef.current; const t = termRef.current;
if (!t) return; if (!t) return;
if (font) { if (font) {
t.options.fontFamily = `'${font.family}', monospace`; t.options.fontFamily = fontStack(font.family);
t.options.fontSize = font.size; t.options.fontSize = font.size;
// The WebGL renderer caches rasterized glyphs in a texture atlas keyed by // The WebGL renderer caches rasterized glyphs in a texture atlas keyed by
// the old font/size; without clearing it the grid keeps rendering stale // the old font/size; without clearing it the grid keeps rendering stale
+30 -6
View File
@@ -1,16 +1,22 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { PresetPicker, PRESETS } from "./PresetPicker"; 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 }) { export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) {
const [path, setPath] = useState("."); const [path, setPath] = useState(".");
const [preset, setPreset] = useState("2x2"); const [preset, setPreset] = useState("2x2");
const [agents, setAgents] = useState<string[]>([]); const [agents, setAgents] = useState<string[]>([]);
const [customCmds, setCustomCmds] = useState<string[]>([]);
const [installed, setInstalled] = useState<string[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const pathRef = useRef<HTMLInputElement>(null); const pathRef = useRef<HTMLInputElement>(null);
const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1; 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 // 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). // (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(); pathRef.current?.select();
}, []); }, []);
// Only offer agents the user actually has installed.
useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []);
async function create() { async function create() {
if (busy) return; if (busy) return;
setBusy(true); setBusy(true);
@@ -27,7 +36,12 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
const ws = await openWorkspace(path); const ws = await openWorkspace(path);
const slotSpecs = Array.from({ length: slots }, (_, i) => { const slotSpecs = Array.from({ length: slots }, (_, i) => {
const a = agents[i] ?? "shell"; 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); await applyPreset(ws, preset, slotSpecs);
onDone(ws); onDone(ws);
@@ -61,12 +75,22 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
<div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div> <div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div>
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label> <label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}>
{Array.from({ length: slots }, (_, i) => ( {Array.from({ length: slots }, (_, i) => {
<select key={i} value={agents[i] ?? "shell"} onChange={(e) => setAgents((a) => { const n = [...a]; n[i] = e.target.value; return n; })} const val = agents[i] ?? "shell";
return (
<div key={i} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<select value={val} onChange={(e) => setAgents((a) => { const n = [...a]; n[i] = e.target.value; return n; })}
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}> style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
{agentChoices.map((c) => <option key={c} value={c}>{c}</option>)} {agentChoices.map((c) => <option key={c} value={c}>{c}</option>)}
</select> </select>
))} {val === CUSTOM && (
<input value={customCmds[i] ?? ""} placeholder="e.g. npm run dev"
onChange={(e) => 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 }} />
)}
</div>
);
})}
</div> </div>
{error && <div style={{ margin: "0 0 14px", padding: "8px 10px", background: "#3A1418", border: "1px solid #6B2230", borderRadius: 8, fontSize: 12, color: "#FF9AA6" }}>{error}</div>} {error && <div style={{ margin: "0 0 14px", padding: "8px 10px", background: "#3A1418", border: "1px solid #6B2230", borderRadius: 8, fontSize: 12, color: "#FF9AA6" }}>{error}</div>}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}> <div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
+10
View File
@@ -199,6 +199,16 @@ export async function openExternal(url: string): Promise<void> {
await invoke("open_external", { url }); await invoke("open_external", { url });
} }
export async function listFonts(): Promise<string[]> {
return await invoke<string[]>("list_fonts");
}
/** Which of the given CLI candidates are actually installed on the daemon's spawn PATH. */
export async function whichAgents(candidates: string[]): Promise<string[]> {
const data = await invoke<{ available: string[] }>("which_agents", { candidates });
return data.available;
}
export async function setZoom(workspaceId: string, surfaceId: string | null): Promise<void> { export async function setZoom(workspaceId: string, surfaceId: string | null): Promise<void> {
await invoke("set_zoom", { workspaceId, surfaceId }); await invoke("set_zoom", { workspaceId, surfaceId });
} }
+9
View File
@@ -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 { :root {
color-scheme: dark; color-scheme: dark;
} }
+49 -5
View File
@@ -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::grid::Dimensions;
use alacritty_terminal::index::{Column, Line, Point}; use alacritty_terminal::index::{Column, Line, Point};
use alacritty_terminal::term::{Config, Term}; 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. /// Owns an alacritty terminal model and feeds raw PTY bytes into it.
pub struct GridSurface { pub struct GridSurface {
term: Term<VoidListener>, term: Term<ReplyCollector>,
parser: Processor, parser: Processor,
size: GridSize, size: GridSize,
replies: ReplyCollector,
} }
impl GridSurface { impl GridSurface {
pub fn new(cols: u16, rows: u16) -> Self { pub fn new(cols: u16, rows: u16) -> Self {
let size = GridSize { cols: cols as usize, lines: rows as usize }; let size = GridSize { cols: cols as usize, lines: rows as usize };
let term = Term::new(Config::default(), &size, VoidListener); let replies = ReplyCollector::default();
Self { term, parser: Processor::new(), size } let term = Term::new(Config::default(), &size, replies.clone());
Self { term, parser: Processor::new(), size, replies }
} }
pub fn feed(&mut self, bytes: &[u8]) { pub fn feed(&mut self, bytes: &[u8]) {
self.parser.advance(&mut self.term, bytes); 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) { pub fn resize(&mut self, cols: u16, rows: u16) {
self.size = GridSize { cols: cols as usize, lines: rows as usize }; self.size = GridSize { cols: cols as usize, lines: rows as usize };
self.term.resize(self.size); self.term.resize(self.size);
@@ -56,7 +89,7 @@ impl GridSurface {
self.term.grid()[point].c self.term.grid()[point].c
} }
pub fn term(&self) -> &Term<VoidListener> { pub fn term(&self) -> &Term<ReplyCollector> {
&self.term &self.term
} }
@@ -97,4 +130,15 @@ mod tests {
assert_eq!(g.char_at(0, 0), 'a'); assert_eq!(g.char_at(0, 0), 'a');
assert_eq!(g.char_at(1, 0), 'c'); 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());
}
} }
+2
View File
@@ -131,6 +131,8 @@ pub enum Cmd {
surface_id: Option<SurfaceId>, surface_id: Option<SurfaceId>,
}, },
Health, Health,
/// Which of the given CLI candidates are actually installed on the spawn PATH.
WhichAgents { candidates: Vec<String> },
Status, Status,
Shutdown, Shutdown,
GetConfig, GetConfig,
+92
View File
@@ -110,6 +110,98 @@ pub fn default_shell() -> String {
"/bin/sh".into() "/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`). /// The current user's login shell from the passwd database (`getpwuid`).
#[cfg(unix)] #[cfg(unix)]
fn login_shell() -> Option<String> { 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) /// Compute spawn env (hooks for claude agents, zsh integration for zsh shells)
/// and whether a deterministic hook source is active. /// and whether a deterministic hook source is active.
fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (Vec<(String, String)>, bool) { 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 env = crate::hooks::prepare(sid, &crate::hooks::spacesh_bin());
let active = !env.is_empty(); let active = !env.is_empty();
(env, active) (env, active)
@@ -249,7 +249,13 @@ fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (
(crate::hooks::shell_env(sid), false) (crate::hooks::shell_env(sid), false)
} else { } else {
(vec![], false) (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. /// Emit a `layout_changed` event for a workspace's current tree.
@@ -628,6 +634,11 @@ async fn handle_request(
}))).await; }))).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 => { Cmd::Status => {
let (groups, workspaces) = reg.status(); let (groups, workspaces) = reg.status();
let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await; 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); flush_deadline = Some(Instant::now() + FLUSH_INTERVAL);
} }
if pending.len() >= FLUSH_BYTES { 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; flush_deadline = None;
} }
} }
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; break;
} }
} }
} }
_ = timer => { _ = 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; flush_deadline = None;
} }
} }
@@ -238,6 +240,8 @@ async fn run_actor(
/// Feed pending bytes into the grid, run detectors, broadcast output, and emit a /// Feed pending bytes into the grid, run detectors, broadcast output, and emit a
/// state change (if any). No-op when pending is empty. /// 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)] #[allow(clippy::too_many_arguments)]
fn flush( fn flush(
pending: &mut Vec<u8>, pending: &mut Vec<u8>,
@@ -248,9 +252,9 @@ fn flush(
id: &SurfaceId, id: &SurfaceId,
bcast: &broadcast::Sender<Vec<u8>>, bcast: &broadcast::Sender<Vec<u8>>,
state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
) { ) -> Vec<u8> {
if pending.is_empty() { if pending.is_empty() {
return; return Vec::new();
} }
// Deterministic source: OSC 133 markers in this chunk. // Deterministic source: OSC 133 markers in this chunk.
// Emit each distinct state transition immediately so no marker is dropped // Emit each distinct state transition immediately so no marker is dropped
@@ -265,6 +269,9 @@ fn flush(
} }
} }
grid.feed(&pending[..]); 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. // Best-effort fallback only when no deterministic source is active.
if !had_osc && !*deterministic { if !had_osc && !*deterministic {
if let Some(st) = FallbackScanner::scan(&grid.tail_text(6)) { if let Some(st) = FallbackScanner::scan(&grid.tail_text(6)) {
@@ -275,6 +282,7 @@ fn flush(
} }
} }
let _ = bcast.send(std::mem::take(pending)); let _ = bcast.send(std::mem::take(pending));
replies
} }
#[cfg(test)] #[cfg(test)]