Compare commits
19 Commits
95ddf30b8c
...
0275c64ace
| Author | SHA1 | Date | |
|---|---|---|---|
|
0275c64ace
|
|||
|
0a67f401c4
|
|||
|
ce6a8d56be
|
|||
|
5c76493a34
|
|||
|
ff0ad7a648
|
|||
|
375e4c5c92
|
|||
|
31c08b5387
|
|||
|
eecea9c38c
|
|||
|
d00abcd2f6
|
|||
|
60383cd543
|
|||
|
69f2e73832
|
|||
|
0674872c9d
|
|||
|
1a7d04aab0
|
|||
|
bd36a83db2
|
|||
|
bb5edb941c
|
|||
|
4419f5660e
|
|||
|
e37faf49d3
|
|||
|
1f69973606
|
|||
|
3d54d679d3
|
Generated
+6
-5
@@ -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
@@ -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).
|
||||
@@ -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
|
||||
|
||||
Generated
+14
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+59
-14
@@ -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,19 +180,26 @@ 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={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={() => void restartSurface(id)}
|
||||
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
|
||||
</button>
|
||||
{zoomed === id && (
|
||||
<button onClick={() => void setZoom(workspaceId, null)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<Minimize2 size={13} /> Exit zoom
|
||||
<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, 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 fresh
|
||||
</button>
|
||||
{zoomed === id && (
|
||||
<button onClick={() => void setZoom(workspaceId, null)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<Minimize2 size={13} /> Exit zoom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
+33
-9
@@ -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; })}
|
||||
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
|
||||
{agentChoices.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
))}
|
||||
{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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
+205
-46
@@ -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,8 +607,18 @@ 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.
|
||||
let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0, "stopped": true }))).await;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)`);
|
||||
Reference in New Issue
Block a user