Compare commits

...

19 Commits

Author SHA1 Message Date
vasyansk 0275c64ace Add NerdFont for symbols and version bump script
Add version bump script to synchronize GUI and daemon versions
2026-06-15 16:32:31 +07:00
vasyansk 0a67f401c4 Update version to 0.1.3
Add daemon version check and restart logic

Add pane count to CenterToolbar

Add minSlots filter to PresetPicker
2026-06-15 16:32:25 +07:00
vasyansk ce6a8d56be fix(daemon,app): graceful-shutdown final snapshot pass + StoppedSnapshot detach cleanup
Addresses final-review findings: Cmd::Shutdown now snapshots all live surfaces
synchronously before exit (spec graceful-shutdown requirement); StoppedSnapshot
calls detachSurface on unmount to release the bridge output channel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:24:53 +07:00
vasyansk 5c76493a34 feat(cli): spacesh restart --resume flag (plan gap: CLI is a first-class client)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:11:06 +07:00
vasyansk ff0ad7a648 feat(app): stopped panel paints last screen + Resume/Restart fresh controls
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:09:39 +07:00
vasyansk 375e4c5c92 feat(app): plumb resume flag through restart_surface bridge + binding
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:07:32 +07:00
vasyansk 31c08b5387 feat(daemon): RestartSurface honors resume — swap to resume_args when mapped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:05:53 +07:00
vasyansk eecea9c38c feat(proto): RestartSurface gains resume flag (defaults false) 2026-06-15 16:03:03 +07:00
vasyansk d00abcd2f6 chore: lock serde_json dev-dep for spacesh-core (Task 1 followup)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:01:58 +07:00
vasyansk 60383cd543 feat(daemon): snapshot ticker + writer wiring + stopped-attach reads disk + cleanup on close
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:00:39 +07:00
vasyansk 69f2e73832 feat(daemon): snapshot writer task (Save/Remove over one channel)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:49:29 +07:00
vasyansk 0674872c9d feat(daemon): actor Snapshot message + dirty tracking + final snapshot on exit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:47:40 +07:00
vasyansk 1a7d04aab0 feat(daemon): [resume] config map + snapshot_interval_secs with built-in defaults
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:38:30 +07:00
vasyansk bd36a83db2 feat(daemon): per-surface JSON snapshot store (atomic write, corrupt-tolerant)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:36:21 +07:00
vasyansk bb5edb941c feat(core): Snapshot derives Deserialize + PartialEq for disk persistence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:30:16 +07:00
vasyansk 4419f5660e wip: in-progress changes (grid, config, wizard, settings, pty) before session-persistence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:28:19 +07:00
vasyansk e37faf49d3 docs: sync session-persistence spec to leaner RestartSurface-based design
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:20:02 +07:00
vasyansk 1f69973606 docs: session persistence implementation plan + spec sync to leaner design
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:18:55 +07:00
vasyansk 3d54d679d3 docs: session persistence (resurrect + resume) design spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:05:21 +07:00
33 changed files with 2569 additions and 128 deletions
Generated
+6 -5
View File
@@ -869,7 +869,7 @@ dependencies = [
[[package]]
name = "spacesh-cli"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"anyhow",
"clap",
@@ -881,16 +881,17 @@ dependencies = [
[[package]]
name = "spacesh-core"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"alacritty_terminal",
"serde",
"serde_json",
"spacesh-proto",
]
[[package]]
name = "spacesh-proto"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"bytes",
"serde",
@@ -902,7 +903,7 @@ dependencies = [
[[package]]
name = "spacesh-pty"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"anyhow",
"bytes",
@@ -912,7 +913,7 @@ dependencies = [
[[package]]
name = "spaceshd"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -10,7 +10,7 @@ members = [
[workspace.package]
edition = "2021"
version = "0.1.0"
version = "0.1.3"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
# Session Persistence (resurrect + resume) — Design
**Date:** 2026-06-15
**Status:** Approved, ready for implementation plan
## Goal
Let a workspace survive both GUI loss and full power loss. Closing a tab or
the whole GUI already keeps agents running (daemon owns PTYs, reattach via live
grid snapshot — M1). This design adds the missing half: after the daemon itself
dies (reboot, battery death, `kill -9`), the user can bring panels back —
panels show their last on-screen state and offer a one-click **Resume** that
restarts the agent with its session-continue flag (e.g. `claude --continue`).
## Scope decisions (locked)
- **Reboot behavior:** resurrect + resume. A live process cannot survive a
power-off — not even tmux does that. After a daemon restart we respawn the
panel from its persisted spec (cwd intact) and, for agents that support it,
relaunch with a resume flag so the conversation continues in a *new* process.
- **Resurrect trigger:** manual, per-panel. After a daemon restart panels are
shown stopped with their last screen; nothing spawns until the user clicks.
This avoids surprise token burn from auto-launching many agents.
- **Persisted scrollback:** visible screen only. We reuse the existing
`snapshot_ansi()` serializer (the same one that powers live reattach) and
write its output to disk. No scrollback history beyond the visible grid.
- **Resume command source:** a `[resume]` table in `~/.spacesh/config.toml`
mapping a command basename to resume args, merged over built-in defaults.
- **Snapshot cadence:** periodic + shutdown. A background task dumps changed
grids every N seconds (default 5), plus a full pass on graceful shutdown and
a final dump when an actor exits. This survives `kill -9` / battery (you lose
at most N seconds of the last screen).
## What already exists (do not rebuild)
- `state.json` (via `JsonStateStore` + debounced `Persister`) already persists
structure: groups, workspaces, layout, zoom, pinned, and per-surface
`SurfaceSpec` (`command`, `args`, `cwd`, `cols`, `rows`, `agent_label`,
`autostart`). On cold start `Registry::restore()` loads this; the `live`
actor map is empty, so every surface is "stopped" (spec present, no process).
- `SurfaceView.running: bool` already tells the client a surface is stopped.
- `spacesh_core::snapshot::snapshot_ansi(&GridSurface) -> Snapshot` serializes
the visible grid to an ANSI dump (`ansi`, `cols`, `rows`, `cursor_row`,
`cursor_col`). `Snapshot` currently derives `Serialize` only.
- The surface actor already answers `SurfaceMsg::AttachSnapshot` by calling
`snapshot_ansi(&grid)`; the grid is the authoritative screen model.
## Components
### 1. Snapshot store — `crates/spaceshd/src/snapshot_store.rs` (new)
Per-surface JSON file `~/.spacesh/snapshots/<surface_id>.json` holding the
serialized visible-screen snapshot. Atomic write (temp file → `sync_all`
rename), mirroring `state_store::JsonStateStore`.
```rust
pub trait SnapshotStore: Send + Sync {
fn save(&self, sid: &SurfaceId, snap: &Snapshot) -> anyhow::Result<()>;
fn load(&self, sid: &SurfaceId) -> Option<Snapshot>;
fn remove(&self, sid: &SurfaceId);
}
```
The store persists the core `spacesh_core::snapshot::Snapshot` directly
(`ansi`, `cols`, `rows`, `cursor_row`, `cursor_col`) — `spaceshd` already
depends on `spacesh-core`, so no separate daemon record type is introduced. A
corrupt/missing file yields `None` (never an error that blocks resurrect).
`remove` deletes the file and is called when a surface is closed or removed
from the tree.
### 2. On-demand snapshot from the actor — `crates/spaceshd/src/surface.rs`
Add a message that returns the current snapshot without subscribing:
```rust
SurfaceMsg::Snapshot { reply: oneshot::Sender<(Snapshot, bool)> } // (snapshot, dirty)
```
The actor tracks a `dirty` flag: set inside `flush` whenever bytes are fed into
the grid (`grid.feed`), cleared when a `Snapshot` reply is produced. The bool
lets the periodic dumper skip unchanged grids.
On actor exit (after `pty.wait()`), the actor takes a final `snapshot_ansi`
and forwards `(id, snapshot)` to the writer channel (a cloned
`mpsc::UnboundedSender<(SurfaceId, Snapshot)>` passed into the actor), so the
last screen of a finished process is persisted even between ticker ticks.
### 3. Writer task + periodic ticker — `crates/spaceshd/src/server.rs` / `main.rs`
- **Writer task:** the sole owner of `Arc<dyn SnapshotStore>`. Receives
`(SurfaceId, Snapshot)` on an unbounded channel and writes to disk. Keeps all
snapshot disk I/O off the actor/PTY hot path and serializes writes.
- **Periodic ticker:** every `snapshot_interval_secs` (config, default 5) the
router iterates live surface handles, sends `SurfaceMsg::Snapshot`, awaits the
reply, and forwards to the writer channel only when `dirty` is true.
- **Graceful shutdown:** before the daemon exits it does one final synchronous
pass over all live surfaces into the writer, then flushes the writer.
### 4. Resume config — `crates/spaceshd/src/config.rs`
```toml
[resume]
commands = { claude = ["--continue"], codex = ["resume"] }
```
```rust
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ResumeConfig {
#[serde(default)]
pub commands: std::collections::HashMap<String, Vec<String>>,
}
```
Added to `Config` as `#[serde(default)] pub resume: ResumeConfig`. A method
`resume_args(command: &str) -> Option<Vec<String>>` resolves by command
basename: user map first, then a built-in default table
(`claude → ["--continue"]`, `codex → ["resume"]`), then `None`. The default
table is a `const`/static, not inline literals in branching logic.
### 5. Protocol — `crates/spacesh-proto/src/message.rs`
The codebase already has `Cmd::RestartSurface { surface_id }` (starts a stopped
surface from its spec, guarded by `is_running`) and an `Attach` response that
already carries `{ snapshot, cols, rows, cursor_row, cursor_col, stopped }`.
So no new command or wire type is needed beyond one field:
- Extend `Cmd::RestartSurface` with `#[serde(default)] resume: bool`. `resume =
true` builds `command + resume_args(command)` (falling back to the original
args when no mapping exists); `resume = false` keeps the original
`command + args` (today's behavior). The `#[serde(default)]` keeps old frames
decoding to `resume = false`.
- No `GetSnapshot`, no `StartSurface`, no `SnapshotView`: a stopped-panel
`Attach` returns the **disk** snapshot (see §6) using the existing response
shape.
`spacesh-core::snapshot::Snapshot` gains `Deserialize` (alongside `Serialize`)
so the store can load it back from disk.
### 6. Server handlers — `crates/spaceshd/src/server.rs`
- `RestartSurface { surface_id, resume }`: unchanged flow (spec lookup,
`spawn_from_spec`, `set_live`, `SurfaceRestarted` broadcast). When `resume`,
spawn with a spec whose `args` are replaced by `config.resume_args(command)`
(when present); otherwise spawn the original spec.
- `Attach` for a **stopped** surface: instead of returning the empty
`{ snapshot: "", stopped: true }`, load the disk snapshot via the snapshot
store and return `{ snapshot: <ansi>, cols, rows, cursor_row, cursor_col,
stopped: true }`. Missing file → empty snapshot, still `stopped: true`.
- Surface close/remove (`Close`, `CloseWorkspace`, `remove_surface` paths):
send a remove to the snapshot writer so stale `<sid>.json` files do not
accumulate.
### 7. App — `app/src` and `app/src-tauri`
- `socketBridge.ts`: `restartSurface(id, resume = false)` gains the `resume`
arg; `AttachResult` gains optional `cursor_row`/`cursor_col`/`stopped`.
- `app/src-tauri/src/bridge.rs`: `restart_surface` forwards a `resume: bool`
arg into `Cmd::RestartSurface`.
- `LayoutEngine.tsx` stopped branch (`running[id] === false`): paint the disk
snapshot into a dimmed, read-only `xterm` behind the controls, and offer two
buttons — **Resume** → `restartSurface(id, true)` and **Restart fresh** →
`restartSurface(id, false)`. On success the daemon's `workspace_changed`
flips `running` to true, the overlay unmounts, and the live `TerminalView`
mounts.
## Data flow
```
running surface ──(every 5s, if dirty)──▶ ticker ──▶ writer task ──▶ <sid>.json
running surface ──(on exit)─────────────────────────▶ writer task ──▶ <sid>.json
daemon shutdown ──(final pass over live)────────────▶ writer task ──▶ <sid>.json
reboot ▶ daemon cold start ▶ Registry::restore(state.json) ▶ all surfaces stopped
client ▶ Attach(sid) [stopped] ▶ disk snapshot ▶ paint dimmed read-only screen
user clicks Resume ▶ RestartSurface{resume:true} ▶ spawn(command + resume_args, cwd)
▶ SurfaceRestarted + running=true ▶ live TerminalView mounts
```
## Error handling
- Missing/corrupt snapshot file → stopped `Attach` returns an empty snapshot;
the overlay shows an empty dimmed panel with the Resume/Restart controls.
- `RestartSurface` on an already-running surface → no-op ok (existing
`is_running` guard); unknown surface → `NOT_FOUND`.
- Resume command for an agent without a mapping → falls back to the original
spec args (plain restart), never fails the spawn.
- Writer task failure to write one file is logged and dropped; it must not stall
the daemon or other surfaces.
## Performance
- A visible-screen snapshot is ≈ rows × cols bytes of ANSI; at a 5s cadence with
the `dirty` debounce, idle panels write nothing. All disk writes happen in the
single writer task, off the PTY/actor hot path, so the keypress→echo (<16 ms)
and output-batching budgets are untouched.
## Testing
- **snapshot_store:** save→load round-trip; atomic write; missing file → `None`;
corrupt file → `None`; `remove` deletes the file.
- **config:** parse `[resume]` table; `resume_args` returns user override, then
built-in default, then `None`; missing section defaults cleanly.
- **surface actor:** `SurfaceMsg::Snapshot` returns the current grid contents;
`dirty` is true after output and false immediately after a snapshot.
- **server:** `RestartSurface{resume:true}` spawns with `command + resume_args`;
`{resume:false}` spawns with `command + args`; stopped `Attach` returns the
saved disk snapshot; `is_running` guard prevents a second actor.
- **registry:** starting a stopped surface re-populates the live map and the
view flips `running` to true.
## Out of scope
- Resuming the literal in-flight process across power loss (impossible).
- Scrollback history beyond the visible screen.
- Auto-resume on daemon start (manual trigger chosen).
- Per-surface resume command stored in the spec/wizard (config map chosen).
+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
.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
+14 -1
View File
@@ -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.3"
dependencies = [
"bytes",
"serde",
+2
View File
@@ -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"
+76 -4
View File
@@ -78,6 +78,13 @@ fn find_daemon() -> PathBuf {
PathBuf::from("spaceshd") // last resort: rely on PATH
}
/// The installed `spaceshd` binary's mtime as ms since the epoch (for staleness check).
fn daemon_bin_mtime_ms() -> Option<u64> {
let meta = std::fs::metadata(find_daemon()).ok()?;
let mtime = meta.modified().ok()?;
Some(mtime.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as u64)
}
async fn ensure_daemon(sock: &PathBuf) -> Result<UnixStream> {
if let Ok(s) = UnixStream::connect(sock).await {
return Ok(s);
@@ -120,7 +127,7 @@ impl Bridge {
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
let out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>> = Arc::default();
let (tx, reader) = spawn_connection(&sock, &app, pending.clone(), out_channels.clone()).await?;
Ok(Self {
let bridge = Self {
next_id: AtomicU64::new(1),
app,
sock,
@@ -130,7 +137,48 @@ impl Bridge {
reader: Mutex::new(reader),
pending,
out_channels,
})
};
// The daemon outlives the GUI by design, so after an update the GUI may
// attach to a stale daemon — new features that need new daemon code then
// silently don't work. Restart it if it's out of date.
bridge.ensure_matching_daemon().await;
Ok(bridge)
}
/// Restart the running daemon if it predates the installed `spaceshd` binary
/// (or was built from a different commit). The bundled binary's mtime vs the
/// daemon's `started_at_ms` is the reliable signal: it catches every reinstall
/// even while developing dirty, where the git build id doesn't change.
async fn ensure_matching_daemon(&self) {
let Ok(reply) = self.request(Cmd::Health).await else { return };
let (daemon_build, started_at_ms) = match &reply {
Envelope::Res { data, .. } => (
data.get("build").and_then(|v| v.as_str()).map(str::to_string),
data.get("started_at_ms").and_then(|v| v.as_u64()),
),
_ => (None, None),
};
let gui_build = option_env!("SPACESH_BUILD").unwrap_or("dev");
let build_mismatch = gui_build != "dev"
&& daemon_build.as_deref().map(|b| b != gui_build).unwrap_or(false);
let binary_newer = match (daemon_bin_mtime_ms(), started_at_ms) {
(Some(bin_ms), Some(start_ms)) => bin_ms > start_ms,
_ => false,
};
if !build_mismatch && !binary_newer {
return;
}
// Ask the stale daemon to exit, wait for its socket to clear, then reconnect
// — which lazily spawns the fresh bundled daemon.
self.fire(Cmd::Shutdown).await;
for _ in 0..100 {
if UnixStream::connect(&self.sock).await.is_err() {
break;
}
tokio::time::sleep(Duration::from_millis(30)).await;
}
let seen = self.gen.load(Ordering::Acquire);
let _ = self.reconnect(seen).await;
}
/// Send a command without awaiting a reply or retrying. Used for Shutdown:
@@ -348,8 +396,8 @@ pub async fn apply_preset(state: BridgeState<'_>, workspace_id: String, preset_i
}
#[tauri::command]
pub async fn restart_surface(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
data_of(state.request(Cmd::RestartSurface { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
pub async fn restart_surface(state: BridgeState<'_>, surface_id: String, resume: bool) -> Result<Value, String> {
data_of(state.request(Cmd::RestartSurface { surface_id: SurfaceId(surface_id), resume }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
@@ -422,6 +470,11 @@ pub async fn health(state: BridgeState<'_>) -> Result<Value, 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 ----
/// Where the GUI looks for the published app version. Overridable via
@@ -481,6 +534,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<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 ----
#[tauri::command]
+2
View File
@@ -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,
+11 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "spacesh",
"version": "0.1.0",
"version": "0.1.3",
"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,
+1 -1
View File
@@ -183,7 +183,7 @@ export function App() {
<Sidebar railMode={!sidebarOpen} groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{active && (
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
<CenterToolbar selected="" paneCount={leaves.length} onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
)}
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
{active
+2 -2
View File
@@ -3,10 +3,10 @@ import { COLORS, FONT } from "./theme";
import { PresetPicker } from "./PresetPicker";
/** Top-of-grid toolbar: layout presets on the left, scrollback search on the right (search is a mock). */
export function CenterToolbar({ selected, onSelect, onOpenSearch }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void }) {
export function CenterToolbar({ selected, onSelect, onOpenSearch, paneCount }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void; paneCount: number }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
<PresetPicker selected={selected} onSelect={onSelect} />
<PresetPicker selected={selected} onSelect={onSelect} minSlots={paneCount} />
<div style={{ flex: 1 }} />
<div
title="Search scrollback"
+51 -6
View File
@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from "react";
import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react";
import { Maximize2, Minimize2, RotateCw, GripVertical, Play } from "lucide-react";
import { Terminal } from "@xterm/xterm";
import { TerminalView } from "./TerminalView";
import { SearchBar } from "./SearchBar";
import { StatusRing } from "./StatusRing";
import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
import { setRatios, restartSurface, setZoom, moveSurface } from "./socketBridge";
import { setRatios, restartSurface, setZoom, moveSurface, attachSurface, detachSurface } from "./socketBridge";
interface Props {
workspaceId: string;
@@ -116,6 +117,43 @@ function Node({ node, path, ...rest }: NodeProps) {
return <SplitView split={node.split} path={path} {...rest} />;
}
const NERD_FALLBACK_LE = "'Symbols Nerd Font Mono'";
const fontStackLE = (family: string | null) =>
family ? `'${family}', ${NERD_FALLBACK_LE}, monospace`
: `'JetBrains Mono Variable', 'JetBrains Mono', ${NERD_FALLBACK_LE}, monospace`;
function xtermThemeLE(p: Record<string, string>) {
return {
background: p["bg-panel"],
foreground: p["text-primary"],
cursor: p["text-primary"],
selectionBackground: p["search-match"],
};
}
function StoppedSnapshot({ surfaceId, font, palette }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null }) {
const hostRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const term = new Terminal({
fontFamily: fontStackLE(font?.family ?? null),
fontSize: font?.size ?? 13,
theme: palette ? xtermThemeLE(palette) : undefined,
cursorBlink: false,
disableStdin: true,
scrollback: 0,
});
term.open(host);
let disposed = false;
void attachSurface(surfaceId, () => {}).then((res) => {
if (!disposed && res.snapshot) term.write(res.snapshot);
});
return () => { disposed = true; term.dispose(); void detachSurface(surfaceId); };
}, [surfaceId, font, palette]); // eslint-disable-line react-hooks/exhaustive-deps
return <div ref={hostRef} style={{ position: "absolute", inset: 0, opacity: 0.45, pointerEvents: "none" }} />;
}
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Omit<NodeProps, "node" | "path"> & { id: string }) {
const focused = focusedId === id;
const dropEdge = drop && drop.id === id ? drop.edge : null;
@@ -142,12 +180,18 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
if (running[id] === false) {
return card(
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", color: COLORS.textSecondary, flexDirection: "column", gap: 10 }}>
<div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Process exited</div>
<div style={{ position: "relative", height: "100%", width: "100%" }}>
<StoppedSnapshot surfaceId={id} font={font} palette={palette} />
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 10, color: COLORS.textSecondary, background: "rgba(0,0,0,0.35)" }}>
<div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Stopped</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => void restartSurface(id)}
<button onClick={() => void restartSurface(id, true)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.accent, color: COLORS.bgApp, border: "none", borderRadius: 7, fontSize: 12, fontWeight: 600 }}>
<Play size={13} /> Resume
</button>
<button onClick={() => void restartSurface(id, false)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
<RotateCw size={13} /> Restart
<RotateCw size={13} /> Restart fresh
</button>
{zoomed === id && (
<button onClick={() => void setZoom(workspaceId, null)}
@@ -157,6 +201,7 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
)}
</div>
</div>
</div>
);
}
+4 -2
View File
@@ -13,10 +13,12 @@ export const PRESETS: { id: string; label: string; slots: number }[] = [
import { COLORS, FONT } from "./theme";
export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) {
// `minSlots` hides presets smaller than the current pane count — applying a preset
// only ever ADDS panes (never destroys running ones); shrink by closing panels.
export function PresetPicker({ selected, onSelect, minSlots = 0 }: { selected: string; onSelect: (id: string) => void; minSlots?: number }) {
return (
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{PRESETS.map((p) => {
{PRESETS.filter((p) => p.slots >= minSlots).map((p) => {
const on = p.id === selected;
return (
<button key={p.id} onClick={() => onSelect(p.id)}
+69 -8
View File
@@ -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<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 }) {
const ref = useRef<HTMLDivElement>(null);
@@ -30,10 +94,7 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
</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 })}
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>
<FontPicker value={config.font_family} onPick={(f) => void setConfig({ font_family: f })} />
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18 }}>
<span style={{ fontSize: 12, color: COLORS.textSecondary }}>Size {sizeLocal}</span>
<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 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>) {
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
+30 -6
View File
@@ -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<string[]>([]);
const [customCmds, setCustomCmds] = useState<string[]>([]);
const [installed, setInstalled] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const pathRef = useRef<HTMLInputElement>(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) =>
<div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div>
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}>
{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; })}
{Array.from({ length: slots }, (_, i) => {
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 }}>
{agentChoices.map((c) => <option key={c} value={c}>{c}</option>)}
</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>
{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 }}>
Binary file not shown.
+15 -2
View File
@@ -42,6 +42,9 @@ export interface AttachResult {
snapshot: string;
cols: number;
rows: number;
cursor_row?: number;
cursor_col?: number;
stopped?: boolean;
}
export async function attachSurface(
@@ -147,8 +150,8 @@ export async function applyPreset(workspaceId: string, presetId: string, slots:
return data.surface_ids;
}
export async function restartSurface(surfaceId: string): Promise<void> {
await invoke("restart_surface", { surfaceId });
export async function restartSurface(surfaceId: string, resume = false): Promise<void> {
await invoke("restart_surface", { surfaceId, resume });
}
export async function closeWorkspaceCmd(workspaceId: string): Promise<void> {
@@ -199,6 +202,16 @@ export async function openExternal(url: string): Promise<void> {
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> {
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 {
color-scheme: dark;
}
+5 -1
View File
@@ -38,7 +38,11 @@ pub enum Sub {
},
Close { surface_id: String },
Focus { surface_id: String },
Restart { surface_id: String },
Restart {
surface_id: String,
/// Relaunch the agent with its session-continue flag (e.g. claude --continue).
#[arg(long)] resume: bool,
},
Notify {
#[arg(long)] surface: String,
#[arg(long, value_enum)] state: StateArg,
+1 -1
View File
@@ -29,7 +29,7 @@ pub fn to_cmd(sub: Sub) -> Cmd {
},
Sub::Close { surface_id } => Cmd::Close { surface_id: SurfaceId(surface_id) },
Sub::Focus { surface_id } => Cmd::Focus { surface_id: SurfaceId(surface_id) },
Sub::Restart { surface_id } => Cmd::RestartSurface { surface_id: SurfaceId(surface_id) },
Sub::Restart { surface_id, resume } => Cmd::RestartSurface { surface_id: SurfaceId(surface_id), resume },
Sub::Notify { surface, state } => Cmd::SetState { surface_id: SurfaceId(surface), state: state_of(state) },
Sub::ApplyPreset { workspace_id, preset, agents } => Cmd::ApplyPreset {
workspace_id: WorkspaceId(workspace_id),
+3
View File
@@ -7,3 +7,6 @@ version.workspace = true
alacritty_terminal.workspace = true
serde.workspace = true
spacesh-proto = { path = "../spacesh-proto" }
[dev-dependencies]
serde_json.workspace = true
+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::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());
}
}
+14 -2
View File
@@ -1,11 +1,11 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
use alacritty_terminal::index::Point;
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::vte::ansi::Color;
use crate::grid::GridSurface;
/// Serializable snapshot returned by `attach`.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Snapshot {
/// ANSI byte dump suitable for `xterm.write()`.
pub ansi: String,
@@ -120,6 +120,18 @@ mod tests {
assert_eq!(a.rows, 3);
}
#[test]
fn snapshot_round_trips_through_json() {
let mut g = GridSurface::new(20, 4);
g.feed(b"hello");
let snap = snapshot_ansi(&g);
let json = serde_json::to_string(&snap).unwrap();
let back: Snapshot = serde_json::from_str(&json).unwrap();
assert_eq!(back.ansi, snap.ansi);
assert_eq!((back.cols, back.rows), (snap.cols, snap.rows));
assert_eq!((back.cursor_row, back.cursor_col), (snap.cursor_row, snap.cursor_col));
}
#[test]
fn cursor_is_one_based_after_input() {
let mut g = GridSurface::new(10, 3);
+22 -1
View File
@@ -92,7 +92,11 @@ pub enum Cmd {
SetRatios { workspace_id: WorkspaceId, node_path: Vec<u32>, ratios: Vec<f32> },
MoveSurface { surface_id: SurfaceId, target_surface_id: SurfaceId, edge: Edge },
ApplyPreset { workspace_id: WorkspaceId, preset_id: String, slots: Vec<PresetSlot> },
RestartSurface { surface_id: SurfaceId },
RestartSurface {
surface_id: SurfaceId,
#[serde(default)]
resume: bool,
},
CloseWorkspace { workspace_id: WorkspaceId },
SetWorkspaceMeta {
workspace_id: WorkspaceId,
@@ -131,6 +135,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,
@@ -366,6 +372,21 @@ mod tests {
assert_eq!(back, env);
}
#[test]
fn restart_surface_resume_defaults_false_and_round_trips() {
// Legacy frame without `resume` decodes to false.
let legacy = r#"{"kind":"req","id":5,"cmd":{"cmd":"restart_surface","args":{"surface_id":"s_1"}}}"#;
let env: Envelope = serde_json::from_str(legacy).unwrap();
match env {
Envelope::Req { cmd: Cmd::RestartSurface { resume, .. }, .. } => assert!(!resume),
_ => panic!("wrong variant"),
}
// resume=true round-trips.
let e = Envelope::Req { id: 6, cmd: Cmd::RestartSurface { surface_id: SurfaceId("s_1".into()), resume: true } };
let back: Envelope = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap();
assert_eq!(back, e);
}
#[test]
fn event_log_cmd_no_limit_round_trips() {
let env = Envelope::Req { id: 9, cmd: Cmd::EventLog { limit: None } };
+164
View File
@@ -22,6 +22,20 @@ pub struct AppearanceConfig {
pub accent: Option<String>,
}
/// Built-in resume args for known agents, used when config has no override.
/// (command basename, resume args)
const DEFAULT_RESUME: &[(&str, &[&str])] = &[
("claude", &["--continue"]),
("codex", &["resume"]),
];
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ResumeConfig {
/// command basename -> args that continue its previous session.
#[serde(default)]
pub commands: std::collections::HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Config {
/// Shell launched for plain (no-command) panels. When unset, the daemon
@@ -32,6 +46,11 @@ pub struct Config {
pub terminal: TerminalConfig,
#[serde(default)]
pub appearance: AppearanceConfig,
#[serde(default)]
pub resume: ResumeConfig,
/// How often (seconds) the daemon dumps changed grids to disk.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_interval_secs: Option<u64>,
}
impl Config {
@@ -85,6 +104,25 @@ impl Config {
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
std::fs::write(path, s)
}
/// Resume args for a command, by basename: user map → built-in default → None.
pub fn resume_args(&self, command: &str) -> Option<Vec<String>> {
let base = std::path::Path::new(command)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| command.to_string());
if let Some(args) = self.resume.commands.get(&base) {
return Some(args.clone());
}
DEFAULT_RESUME.iter()
.find(|(name, _)| *name == base)
.map(|(_, args)| args.iter().map(|s| s.to_string()).collect())
}
/// Snapshot dump cadence in seconds (config → default 5, clamped to [1, 3600]).
pub fn snapshot_interval_secs(&self) -> u64 {
self.snapshot_interval_secs.unwrap_or(5).clamp(1, 3600)
}
}
/// Resolve the shell to spawn for a plain panel.
@@ -110,6 +148,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> {
@@ -206,4 +336,38 @@ mod tests {
assert_eq!(back.appearance.accent.as_deref(), Some("purple"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn resume_args_user_then_default_then_none() {
let mut c = Config::default();
// built-in defaults present without any config
assert_eq!(c.resume_args("claude").as_deref(), Some(&["--continue".to_string()][..]));
assert_eq!(c.resume_args("codex").as_deref(), Some(&["resume".to_string()][..]));
// a path is reduced to its basename before lookup
assert_eq!(c.resume_args("/usr/local/bin/claude").as_deref(), Some(&["--continue".to_string()][..]));
// unknown command → None
assert_eq!(c.resume_args("bash"), None);
// user override wins over the default
c.resume.commands.insert("claude".into(), vec!["--resume".into(), "last".into()]);
assert_eq!(c.resume_args("claude"), Some(vec!["--resume".into(), "last".into()]));
}
#[test]
fn snapshot_interval_defaults_to_5s() {
let c = Config::default();
assert_eq!(c.snapshot_interval_secs(), 5);
}
#[test]
fn parses_resume_table_and_interval() {
let dir = std::env::temp_dir().join(format!("spacesh-cfg-resume-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path,
"snapshot_interval_secs = 10\n[resume.commands]\ngemini = [\"--resume\"]\n").unwrap();
let c = Config::from_path(&path);
assert_eq!(c.snapshot_interval_secs(), 10);
assert_eq!(c.resume_args("gemini"), Some(vec!["--resume".into()]));
let _ = std::fs::remove_file(&path);
}
}
+5 -1
View File
@@ -7,6 +7,7 @@ mod lifecycle;
mod persist;
mod registry;
mod server;
mod snapshot_store;
mod state_store;
mod surface;
@@ -60,6 +61,9 @@ async fn run_daemon() -> Result<()> {
let events_path = lifecycle::spacesh_dir()?.join("events.json");
let event_store: std::sync::Arc<dyn event_store::EventStore> =
std::sync::Arc::new(event_store::JsonEventStore::new(events_path));
let snapshots_dir = lifecycle::spacesh_dir()?.join("snapshots");
let snapshot_store: std::sync::Arc<dyn snapshot_store::SnapshotStore> =
std::sync::Arc::new(snapshot_store::JsonSnapshotStore::new(snapshots_dir));
eprintln!("spaceshd listening on {}", sock.display());
server::serve(&sock, store, event_store).await
server::serve(&sock, store, event_store, snapshot_store).await
}
+4
View File
@@ -114,6 +114,10 @@ impl Registry {
pub fn is_running(&self, sid: &SurfaceId) -> bool {
self.live.contains_key(sid)
}
/// Ids of all currently-live surfaces.
pub fn live_ids(&self) -> Vec<SurfaceId> {
self.live.keys().cloned().collect()
}
// ---- surface state ----
+204 -45
View File
@@ -13,6 +13,7 @@ use crate::event_log::EventLog;
use crate::event_store::{self, EventPersister, EventStore};
use crate::persist::{self, Persister};
use crate::registry::Registry;
use crate::snapshot_store::{SnapshotStore, SnapshotMsg, spawn_writer};
use crate::state_store::StateStore;
use crate::surface::{SurfaceMsg};
@@ -33,11 +34,13 @@ enum ServerMsg {
ClientDisconnected { client: ClientId },
/// A status change detected internally (OSC 133 / fallback) by a surface actor.
StateDetected { surface_id: SurfaceId, state: SurfaceState },
/// Periodic snapshot tick: ask all live surfaces for a snapshot and persist dirty ones.
SnapshotTick,
}
type ClientId = u64;
pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<dyn EventStore>) -> Result<()> {
pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<dyn EventStore>, snapshot_store: Arc<dyn SnapshotStore>) -> Result<()> {
let listener = UnixListener::bind(socket)?;
let (router_tx, router_rx) = mpsc::channel::<ServerMsg>(256);
@@ -58,6 +61,20 @@ pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<d
}
});
let snapshot_tx = spawn_writer(snapshot_store.clone());
// Periodic snapshot tick → router.
let tick_router = router_tx.clone();
let interval_secs = crate::config::Config::load().snapshot_interval_secs();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(interval_secs));
tick.tick().await; // consume the immediate first tick
loop {
tick.tick().await;
if tick_router.send(ServerMsg::SnapshotTick).await.is_err() { break; }
}
});
let persister = persist::spawn(store.clone(), Duration::from_millis(500));
let initial = store.load().unwrap_or_default();
let event_persister = event_store::spawn(event_store.clone(), Duration::from_millis(500));
@@ -66,7 +83,7 @@ pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<d
let shutdown = tokio::spawn(router(
router_rx, router_tx.clone(), exit_tx, state_tx,
persister, initial, event_persister, event_initial,
started_at_ms,
started_at_ms, snapshot_store, snapshot_tx,
));
let mut next_client: ClientId = 0;
@@ -128,6 +145,8 @@ async fn router(
event_persister: EventPersister,
event_initial: crate::event_log::EventLogState,
started_at_ms: u64,
snapshot_store: Arc<dyn SnapshotStore>,
snapshot_tx: mpsc::UnboundedSender<SnapshotMsg>,
) {
let mut reg = Registry::new();
reg.restore(initial);
@@ -175,10 +194,24 @@ async fn router(
}
}
}
ServerMsg::SnapshotTick => {
let ids = reg.live_ids();
for sid in ids {
let Some(handle) = reg.live(&sid) else { continue };
let (reply_tx, reply_rx) = oneshot::channel();
if handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.is_err() { continue; }
if let Ok((snap, dirty)) = reply_rx.await {
if dirty {
let _ = snapshot_tx.send(SnapshotMsg::Save(sid.clone(), snap));
}
}
}
}
ServerMsg::Request { id, cmd, client, out } => {
handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients,
&router_tx, &exit_tx, &state_tx, &persister,
&mut event_log, &event_persister, started_at_ms, &mut config).await;
&mut event_log, &event_persister, started_at_ms, &mut config,
&snapshot_store, &snapshot_tx).await;
}
}
}
@@ -241,7 +274,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 +282,30 @@ 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)
}
/// Build the spawn spec for a (re)start. When `resume` and the command has a
/// resume mapping, its args are replaced with the resume args; otherwise the
/// original spec args are kept.
fn resume_spec(
spec: &spacesh_proto::workspace::SurfaceSpec,
resume: bool,
cfg: &crate::config::Config,
) -> spacesh_proto::workspace::SurfaceSpec {
let mut out = spec.clone();
if resume {
if let Some(args) = cfg.resume_args(&spec.command) {
out.args = args;
}
}
out
}
/// Emit a `layout_changed` event for a workspace's current tree.
@@ -278,6 +334,8 @@ async fn handle_request(
event_persister: &EventPersister,
started_at_ms: u64,
config: &mut crate::config::Config,
snapshot_store: &Arc<dyn SnapshotStore>,
snapshot_tx: &mpsc::UnboundedSender<SnapshotMsg>,
) {
use spacesh_proto::message::SplitDir;
use spacesh_proto::layout::{LayoutNode, Orient};
@@ -306,7 +364,7 @@ async fn handle_request(
agent_label: command, cols, rows, autostart: false,
};
let (env, hooks_active) = spawn_env(&sid, &spec);
match crate::surface::spawn_from_spec(sid.clone(), workspace_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
match crate::surface::spawn_from_spec(sid.clone(), workspace_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone(), snapshot_tx.clone()) {
Ok(handle) => {
spawn_output_bridge(sid.clone(), &handle, router_tx.clone());
reg.set_live(handle);
@@ -338,7 +396,7 @@ async fn handle_request(
let shell = command.clone().unwrap_or_else(|| config.resolved_shell());
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
let (env, hooks_active) = spawn_env(&new_sid, &spec);
match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone(), snapshot_tx.clone()) {
Ok(handle) => {
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
reg.set_live(handle);
@@ -395,55 +453,56 @@ async fn handle_request(
let Some(ws) = reg.workspace(&workspace_id).cloned() else {
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
};
// Kill current panels of this workspace.
let existing: Vec<SurfaceId> = ws.surfaces.keys().cloned().collect();
for sid in &existing {
if let Some(h) = reg.live(sid) { let _ = h.tx.send(crate::surface::SurfaceMsg::Close).await; }
reg.remove_surface(sid);
subs.remove(sid);
}
// Spawn `count` panels (slots padded/truncated to count).
let mut new_ids = Vec::new();
for i in 0..count {
let slot = slots.get(i);
// Additive: keep existing panels (and their live processes) in their
// current visual order, spawn only the delta needed to reach `count`,
// then rebuild the tree to the preset shape. Presets never destroy
// running panels — shrinking is done by closing panels via the X. The
// GUI only offers presets whose count >= the current pane count, so
// `count >= existing.len()` and `ids.len() == count` after the loop.
let existing: Vec<SurfaceId> = ws.layout.as_ref()
.map(spacesh_core::ops::leaves)
.unwrap_or_else(|| ws.surfaces.keys().cloned().collect());
let mut ids = existing.clone();
let to_spawn = count.saturating_sub(existing.len());
for j in 0..to_spawn {
let slot = slots.get(existing.len() + j);
let new_sid = reg.new_surface_id();
let command = slot.and_then(|s| s.command.clone());
let shell = command.clone().unwrap_or_else(|| config.resolved_shell());
let args = slot.map(|s| s.args.clone()).unwrap_or_default();
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
let (env, hooks_active) = spawn_env(&new_sid, &spec);
match crate::surface::spawn_from_spec(new_sid.clone(), workspace_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
match crate::surface::spawn_from_spec(new_sid.clone(), workspace_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone(), snapshot_tx.clone()) {
Ok(handle) => {
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
reg.set_live(handle);
reg.set_state(&new_sid, spacesh_proto::SurfaceState::Idle);
reg.add_surface_spec(&workspace_id, new_sid.clone(), spec);
new_ids.push(new_sid);
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: new_sid.clone(), workspace_id: workspace_id.clone() }));
ids.push(new_sid);
}
Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; return; }
}
}
if let Some(tree) = spacesh_core::presets::build(&preset_id, &new_ids) {
if let Some(tree) = spacesh_core::presets::build(&preset_id, &ids) {
if let Some(w) = reg.workspace_mut(&workspace_id) { w.layout = Some(tree); }
}
for sid in &new_ids {
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: sid.clone(), workspace_id: workspace_id.clone() }));
}
emit_layout(reg, &workspace_id, clients);
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::json!({ "surface_ids": new_ids.iter().map(|s| s.0.clone()).collect::<Vec<_>>() }))).await;
let _ = out.send(ok(id, serde_json::json!({ "surface_ids": ids.iter().map(|s| s.0.clone()).collect::<Vec<_>>() }))).await;
}
Cmd::RestartSurface { surface_id } => {
Cmd::RestartSurface { surface_id, resume } => {
if reg.is_running(&surface_id) {
let _ = out.send(ok(id, serde_json::Value::Null)).await; return; // already running
}
let Some(spec) = reg.surface_spec(&surface_id) else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return;
};
let spec = resume_spec(&spec, resume, config);
let ws_id = reg.workspace_of(&surface_id).unwrap();
let (env, hooks_active) = spawn_env(&surface_id, &spec);
match crate::surface::spawn_from_spec(surface_id.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
match crate::surface::spawn_from_spec(surface_id.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone(), snapshot_tx.clone()) {
Ok(handle) => {
spawn_output_bridge(surface_id.clone(), &handle, router_tx.clone());
reg.set_live(handle);
@@ -458,6 +517,7 @@ async fn handle_request(
Cmd::CloseWorkspace { workspace_id } => {
let ids = reg.close_workspace(&workspace_id);
for sid in &ids { crate::hooks::cleanup(sid); crate::hooks::cleanup_shell(sid); subs.remove(sid); }
for sid in &ids { let _ = snapshot_tx.send(SnapshotMsg::Remove(sid.clone())); }
broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceClosed { workspace_id: workspace_id.clone() }));
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::Value::Null)).await;
@@ -547,10 +607,20 @@ async fn handle_request(
}
let _ = out.send(err(id, "INTERNAL", "attach failed")).await;
} else {
// stopped panel: no live stream, return an empty snapshot so the GUI shows the restart overlay.
// stopped panel: no live stream. Paint the last on-disk screen if we have one.
match snapshot_store.load(&surface_id) {
Some(snap) => {
let _ = out.send(ok(id, serde_json::json!({
"snapshot": snap.ansi, "cols": snap.cols, "rows": snap.rows,
"cursor_row": snap.cursor_row, "cursor_col": snap.cursor_col, "stopped": true,
}))).await;
}
None => {
let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0, "stopped": true }))).await;
}
}
}
}
Cmd::Detach { surface_id } => {
if let Some(list) = subs.get_mut(&surface_id) { list.retain(|c| *c != client); }
@@ -574,6 +644,7 @@ async fn handle_request(
subs.remove(&surface_id);
crate::hooks::cleanup(&surface_id);
crate::hooks::cleanup_shell(&surface_id);
let _ = snapshot_tx.send(SnapshotMsg::Remove(surface_id.clone()));
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() }));
if let Some(ws_id) = ws_id {
emit_layout(reg, &ws_id, clients);
@@ -628,6 +699,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;
@@ -656,6 +732,19 @@ async fn handle_request(
}
Cmd::Shutdown => {
// Final snapshot pass: capture each live surface's visible screen so a
// clean restart (e.g. Settings → Restart daemon) repaints last screens.
// Written synchronously through the store (the async writer task would
// not drain before process::exit).
for sid in reg.live_ids() {
let Some(handle) = reg.live(&sid) else { continue };
let (reply_tx, reply_rx) = oneshot::channel();
if handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.is_ok() {
if let Ok((snap, _dirty)) = reply_rx.await {
snapshot_store.save(&sid, &snap);
}
}
}
let _ = out.send(ok(id, serde_json::Value::Null)).await;
std::process::exit(0);
}
@@ -728,6 +817,7 @@ fn spawn_output_bridge(
mod tests {
use super::*;
use base64::Engine;
use crate::snapshot_store::NullSnapshotStore;
async fn req(stream: &mut UnixStream, id: u64, cmd: Cmd) -> Envelope {
write_frame(stream, &Envelope::Req { id, cmd }).await.unwrap();
@@ -776,7 +866,7 @@ mod tests {
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
@@ -818,7 +908,7 @@ mod tests {
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Input {
@@ -844,7 +934,7 @@ mod tests {
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
// First client: open, new surface that prints a marker, attach, then disconnect.
@@ -883,7 +973,7 @@ mod tests {
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
@@ -913,7 +1003,7 @@ mod tests {
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
@@ -957,7 +1047,7 @@ mod tests {
// per-test dir so instance B reads from disk what instance A persisted.
let event_store = make_event_store(&dir);
let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
@@ -974,7 +1064,7 @@ mod tests {
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
let event_store_b = make_event_store(&dir);
let sb2 = sock_b.clone();
tokio::spawn(async move { let _ = serve(&sock_b, store_b, event_store_b).await; });
tokio::spawn(async move { let _ = serve(&sock_b, store_b, event_store_b, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sb2).await;
let mut s2 = UnixStream::connect(&sb2).await.unwrap();
let r = req(&mut s2, 1, Cmd::Status).await;
@@ -996,7 +1086,7 @@ mod tests {
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
@@ -1034,7 +1124,7 @@ mod tests {
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
// Control connection: open workspace and spawn surface.
@@ -1087,7 +1177,7 @@ mod tests {
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
// Control connection: open workspace and spawn surface.
@@ -1139,7 +1229,7 @@ mod tests {
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
// Control connection: open workspace and spawn a surface that emits OSC 133.
@@ -1190,7 +1280,7 @@ mod tests {
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
// Observer connection to catch the EventsRead broadcast.
@@ -1258,7 +1348,7 @@ mod tests {
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
let event_store = make_event_store(&dir);
let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock2, store, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
@@ -1300,7 +1390,7 @@ mod tests {
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
let event_store_b = make_event_store(&dir);
let sb2 = sock_b.clone();
tokio::spawn(async move { let _ = serve(&sock_b, store_b, event_store_b).await; });
tokio::spawn(async move { let _ = serve(&sock_b, store_b, event_store_b, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sb2).await;
let mut s2 = UnixStream::connect(&sb2).await.unwrap();
@@ -1328,7 +1418,7 @@ mod tests {
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
// Observer connection.
@@ -1390,7 +1480,7 @@ mod tests {
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
@@ -1413,7 +1503,7 @@ mod tests {
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
@@ -1460,7 +1550,7 @@ mod tests {
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; });
wait_for_socket(&sock).await;
// Control connection: open, spawn, zoom.
@@ -1500,4 +1590,73 @@ mod tests {
}
assert!(saw_cleared, "expected a WorkspaceChanged broadcast with cleared zoom after closing the zoomed surface");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stopped_attach_returns_disk_snapshot() {
let _serial = crate::test_support::serial();
let dir = tempdir_path();
let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
// Use a real JsonSnapshotStore so the on-exit dump actually lands on disk.
let snap_dir = dir.join("snapshots");
let snapshot_store: std::sync::Arc<dyn crate::snapshot_store::SnapshotStore> =
std::sync::Arc::new(crate::snapshot_store::JsonSnapshotStore::new(snap_dir.clone()));
let sock_for_task = sock.clone();
let store2 = store.clone();
let snap_store2 = snapshot_store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store, snap_store2).await; });
wait_for_socket(&sock).await;
// Open workspace, spawn a surface that prints a unique marker then exits quickly.
let surface_id;
{
let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
let r = req(&mut s, 2, Cmd::NewSurface {
workspace_id: spacesh_proto::WorkspaceId(ws),
command: Some("/bin/sh".into()),
args: vec!["-c".into(), "printf SNAPDISK; sleep 0.2".into()],
cols: 80, rows: 24,
}).await;
surface_id = spacesh_proto::SurfaceId(res_data(&r)["surface_id"].as_str().unwrap().to_string());
// Give the process time to run, exit, and the actor to dump its snapshot to the writer.
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
// s drops here
}
// Re-verify the socket is still alive.
wait_for_socket(&sock).await;
// Fresh client: attach to the now-stopped surface.
let mut s2 = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s2, 1, Cmd::Attach { surface_id: surface_id.clone() }).await;
let data = res_data(&r);
assert_eq!(data["stopped"].as_bool(), Some(true), "surface should be stopped");
let snap = data["snapshot"].as_str().unwrap_or("");
assert!(snap.contains("SNAPDISK"), "on-disk snapshot should contain SNAPDISK, got: {snap:?}");
}
#[test]
fn resume_spec_swaps_args_when_mapped() {
use spacesh_proto::workspace::SurfaceSpec;
let spec = SurfaceSpec {
command: "claude".into(), args: vec!["--foo".into()], cwd: "/tmp".into(),
agent_label: Some("claude".into()), cols: 80, rows: 24, autostart: false,
};
let cfg = crate::config::Config::default();
// resume=false → original args
let plain = resume_spec(&spec, false, &cfg);
assert_eq!(plain.args, vec!["--foo".to_string()]);
// resume=true with a default mapping → resume args
let resumed = resume_spec(&spec, true, &cfg);
assert_eq!(resumed.args, vec!["--continue".to_string()]);
// resume=true for an unmapped command → original args (graceful fallback)
let mut shell = spec.clone();
shell.command = "bash".into();
let resumed_shell = resume_spec(&shell, true, &cfg);
assert_eq!(resumed_shell.args, shell.args);
}
}
+162
View File
@@ -0,0 +1,162 @@
use std::path::PathBuf;
use spacesh_core::snapshot::Snapshot;
use spacesh_proto::SurfaceId;
/// Stores one visible-screen snapshot per surface as `<dir>/<surface_id>.json`.
pub trait SnapshotStore: Send + Sync {
fn save(&self, sid: &SurfaceId, snap: &Snapshot);
fn load(&self, sid: &SurfaceId) -> Option<Snapshot>;
fn remove(&self, sid: &SurfaceId);
}
/// Writer command: persist or delete a surface's snapshot. Shared by the
/// router ticker, the close/remove paths, and each actor's on-exit dump, so a
/// single channel type flows everywhere.
pub enum SnapshotMsg {
Save(SurfaceId, Snapshot),
Remove(SurfaceId),
}
/// A no-op store for tests and contexts that do not persist snapshots.
pub struct NullSnapshotStore;
impl SnapshotStore for NullSnapshotStore {
fn save(&self, _sid: &SurfaceId, _snap: &Snapshot) {}
fn load(&self, _sid: &SurfaceId) -> Option<Snapshot> { None }
fn remove(&self, _sid: &SurfaceId) {}
}
/// JSON file store. Filenames are the surface id (e.g. `s_1f.json`); ids are
/// `^[a-z]_[0-9a-f]+$` so they are always safe path components.
pub struct JsonSnapshotStore {
dir: PathBuf,
}
impl JsonSnapshotStore {
pub fn new(dir: PathBuf) -> Self {
let _ = std::fs::create_dir_all(&dir);
Self { dir }
}
fn path(&self, sid: &SurfaceId) -> PathBuf {
self.dir.join(format!("{}.json", sid.0))
}
}
impl SnapshotStore for JsonSnapshotStore {
fn save(&self, sid: &SurfaceId, snap: &Snapshot) {
let path = self.path(sid);
let tmp = path.with_extension("json.tmp");
let Ok(bytes) = serde_json::to_vec(snap) else { return };
if std::fs::write(&tmp, &bytes).is_err() { return; }
if std::fs::File::open(&tmp).and_then(|f| f.sync_all()).is_err() { return; }
let _ = std::fs::rename(&tmp, &path);
}
fn load(&self, sid: &SurfaceId) -> Option<Snapshot> {
let bytes = std::fs::read(self.path(sid)).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn remove(&self, sid: &SurfaceId) {
let _ = std::fs::remove_file(self.path(sid));
}
}
/// Spawn the writer task; returns the sender used by the router and actors.
pub fn spawn_writer(store: std::sync::Arc<dyn SnapshotStore>) -> tokio::sync::mpsc::UnboundedSender<SnapshotMsg> {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<SnapshotMsg>();
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
match msg {
SnapshotMsg::Save(sid, snap) => store.save(&sid, &snap),
SnapshotMsg::Remove(sid) => store.remove(&sid),
}
}
});
tx
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir(name: &str) -> PathBuf {
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let p = std::env::temp_dir().join(format!("spacesh-snap-{name}-{n}"));
std::fs::create_dir_all(&p).unwrap();
p
}
fn sample() -> Snapshot {
Snapshot { ansi: "\u{1b}[mhello".into(), cols: 80, rows: 24, cursor_row: 1, cursor_col: 6 }
}
#[test]
fn save_then_load_round_trips() {
let dir = tmp_dir("roundtrip");
let store = JsonSnapshotStore::new(dir.clone());
let sid = SurfaceId("s_1".into());
store.save(&sid, &sample());
assert_eq!(store.load(&sid), Some(sample()));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn missing_loads_none() {
let store = JsonSnapshotStore::new(tmp_dir("missing"));
assert_eq!(store.load(&SurfaceId("s_none".into())), None);
}
#[test]
fn corrupt_loads_none() {
let dir = tmp_dir("corrupt");
let store = JsonSnapshotStore::new(dir.clone());
let sid = SurfaceId("s_2".into());
std::fs::write(dir.join("s_2.json"), b"{ not json").unwrap();
assert_eq!(store.load(&sid), None);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn remove_deletes_file() {
let dir = tmp_dir("remove");
let store = JsonSnapshotStore::new(dir.clone());
let sid = SurfaceId("s_3".into());
store.save(&sid, &sample());
assert!(store.load(&sid).is_some());
store.remove(&sid);
assert_eq!(store.load(&sid), None);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn null_store_is_inert() {
let store = NullSnapshotStore;
let sid = SurfaceId("s_4".into());
store.save(&sid, &sample());
assert_eq!(store.load(&sid), None);
store.remove(&sid);
}
#[tokio::test]
async fn writer_saves_and_removes() {
let dir = tmp_dir("writer");
let store: std::sync::Arc<dyn SnapshotStore> = std::sync::Arc::new(JsonSnapshotStore::new(dir.clone()));
let tx = spawn_writer(store.clone());
let sid = SurfaceId("s_w".into());
tx.send(SnapshotMsg::Save(sid.clone(), sample())).unwrap();
let mut saved = None;
for _ in 0..50 {
if let Some(s) = store.load(&sid) { saved = Some(s); break; }
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(saved, Some(sample()));
tx.send(SnapshotMsg::Remove(sid.clone())).unwrap();
let mut gone = false;
for _ in 0..50 {
if store.load(&sid).is_none() { gone = true; break; }
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(gone, "writer should have removed the snapshot file");
let _ = std::fs::remove_dir_all(dir);
}
}
+93 -15
View File
@@ -7,6 +7,7 @@ use spacesh_proto::workspace::SurfaceSpec;
use spacesh_pty::{PtyHandle, SpawnSpec};
use tokio::sync::{broadcast, mpsc, oneshot};
use tokio::time::{Duration, Instant};
use crate::snapshot_store::SnapshotMsg;
/// Spawn (or restart) a surface actor from a persisted spec. Injects
/// SPACESH_SURFACE_ID into the child env, mirroring `new_surface`.
@@ -24,6 +25,7 @@ pub fn spawn_from_spec(
hooks_active: bool,
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
snapshot_tx: mpsc::UnboundedSender<SnapshotMsg>,
) -> std::io::Result<SurfaceHandle> {
let mut env = vec![("SPACESH_SURFACE_ID".to_string(), id.0.clone())];
env.extend(extra_env);
@@ -35,7 +37,7 @@ pub fn spawn_from_spec(
rows: spec.rows,
env,
};
Ok(spawn_surface_deferred(id, workspace_id, spawn_spec, spec.cols, spec.rows, hooks_active, state_tx, exit_tx))
Ok(spawn_surface_deferred(id, workspace_id, spawn_spec, spec.cols, spec.rows, hooks_active, state_tx, exit_tx, snapshot_tx))
}
const BROADCAST_CAP: usize = 1024;
@@ -53,6 +55,8 @@ pub enum SurfaceMsg {
Attach { reply: oneshot::Sender<broadcast::Receiver<Vec<u8>>> },
/// Attach with snapshot: subscribe AND capture the grid in one actor turn.
AttachSnapshot { reply: oneshot::Sender<(Snapshot, broadcast::Receiver<Vec<u8>>)> },
/// On-demand snapshot without subscribing; bool = dirty since last snapshot.
Snapshot { reply: oneshot::Sender<(Snapshot, bool)> },
Close,
}
@@ -76,10 +80,11 @@ pub fn spawn_surface(
hooks_active: bool,
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
snapshot_tx: mpsc::UnboundedSender<SnapshotMsg>,
) -> SurfaceHandle {
let (tx, rx) = mpsc::channel::<SurfaceMsg>(64);
let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
tokio::spawn(run_actor(id.clone(), pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, Vec::new()));
tokio::spawn(run_actor(id.clone(), pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, snapshot_tx, Vec::new()));
SurfaceHandle { id, workspace_id, tx }
}
@@ -97,6 +102,7 @@ pub fn spawn_surface_deferred(
hooks_active: bool,
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
snapshot_tx: mpsc::UnboundedSender<SnapshotMsg>,
) -> SurfaceHandle {
let (tx, mut rx) = mpsc::channel::<SurfaceMsg>(64);
let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
@@ -122,6 +128,10 @@ pub fn spawn_surface_deferred(
let snap = snapshot_ansi(&GridSurface::new(cols, rows));
let _ = reply.send((snap, sub));
}
Some(SurfaceMsg::Snapshot { reply }) => {
let snap = snapshot_ansi(&GridSurface::new(cols, rows));
let _ = reply.send((snap, false));
}
Some(SurfaceMsg::Close) | None => break false,
}
}
@@ -135,7 +145,7 @@ pub fn spawn_surface_deferred(
spec.cols = cols;
spec.rows = rows;
match PtyHandle::spawn(spec) {
Ok(pty) => run_actor(actor_id, pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, prebuf).await,
Ok(pty) => run_actor(actor_id, pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, snapshot_tx, prebuf).await,
Err(_) => { let _ = exit_tx.send((actor_id, 127)); }
}
});
@@ -156,6 +166,7 @@ async fn run_actor(
mut rx: mpsc::Receiver<SurfaceMsg>,
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
snapshot_tx: mpsc::UnboundedSender<SnapshotMsg>,
prebuffered_input: Vec<u8>,
) {
let actor_id = id.clone();
@@ -173,6 +184,7 @@ async fn run_actor(
// (hooks active, or any OSC 133 marker observed).
let mut deterministic = hooks_active;
let mut last_state = SurfaceState::Idle;
let mut dirty = false;
loop {
// Copy the deadline into an owned local so the timer future doesn't
@@ -202,8 +214,15 @@ async fn run_actor(
// this snapshot. Broadcasting here would double-render on reattach.
let sub = bcast.subscribe();
let snap = snapshot_ansi(&grid);
dirty = false;
let _ = reply.send((snap, sub));
}
Some(SurfaceMsg::Snapshot { reply }) => {
let snap = snapshot_ansi(&grid);
let was_dirty = dirty;
dirty = false;
let _ = reply.send((snap, was_dirty));
}
Some(SurfaceMsg::Close) | None => { pty.kill(); break; }
}
}
@@ -211,26 +230,31 @@ async fn run_actor(
match chunk {
Some(bytes) => {
pending.extend_from_slice(&bytes);
dirty = true;
if flush_deadline.is_none() {
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;
}
}
}
let final_snap = snapshot_ansi(&grid);
let _ = snapshot_tx.send(SnapshotMsg::Save(actor_id.clone(), final_snap));
let code = pty.wait();
let _ = exit_tx.send((actor_id, code));
}
@@ -238,6 +262,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 +274,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 +291,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 +304,7 @@ fn flush(
}
}
let _ = bcast.send(std::mem::take(pending));
replies
}
#[cfg(test)]
@@ -299,7 +329,8 @@ mod tests {
let pty = PtyHandle::spawn(spec("printf HELLO; sleep 0.3")).unwrap();
let (state_tx, _state_rx) = mpsc::unbounded_channel();
let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx);
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
let (reply_tx, reply_rx) = oneshot::channel();
handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap();
@@ -324,7 +355,8 @@ mod tests {
let pty = PtyHandle::spawn(spec("exit 7")).unwrap();
let (state_tx, _state_rx) = mpsc::unbounded_channel();
let (exit_tx, mut exit_rx) = mpsc::unbounded_channel();
let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx);
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
let (sid, code) = tokio::time::timeout(tokio::time::Duration::from_secs(3), exit_rx.recv())
.await.unwrap().unwrap();
assert_eq!(sid, SurfaceId("s_2".into()));
@@ -337,7 +369,8 @@ mod tests {
let pty = PtyHandle::spawn(spec("printf SNAPME; sleep 0.5")).unwrap();
let (state_tx, _state_rx) = mpsc::unbounded_channel();
let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
let handle = spawn_surface(SurfaceId("s_s".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx);
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
let handle = spawn_surface(SurfaceId("s_s".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
// Give the child time to write and the actor time to flush into the grid.
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
@@ -359,7 +392,8 @@ mod tests {
};
let (state_tx, _state_rx) = mpsc::unbounded_channel();
let (exit_tx, _rx) = mpsc::unbounded_channel();
let handle = spawn_from_spec(SurfaceId("s_r".into()), WorkspaceId("w_1".into()), &spec, vec![], false, state_tx, exit_tx).unwrap();
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
let handle = spawn_from_spec(SurfaceId("s_r".into()), WorkspaceId("w_1".into()), &spec, vec![], false, state_tx, exit_tx, snap_tx).unwrap();
let (reply_tx, reply_rx) = oneshot::channel();
handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap();
let mut sub = reply_rx.await.unwrap();
@@ -404,7 +438,8 @@ mod tests {
let _serial = crate::test_support::serial();
let (state_tx, _s) = mpsc::unbounded_channel();
let (exit_tx, _e) = mpsc::unbounded_channel();
let handle = spawn_surface_deferred(SurfaceId("s_d".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx);
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
let handle = spawn_surface_deferred(SurfaceId("s_d".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx, snap_tx);
let (rtx, rrx) = oneshot::channel();
handle.tx.send(SurfaceMsg::Attach { reply: rtx }).await.unwrap();
@@ -421,7 +456,8 @@ mod tests {
let _serial = crate::test_support::serial();
let (state_tx, _s) = mpsc::unbounded_channel();
let (exit_tx, _e) = mpsc::unbounded_channel();
let handle = spawn_surface_deferred(SurfaceId("s_f".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx);
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
let handle = spawn_surface_deferred(SurfaceId("s_f".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx, snap_tx);
let (rtx, rrx) = oneshot::channel();
handle.tx.send(SurfaceMsg::Attach { reply: rtx }).await.unwrap();
@@ -437,7 +473,8 @@ mod tests {
let pty = PtyHandle::spawn(spec("printf '\\033]133;C\\007'; printf working; printf '\\033]133;D;0\\007'; sleep 0.3")).unwrap();
let (state_tx, mut state_rx) = mpsc::unbounded_channel();
let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
let _h = spawn_surface(SurfaceId("s_o".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx);
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
let _h = spawn_surface(SurfaceId("s_o".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
let mut seen = Vec::new();
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2);
while tokio::time::Instant::now() < deadline {
@@ -449,4 +486,45 @@ mod tests {
assert!(seen.contains(&SurfaceState::Work), "states: {seen:?}");
assert!(seen.contains(&SurfaceState::Done), "states: {seen:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_msg_returns_grid_and_tracks_dirty() {
let _serial = crate::test_support::serial();
let pty = PtyHandle::spawn(spec("printf DIRTYME; sleep 0.4")).unwrap();
let (state_tx, _s) = mpsc::unbounded_channel();
let (exit_tx, _e) = mpsc::unbounded_channel();
let (snap_tx, _snap_rx) = mpsc::unbounded_channel();
let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
tokio::time::sleep(Duration::from_millis(150)).await;
let (reply_tx, reply_rx) = oneshot::channel();
handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.unwrap();
let (snap, dirty) = reply_rx.await.unwrap();
assert!(snap.ansi.contains("DIRTYME"), "snapshot: {:?}", snap.ansi);
assert!(dirty, "first snapshot after output should be dirty");
let (reply_tx, reply_rx) = oneshot::channel();
handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.unwrap();
let (_snap2, dirty2) = reply_rx.await.unwrap();
assert!(!dirty2, "second snapshot with no new output should be clean");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn final_snapshot_sent_on_exit() {
let _serial = crate::test_support::serial();
let pty = PtyHandle::spawn(spec("printf BYE")).unwrap(); // exits immediately
let (state_tx, _s) = mpsc::unbounded_channel();
let (exit_tx, _e) = mpsc::unbounded_channel();
let (snap_tx, mut snap_rx) = mpsc::unbounded_channel();
let _handle = spawn_surface(SurfaceId("s_x".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx);
let msg = tokio::time::timeout(Duration::from_secs(2), snap_rx.recv()).await.unwrap().unwrap();
match msg {
crate::snapshot_store::SnapshotMsg::Save(sid, snap) => {
assert_eq!(sid.0, "s_x");
assert!(snap.ansi.contains("BYE"), "final snapshot: {:?}", snap.ansi);
}
_ => panic!("expected a Save message on exit"),
}
}
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
// Bump the patch version in lockstep for the GUI (app/src-tauri/tauri.conf.json)
// and the daemon/CLI (root Cargo.toml [workspace.package].version), so the app's
// update check and the daemon's reported version never drift apart.
import { readFileSync, writeFileSync } from "node:fs";
const TAURI_CONF = "app/src-tauri/tauri.conf.json";
const CARGO_TOML = "Cargo.toml";
const conf = JSON.parse(readFileSync(TAURI_CONF, "utf8"));
const parts = conf.version.split(".").map((n) => parseInt(n, 10) || 0);
parts[2] = (parts[2] || 0) + 1;
const next = parts.join(".");
conf.version = next;
writeFileSync(TAURI_CONF, JSON.stringify(conf, null, 2) + "\n");
let cargo = readFileSync(CARGO_TOML, "utf8");
// Replace only the version inside [workspace.package], not dependency versions.
cargo = cargo.replace(
/(\[workspace\.package\][\s\S]*?version\s*=\s*")[^"]+(")/,
`$1${next}$2`
);
writeFileSync(CARGO_TOML, cargo);
console.log(`version → ${next} (GUI + daemon)`);