Compare commits

...

40 Commits

Author SHA1 Message Date
vasyansk 50996c929d Add dialog plugin to Tauri app
Add workspace name to LayoutEngine props

Update version to 0.1.30

Implement file open dialog in Wizard
2026-06-16 12:53:46 +07:00
vasyansk ee845e15b3 Add full disk access checks and settings
Add background themes and custom images

Add shell command logging toggle

Add UTF-8 locale guarantee for PTY

Add Claude hook settings injection

Add hotkey system for GUI

Add glass panel styling

Add search disabled state for agent panels

Add zoom toggle command

Add device report filtering

Add entitlements for notarization

Update version to 0.1.27
2026-06-15 22:26:06 +07:00
vasyansk 2ee2aaaffb Update version to 0.1.10
Build / Build & push landing (push) Successful in 14s
Build / Deploy to prod (push) Successful in 7s
Build / Notify Max (push) Successful in 2s
Add deepseek to resume commands

Rename app to spaceshell

Add SurfacePicker component for preset panel configuration

Extract agent selection logic to shared agents.ts

Update landing
2026-06-15 17:25:53 +07:00
vasyansk 333b051e9d Update version to 0.1.7
Build / Build & push landing (push) Successful in 18s
Build / Deploy to prod (push) Successful in 6s
Build / Notify Max (push) Successful in 1s
Fix landing page GitHub links to use pub namespace
2026-06-15 16:59:27 +07:00
vasyansk 372dd7123a Update version to 0.1.6
Add Gitea package registry support

Add publish-dmg target for versioned DMG uploads

Update deploy-dmg to include Gitea publishing

Document Gitea token requirements in README
2026-06-15 16:52:24 +07:00
vasyansk 39bb8e5fee feat(app): close (X) on panel header + Close button on stopped overlay
Wires the existing closeSurfaceCmd into the panel header (red-on-hover X next
to zoom) and adds a Close button to the stopped overlay, so a panel — including
an empty/stopped one — can be dismissed instead of resumed/restarted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:47:43 +07:00
vasyansk d62628be8d fix(daemon): reseed id counter on restore + heal duplicate leaves
Root cause of the multi-focus/multi-search/linked-terminal bug: the in-memory
id counter resets to 0 each daemon start, but restore() never advanced it past
restored ids. After a restart new_surface_id() re-minted existing ids → the same
surface_id appeared twice in a layout tree (rendered as two panels sharing focus,
search bar, and output channel — one ends up blank). Session-persistence made
restarts routine, surfacing the latent bug.

- restore() now reseeds the counter to max(restored id)+1
- ops::dedupe_leaves heals an already-corrupted persisted tree on load

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:46:04 +07:00
vasyansk 3317b24d18 fix(daemon): gate NullSnapshotStore behind cfg(test) — silence release dead_code warning
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:34:10 +07:00
vasyansk 0275c64ace Add NerdFont for symbols and version bump script
Add version bump script to synchronize GUI and daemon versions
2026-06-15 16:32:31 +07:00
vasyansk 0a67f401c4 Update version to 0.1.3
Add daemon version check and restart logic

Add pane count to CenterToolbar

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:24:53 +07:00
vasyansk 5c76493a34 feat(cli): spacesh restart --resume flag (plan gap: CLI is a first-class client)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:11:06 +07:00
vasyansk ff0ad7a648 feat(app): stopped panel paints last screen + Resume/Restart fresh controls
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:09:39 +07:00
vasyansk 375e4c5c92 feat(app): plumb resume flag through restart_surface bridge + binding
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:07:32 +07:00
vasyansk 31c08b5387 feat(daemon): RestartSurface honors resume — swap to resume_args when mapped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:05:53 +07:00
vasyansk eecea9c38c feat(proto): RestartSurface gains resume flag (defaults false) 2026-06-15 16:03:03 +07:00
vasyansk d00abcd2f6 chore: lock serde_json dev-dep for spacesh-core (Task 1 followup)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:01:58 +07:00
vasyansk 60383cd543 feat(daemon): snapshot ticker + writer wiring + stopped-attach reads disk + cleanup on close
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:00:39 +07:00
vasyansk 69f2e73832 feat(daemon): snapshot writer task (Save/Remove over one channel)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:49:29 +07:00
vasyansk 0674872c9d feat(daemon): actor Snapshot message + dirty tracking + final snapshot on exit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:47:40 +07:00
vasyansk 1a7d04aab0 feat(daemon): [resume] config map + snapshot_interval_secs with built-in defaults
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:38:30 +07:00
vasyansk bd36a83db2 feat(daemon): per-surface JSON snapshot store (atomic write, corrupt-tolerant)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:36:21 +07:00
vasyansk bb5edb941c feat(core): Snapshot derives Deserialize + PartialEq for disk persistence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:30:16 +07:00
vasyansk 4419f5660e wip: in-progress changes (grid, config, wizard, settings, pty) before session-persistence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:28:19 +07:00
vasyansk e37faf49d3 docs: sync session-persistence spec to leaner RestartSurface-based design
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:20:02 +07:00
vasyansk 1f69973606 docs: session persistence implementation plan + spec sync to leaner design
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:18:55 +07:00
vasyansk 3d54d679d3 docs: session persistence (resurrect + resume) design spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:05:21 +07:00
vasyansk 95ddf30b8c Update index.html
Build / Build & push landing (push) Successful in 14s
Build / Deploy to prod (push) Successful in 7s
Build / Notify Max (push) Successful in 1s
2026-06-15 14:37:58 +07:00
vasyansk 614d7fea06 Add cloud download icon and improve update UI handling
Build / Build & push landing (push) Successful in 14s
Build / Deploy to prod (push) Successful in 6s
Build / Notify Max (push) Successful in 2s
Update UI to use CloudDownload icon instead of RefreshCw
Improve error handling for update checks
Add better visual feedback for update states
Change GitHub link to RealManual repository
2026-06-15 14:36:30 +07:00
vasyansk 74abea5467 fix(deploy): stable container_name for the proxy (avoid pinned-IP collision)
container_name: spacesh-proxy lets NPM forward by name instead of the fragile
pinned 172.18.0.28, which another webproxy container could grab — sending NPM
to the wrong target (or itself) and looping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:25:31 +07:00
vasyansk fcbf4a69a0 Update VERSION
Build / Build & push landing (push) Successful in 14s
Build / Deploy to prod (push) Successful in 6s
Build / Notify Max (push) Successful in 2s
2026-06-15 14:24:35 +07:00
vasyansk 9db52595c7 Add update check functionality
Implement version checking and update notifications in the GUI
2026-06-15 14:23:30 +07:00
vasyansk 4c9eacccb7 fix(deploy): put landing on proxy's network + runtime DNS resolve
The landing service had no networks: key, so it joined the auto 'default'
network while proxy was only on spaceshell-network + webproxy — they shared no
network, so proxy_pass to 'landing' couldn't resolve. With a static
upstream{ server landing:80 } nginx fails to boot on an unresolvable name and
restart-loops, so the proxy flapped (page intermittently up/down). Fixes:
- landing now joins spaceshell-network (shared with proxy).
- proxy.conf resolves 'landing' at request time via Docker DNS (127.0.0.11)
  using a variable proxy_pass, so nginx starts even if landing is briefly down.
nginx -t passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:20:33 +07:00
vasyansk 1423150b10 docs: repoint README image/doc links to DOCS/ after the move
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:13:26 +07:00
vasyansk 1b8dd9bd93 feat(app): make 'Mark all read' an icon (CheckCheck) next to the trash
Matches the clear-all trash icon; dimmed/disabled when there are no unread events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:12:45 +07:00
vasyansk 524b3def6b docs: move the user guide to the root README.md
Promotes DOCS/GUIDE.md to the repo root README (image/links repointed to DOCS/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:12:45 +07:00
vasyansk a9af60f5cd docs: Russian user guide with screenshots
DOCS/GUIDE.md — feature walkthrough with the two app screenshots from
landing/pics copied into DOCS/images.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:09:42 +07:00
vasyansk 179744d8b3 fixes port
Build / Build & push landing (push) Successful in 16s
Build / Deploy to prod (push) Successful in 11s
Build / Notify Max (push) Successful in 1s
2026-06-15 14:05:18 +07:00
vasyansk e15146af60 Merge fix-launch-delay: no blocking handshake, fire-and-forget shutdown, .app-only reinstall 2026-06-15 13:58:05 +07:00
vasyansk 0a26e77899 fix(app): drop blocking version-handshake; Shutdown is fire-and-forget
The handshake ran synchronously in Bridge::connect: on a build-id mismatch it
sent Cmd::Shutdown and awaited a reply that never flushes (the daemon exits
first), so request() hit its 5s timeout and the reconnect-retry respawned the
daemon and re-sent Shutdown — a loop that produced repeated 'spaceshd
listening' lines and a multi-second launch delay. The id stamps also differed
between the separately-built daemon and GUI, so it fired on normal launches.

- Remove the handshake auto-restart; `make install`/`reinstall` already kill
  and replace the daemon reliably. health.build stays for display in Settings.
- Shutdown now goes through a fire-and-forget send (no reply wait, no retry),
  fixing the same loop for the Settings Restart button.
- Makefile: `make app-bundle` builds just the .app via `tauri build --bundles
  app` (no .dmg, no hdiutil) and `reinstall` uses it — faster self-update that
  can't hang on a mounted DMG volume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:58:04 +07:00
62 changed files with 4665 additions and 309 deletions
+3
View File
@@ -6,3 +6,6 @@ app/dist/
# Generated daemon sidecar for DMG bundling (make dmg)
app/src-tauri/bin/
# Local notarization secrets (Apple ID / app-specific password)
.signing.env
Generated
+6 -5
View File
@@ -869,7 +869,7 @@ dependencies = [
[[package]]
name = "spacesh-cli"
version = "0.1.0"
version = "0.1.30"
dependencies = [
"anyhow",
"clap",
@@ -881,16 +881,17 @@ dependencies = [
[[package]]
name = "spacesh-core"
version = "0.1.0"
version = "0.1.30"
dependencies = [
"alacritty_terminal",
"serde",
"serde_json",
"spacesh-proto",
]
[[package]]
name = "spacesh-proto"
version = "0.1.0"
version = "0.1.30"
dependencies = [
"bytes",
"serde",
@@ -902,7 +903,7 @@ dependencies = [
[[package]]
name = "spacesh-pty"
version = "0.1.0"
version = "0.1.30"
dependencies = [
"anyhow",
"bytes",
@@ -912,7 +913,7 @@ dependencies = [
[[package]]
name = "spaceshd"
version = "0.1.0"
version = "0.1.30"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -10,7 +10,7 @@ members = [
[workspace.package]
edition = "2021"
version = "0.1.0"
version = "0.1.30"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

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).
+98 -17
View File
@@ -7,9 +7,10 @@ DMG_DIR := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/dm
NATIVE_DMG_DIR := $(APP_DIR)/src-tauri/target/release/bundle/dmg
NATIVE_TRIPLE := $(shell rustc -vV 2>/dev/null | awk '/^host:/{print $$2}')
SIDECAR_DIR := $(APP_DIR)/src-tauri/bin
ENTITLEMENTS := $(APP_DIR)/src-tauri/Entitlements.plist
BUNDLE_CONFIG := src-tauri/tauri.bundle.conf.json
APP_BUNDLE := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/macos/spacesh.app
NATIVE_APP_BUNDLE := $(APP_DIR)/src-tauri/target/release/bundle/macos/spacesh.app
APP_BUNDLE := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/macos/spaceshell.app
NATIVE_APP_BUNDLE := $(APP_DIR)/src-tauri/target/release/bundle/macos/spaceshell.app
APP_VERSION := $(shell node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version" 2>/dev/null || echo 0.0.0)
LANDING_IMAGE := spacesh-landing
@@ -17,10 +18,44 @@ LANDING_VERSION := $(shell cat landing/VERSION 2>/dev/null || echo 0.0.0)
REGISTRY ?= git.realmanual.ru
REPO ?= spacesh
# Stable code-signing identity. Without a STABLE signature the app is ad-hoc
# signed and its code identity changes every build, so macOS attributes child
# processes (the daemon → Claude Code) to a different "responsible app" each time:
# TCC permissions reset and agents lose their Keychain login on every rebuild.
# Defaults to the Developer ID (Team 3PNKDC6L42) — a stable designated requirement
# (anchor apple generic + TeamID) that Keychain/TCC trust survives across rebuilds.
# Override with `SIGN_IDENTITY="<cert name>" make reinstall`, or `SIGN_IDENTITY=`
# to fall back to ad-hoc. Tauri reads APPLE_SIGNING_IDENTITY for the bundle + sidecar.
SIGN_IDENTITY ?= Developer ID Application: Vassiliy Yegorov (3PNKDC6L42)
ifneq ($(strip $(SIGN_IDENTITY)),)
export APPLE_SIGNING_IDENTITY := $(SIGN_IDENTITY)
endif
# Notarization (required to distribute the DMG — Gatekeeper blocks un-notarized apps
# on other Macs). Secrets: put them in a gitignored `.signing.env` (make syntax,
# e.g. `APPLE_ID := you@example.com`) or pass on the CLI. NEVER commit them.
# APPLE_ID — your Apple ID email
# APPLE_PASSWORD — an app-specific password (appleid.apple.com → App-Specific Passwords)
# APPLE_TEAM_ID — 3PNKDC6L42 (defaulted below)
# When all three are present, `tauri build` auto-notarizes + staples the bundle.
-include .signing.env
APPLE_ID ?=
APPLE_PASSWORD ?=
APPLE_TEAM_ID ?= 3PNKDC6L42
ifneq ($(strip $(APPLE_ID)),)
export APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID
endif
# ---- Gitea generic package registry (versioned .dmg downloads) ----
GITEA_URL ?= https://git.realmanual.ru
GITEA_OWNER ?= pub
GITEA_PKG ?= spacesh
GITEA_TOKEN ?= # token with package:write; pass via env/CLI, never commit
# ---- Prod deploy (SSH) ----
SSH_HOST ?= 192.168.8.5
SSH_USER ?= deploy
SSH_REMOTE_DIR ?= /opt/spacesh
SSH_USER ?= root
SSH_REMOTE_DIR ?= /srv/spaceshell
SSH_KEY ?= $(HOME)/.ssh/id_rsa
SSH_OPTS := -i $(SSH_KEY) -o StrictHostKeyChecking=accept-new
@@ -42,27 +77,42 @@ deps: ## install frontend deps (npm ci)
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 for BOTH the GUI (tauri.conf.json) and the daemon (workspace Cargo.toml)
@node scripts/bump_version.mjs
.PHONY: dmg
dmg: targets ## build the universal (Intel + Apple Silicon) .dmg — UNSIGNED
# Tauri's universal build compiles each arch separately and expects a sidecar
# named with THAT arch's triple; it lipo's them into the universal bundle and
# ships spaceshd inside spacesh.app/Contents/MacOS (else the GUI is offline).
dmg: bump targets ## bump version + build universal .dmg (signed; notarized if .signing.env set)
# Tauri's universal build needs BOTH the per-arch sidecars (resolved during each
# arch sub-build) AND a fat spaceshd-universal-apple-darwin (copied into the final
# bundle — Tauri does not lipo sidecars itself). spaceshd ships inside
# spacesh.app/Contents/MacOS else the GUI is offline.
cargo build --release -p spaceshd --target aarch64-apple-darwin
cargo build --release -p spaceshd --target x86_64-apple-darwin
rm -rf $(SIDECAR_DIR) && mkdir -p $(SIDECAR_DIR) # avoid stale sidecars poisoning the bundle
cp target/aarch64-apple-darwin/release/spaceshd $(SIDECAR_DIR)/spaceshd-aarch64-apple-darwin
cp target/x86_64-apple-darwin/release/spaceshd $(SIDECAR_DIR)/spaceshd-x86_64-apple-darwin
lipo -create -output $(SIDECAR_DIR)/spaceshd-universal-apple-darwin \
target/aarch64-apple-darwin/release/spaceshd \
target/x86_64-apple-darwin/release/spaceshd
cd $(APP_DIR) && npm run tauri build -- --target $(TAURI_TARGET) --config $(BUNDLE_CONFIG)
@echo "$(DMG_DIR)" && ls -lh $(DMG_DIR)/*.dmg
.PHONY: dmg-native
dmg-native: ## build a .dmg for the current arch only (faster)
dmg-native: bump ## bump version + build a .dmg for the current arch only (faster)
cargo build --release -p spaceshd
rm -rf $(SIDECAR_DIR) && mkdir -p $(SIDECAR_DIR) # avoid stale sidecars poisoning the bundle
cp target/release/spaceshd $(SIDECAR_DIR)/spaceshd-$(NATIVE_TRIPLE)
cd $(APP_DIR) && npm run tauri build -- --config $(BUNDLE_CONFIG)
@ls -lh $(NATIVE_DMG_DIR)/*.dmg
.PHONY: app-bundle
app-bundle: ## build just the native .app (no .dmg/hdiutil — fast, for self-install)
cargo build --release -p spaceshd
rm -rf $(SIDECAR_DIR) && mkdir -p $(SIDECAR_DIR)
cp target/release/spaceshd $(SIDECAR_DIR)/spaceshd-$(NATIVE_TRIPLE)
cd $(APP_DIR) && npm run tauri build -- --bundles app --config $(BUNDLE_CONFIG)
.PHONY: dev
dev: ## run the app in dev mode (tauri dev)
cd $(APP_DIR) && npm run tauri dev
@@ -78,19 +128,28 @@ kill-daemon: ## stop a running spaceshd so a freshly-built one takes over
.PHONY: install
install: kill-daemon ## install the native .app to /Applications, restart daemon, clear quarantine
rm -rf /Applications/spacesh.app
rm -rf /Applications/spacesh.app /Applications/spaceshell.app # drop the pre-rename app too
cp -R "$(NATIVE_APP_BUNDLE)" /Applications/
xattr -dr com.apple.quarantine /Applications/spacesh.app
@echo "Installed (native). Quit & relaunch spacesh; the bundled daemon restarts."
xattr -dr com.apple.quarantine /Applications/spaceshell.app
ifneq ($(strip $(SIGN_IDENTITY)),)
# Belt-and-suspenders: re-sign inside-out with the stable identity so neither the
# embedded daemon nor the app is left ad-hoc if Tauri skipped the sidecar.
codesign --force --options runtime --timestamp --entitlements "$(ENTITLEMENTS)" --sign "$(SIGN_IDENTITY)" /Applications/spaceshell.app/Contents/MacOS/spaceshd
codesign --force --options runtime --timestamp --entitlements "$(ENTITLEMENTS)" --sign "$(SIGN_IDENTITY)" /Applications/spaceshell.app
@codesign -dvv /Applications/spaceshell.app 2>&1 | grep -E "TeamIdentifier|Signature" || true
endif
@echo "Installed (native). Quit & relaunch spaceshell; the bundled daemon restarts."
@echo "Tip: on first launch grant Full Disk Access (System SettingsPrivacy & Security)"
@echo " so terminals inside the app can run tmutil / reach protected folders."
.PHONY: install-universal
install-universal: kill-daemon ## install the universal .app to /Applications
rm -rf /Applications/spacesh.app
rm -rf /Applications/spacesh.app /Applications/spaceshell.app
cp -R "$(APP_BUNDLE)" /Applications/
xattr -dr com.apple.quarantine /Applications/spacesh.app
xattr -dr com.apple.quarantine /Applications/spaceshell.app
.PHONY: reinstall
reinstall: dmg-native install ## native rebuild + reinstall + restart daemon (fast self-update)
reinstall: app-bundle install ## fast self-update: build .app (no dmg), reinstall, restart daemon
# ---- Tests ----
@@ -119,10 +178,32 @@ landing-push: landing-image ## tag & push the landing image to the registry
# ---- Prod deploy ----
.PHONY: deploy-dmg
deploy-dmg: dmg ## upload the universal .dmg to the prod download dir (stable spacesh.dmg)
deploy-dmg: dmg ## upload .dmg + manifest to prod, and publish the versioned .dmg to Gitea Packages
@VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \
printf '{"version":"%s","url":"https://spaceshell.ru/download/spacesh.dmg"}\n' "$$VER" > /tmp/spacesh-latest.json; \
echo "manifest version → $$VER"
ssh $(SSH_OPTS) $(SSH_USER)@$(SSH_HOST) "mkdir -p $(SSH_REMOTE_DIR)/download"
scp $(SSH_OPTS) $(DMG_DIR)/*.dmg "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/spacesh.dmg"
@echo "Uploaded → https://spaceshell.ru/download/spacesh.dmg"
scp $(SSH_OPTS) /tmp/spacesh-latest.json "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/latest.json"
@echo "Uploaded → https://spaceshell.ru/download/spacesh.dmg + latest.json"
@$(MAKE) --no-print-directory _publish-dmg
.PHONY: publish-dmg
publish-dmg: dmg _publish-dmg ## build + publish the versioned .dmg to the Gitea package registry
# Internal: upload the most recently built .dmg to Gitea's generic registry under
# the current version. No build dependency, so deploy-dmg can call it without a
# second bump/rebuild. Skips (doesn't fail) when GITEA_TOKEN is unset.
.PHONY: _publish-dmg
_publish-dmg:
@if [ -z "$(GITEA_TOKEN)" ]; then echo "GITEA_TOKEN unset — skipping Gitea Packages publish"; exit 0; fi; \
VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \
DMG=$$(ls -t $(DMG_DIR)/*.dmg 2>/dev/null | head -1); \
if [ -z "$$DMG" ]; then echo "no .dmg in $(DMG_DIR) — run make dmg first"; exit 1; fi; \
URL="$(GITEA_URL)/api/packages/$(GITEA_OWNER)/generic/$(GITEA_PKG)/$$VER/spaceshell-$$VER.dmg"; \
echo "Publishing $$DMG → $$URL"; \
curl --fail-with-body -sS -H "Authorization: token $(GITEA_TOKEN)" --upload-file "$$DMG" "$$URL" && \
echo "Published spaceshell-$$VER.dmg to Gitea Packages ($(GITEA_OWNER)/$(GITEA_PKG)@$$VER)"
.PHONY: deploy-stack
deploy-stack: ## sync compose+proxy.conf to prod and pull/up (manual; CI does this on push)
+163
View File
@@ -0,0 +1,163 @@
# spacesh — руководство пользователя
spacesh — терминал-воркспейс для параллельного запуска AI-агентов (Claude Code,
Codex, Gemini, opencode, shell) на macOS. Сессиями владеет фоновый демон, а не
окно: закрыл GUI, обновил приложение или словил краш — агенты продолжают
работать, а при следующем открытии экран восстанавливается из снапшота.
> Сборка и ручное тестирование описаны отдельно в [`RUNNING.md`](DOCS/RUNNING.md).
> Техническая спецификация — в [`MAIN.md`](DOCS/MAIN.md).
---
## Как это устроено
- **`spaceshd` (демон)** — единственный источник истины. Владеет живыми
PTY-сессиями, держит грид-модель экрана, слушает один Unix-socket
(`~/.spacesh/sock`), хранит конфиг и лог событий.
- **GUI (Tauri)** и **CLI `spacesh`** — тонкие клиенты. Состояния не хранят,
только шлют команды и подписываются на события. Клик в GUI и `spacesh focus`
из скрипта — одна и та же операция.
- GUI/CLI **поднимают демон лениво**, если он не запущен.
Следствие: GUI можно перезапускать и обновлять, не теряя сессии агентов.
---
## Установка
- **Готовый билд:** скачать `.dmg`, перетащить `spacesh.app` в `/Applications`.
Сборка пока без подписи — при первом запуске снять карантин:
```bash
xattr -dr com.apple.quarantine /Applications/spacesh.app
```
- **Из исходников** (нужны Rust + Node ≥ 20):
```bash
make reinstall # собирает .app, ставит в /Applications, перезапускает демон
```
Полный список целей — `make help`.
---
## Главное окно
![Главное окно spacesh](DOCS/images/app-overview.png)
Три зоны:
1. **Сайдбар (слева)** — список воркспейсов и групп. Кнопка **New workspace**
(⌘N). У каждого воркспейса слева — кольцо агрегированного статуса (см. ниже),
справа на наведении — звезда (избранное) и корзина (удаление).
- Сворачивается кнопкой-гамбургером в шапке: вместо полного списка остаётся
узкий **rail** со статус-кольцами — активность видна даже в свёрнутом виде.
2. **Сетка панелей (центр)** — терминалы агентов в выбранной раскладке.
3. **Центр событий (справа)** — лента уведомлений о сменах статуса и выходах.
---
## Воркспейсы
- **Создание:** New workspace (⌘N) → выбрать папку проекта, раскладку (пресет)
и агентов по слотам.
- **Переименование:** двойной клик по названию в сайдбаре → ввести новое →
Enter (Esc — отмена).
- **Избранное:** звезда на строке. Запиненные собираются в секцию **FAVORITES**
сверху.
- **Порядок:** перетаскивание строк внутри секции мышью.
- **Удаление:** корзина на строке → модалка с подтверждением. Если в воркспейсе
есть живые терминалы — предупреждение, что они будут завершены.
## Панели
- **Раскладки/пресеты:** `1`, `2↔`, `2↕`, `2+1`, `1+2`, `3`, `2×2`, `4`, `2×3`,
`2×4` — кнопки в тулбаре над сеткой.
- **Ресайз:** тянуть разделитель между панелями.
- **Перетаскивание:** взять панель за шапку (иконка‐грип) и бросить к краю
другой — переставляется местами/делится.
- **Зум:** иконка разворота в шапке панели — панель на весь грид; повторный клик
возвращает.
- **Перезапуск:** если процесс вышел, в панели кнопка **Restart**.
---
## Статусы агентов
Статус приходит **пушем** — от хуков агентов (`spacesh notify`), маркеров
OSC 133 для shell и паттернов как запасной вариант:
| Статус | Значение | Цвет |
|---------|-----------------------------------|----------|
| `work` | агент работает | синий |
| `wait` | ждёт ввода/лимита | жёлтый |
| `done` | задача завершена | зелёный |
| `error` | ошибка | красный |
| `idle` | простаивает | серый |
Статус виден кольцом в шапке панели и в сайдбаре (агрегат по воркспейсу).
### Центр событий
Лента справа. Вкладки **All / Unread / Errors**. Клик по записи — фокус на
нужной панели. Действия в шапке:
- **Mark all read** — пометить всё прочитанным.
- 🗑 (красная корзина) — очистить весь лог событий (на пустом списке неактивна).
---
## Поиск по скроллбэку
`⌘F` на сфокусированной панели открывает строку поиска **в её заголовке** —
видно, где именно ищем. Поиск инкрементальный (по мере ввода), с подсветкой и
счётчиком совпадений; Enter / Shift+Enter — следующее/предыдущее. Поиск
привязан к панели, на которой открыт, и не «уезжает» при переключении фокуса.
---
## Настройки
![Модалка настроек](DOCS/images/settings.png)
Шестерёнка в шапке. Всё хранится демоном в `~/.spacesh/config.toml` и
применяется на лету ко всем окнам. Закрыть — крестик справа сверху или Esc.
- **Terminal** — шрифт (курированный список) и размер (10–20).
- **Appearance** — тема **Dark / Light** и акцентный цвет.
- **Default shell** — shell для plain-панелей; пусто = автоопределение
(env `SPACESH_SHELL` → config → login-shell из passwd → `$SHELL` → `/bin/sh`).
- **Daemon** — версия, build-id, pid, uptime и кнопка **Restart**
(перезапускает демон; панели переподцепляются автоматически).
---
## CLI
`spacesh` — те же операции, что и в GUI, поверх той же шины:
```bash
spacesh status --json # состояние воркспейсов/панелей
spacesh focus s_8f3 # сфокусировать панель
spacesh open <path> # открыть/создать воркспейс
spacesh new-surface ... # добавить панель
spacesh notify ... # сообщить статус (для хуков агентов)
```
`$SPACESH_SURFACE_ID` инъектируется в окружение каждой панели — хуки знают, о
какой панели рапортуют.
---
## В планах
- Внешние уведомления в **Telegram** и **MAX** (подписчик на шине событий).
- Diff-просмотр изменений агента.
- Удалённая работа через SSH-туннель к демону.
---
## Где что лежит
- Конфиг: `~/.spacesh/config.toml`
- Socket: `~/.spacesh/sock`
- Lock: `~/.spacesh/daemon.lock`
- Состояние воркспейсов и лог событий: `~/.spacesh/` (персист демона)
+10
View File
@@ -11,6 +11,7 @@
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-notification": "^2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
@@ -1354,6 +1355,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
+1
View File
@@ -12,6 +12,7 @@
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-notification": "^2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
+406 -3
View File
@@ -453,6 +453,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.45"
@@ -534,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"
@@ -1246,8 +1263,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1257,9 +1276,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1537,6 +1558,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1955,6 +1992,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac-notification-sys"
version = "0.6.13"
@@ -2649,6 +2692,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -2785,6 +2883,44 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -2819,6 +2955,44 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -2847,12 +3021,53 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3043,6 +3258,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.21.0"
@@ -3221,12 +3448,16 @@ version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"core-foundation",
"core-text",
"dirs 5.0.1",
"reqwest 0.12.28",
"serde",
"serde_json",
"spacesh-proto",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-window-state",
"tokio",
@@ -3234,7 +3465,7 @@ dependencies = [
[[package]]
name = "spacesh-proto"
version = "0.1.0"
version = "0.1.30"
dependencies = [
"bytes",
"serde",
@@ -3280,6 +3511,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3432,7 +3669,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -3531,6 +3768,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
@@ -3824,6 +4103,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4143,6 +4432,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4379,6 +4674,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
@@ -4435,6 +4740,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4674,6 +4988,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -4683,6 +5006,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -4731,13 +5063,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -4774,6 +5123,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -4792,6 +5147,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -4810,12 +5171,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -4834,6 +5207,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -4852,6 +5231,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -4870,6 +5255,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -4888,6 +5279,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -5212,6 +5609,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.4"
+5
View File
@@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
tauri-plugin-notification = "2"
tauri-plugin-window-state = "2"
tauri-plugin-dialog = "2"
spacesh-proto = { path = "../../crates/spacesh-proto" }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
@@ -23,3 +24,7 @@ serde_json = "1"
base64 = "0.22"
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"
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- WKWebView's JavaScriptCore needs JIT + writable/executable memory under the
hardened runtime required for notarization. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- The bundle embeds the spaceshd sidecar (same Team) and loads system/webview
components; relax library validation so loading never trips the hardened runtime. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+4 -1
View File
@@ -7,11 +7,14 @@
"core:default",
"core:event:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:app:default",
"core:resources:default",
"core:menu:default",
"core:tray:default",
"notification:default",
"window-state:default"
"window-state:default",
"dialog:allow-open"
]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capability for spacesh app","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:default","window-state:default"]}}
{"default":{"identifier":"default","description":"Default capability for spacesh app","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-start-dragging","core:window:allow-start-resize-dragging","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:default","window-state:default","dialog:allow-open"]}}
@@ -2192,6 +2192,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
@@ -2192,6 +2192,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
+185 -22
View File
@@ -78,6 +78,13 @@ fn find_daemon() -> PathBuf {
PathBuf::from("spaceshd") // last resort: rely on PATH
}
/// The installed `spaceshd` binary's mtime as ms since the epoch (for staleness check).
fn daemon_bin_mtime_ms() -> Option<u64> {
let meta = std::fs::metadata(find_daemon()).ok()?;
let mtime = meta.modified().ok()?;
Some(mtime.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as u64)
}
async fn ensure_daemon(sock: &PathBuf) -> Result<UnixStream> {
if let Ok(s) = UnixStream::connect(sock).await {
return Ok(s);
@@ -131,33 +138,56 @@ impl Bridge {
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)
}
/// If a previously-running daemon is a different build than the one bundled
/// with this GUI (the daemon outlives the GUI, so an old one can still be
/// serving the socket), restart it so our matching daemon takes over. No-op
/// for unstamped dev builds or when the daemon is too old to report a build.
/// 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 want = option_env!("SPACESH_BUILD").unwrap_or("dev");
if want == "dev" {
return;
}
let got = match self.request(Cmd::Health).await {
Ok(env) => data_of(env)
.ok()
.and_then(|v| v.get("build").and_then(|b| b.as_str()).map(str::to_string))
.unwrap_or_default(),
Err(_) => return,
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),
};
if got.is_empty() || got == want {
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;
}
// Stale daemon — stop it and respawn our bundled one (matching code).
// 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.request(Cmd::Shutdown).await; // daemon exits; reply may not arrive
let _ = self.reconnect(seen).await; // ensure_daemon respawns the bundled daemon
let _ = self.reconnect(seen).await;
}
/// Send a command without awaiting a reply or retrying. Used for Shutdown:
/// the daemon exits before its reply is flushed, so a normal request() would
/// time out and the reconnect-retry would respawn-and-reshutdown in a loop.
async fn fire(&self, cmd: Cmd) {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let tx = self.tx.lock().await.clone();
let _ = tx.send(Envelope::Req { id, cmd }).await;
}
/// Re-establish the daemon connection. Single-flight: callers pass the `gen`
@@ -366,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]
@@ -440,6 +470,115 @@ 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
/// SPACESH_UPDATE_URL for local testing against a staging server.
const DEFAULT_UPDATE_URL: &str = "https://spaceshell.ru/download/latest.json";
#[derive(serde::Serialize)]
pub struct UpdateInfo {
current: String,
latest: String,
has_update: bool,
url: String,
}
/// Parse a `major.minor.patch` string (tolerating a leading `v` and a
/// `-prerelease` suffix) into a comparable tuple; missing parts are 0.
fn parse_ver(v: &str) -> (u64, u64, u64) {
let core = v.trim().trim_start_matches('v').split('-').next().unwrap_or("");
let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
(it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
}
/// Fetch the server manifest and compare against the bundled app version.
/// `current` is the Tauri package version (single source of truth: tauri.conf.json),
/// which `make dmg` bumps on every build.
#[tauri::command]
pub async fn check_update(app: AppHandle) -> Result<UpdateInfo, String> {
// The local version is always known; the server may be unreachable (no manifest
// yet, offline). Never fail the command for that — return the current version with
// an empty `latest` so the UI can show "couldn't check" instead of blanking out.
let current = app.package_info().version.to_string();
let fallback_url = "https://spaceshell.ru/download/spacesh.dmg".to_string();
let manifest_url =
std::env::var("SPACESH_UPDATE_URL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_UPDATE_URL.to_string());
let fetch = || async {
let client = reqwest::Client::builder().timeout(Duration::from_secs(10)).build()?;
client.get(&manifest_url).send().await?.error_for_status()?.json::<Value>().await
};
match fetch().await {
Ok(manifest) => {
let latest = manifest.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
let url = manifest.get("url").and_then(|v| v.as_str()).unwrap_or(&fallback_url).to_string();
let has_update = !latest.is_empty() && parse_ver(&latest) > parse_ver(&current);
Ok(UpdateInfo { current, latest, has_update, url })
}
Err(_) => Ok(UpdateInfo { current, latest: String::new(), has_update: false, url: fallback_url }),
}
}
/// Open a URL in the default browser (macOS `open`). Used by the update popover's
/// download action — the GUI itself never streams the .dmg.
#[tauri::command]
pub fn open_external(url: String) -> Result<(), String> {
std::process::Command::new("open").arg(&url).spawn().map_err(|e| e.to_string())?;
Ok(())
}
/// Whether spaceshell.app has Full Disk Access. Terminals spawned inside the app
/// inherit its TCC grants (the app is their "responsible process"), so without FDA
/// commands like `tmutil` fail. Probe: reading the user's TCC database requires FDA —
/// a permission error means we lack it; success (or the file being absent) means we
/// have it.
#[tauri::command]
pub fn has_full_disk_access() -> bool {
let Some(home) = std::env::var_os("HOME") else { return false };
let probe = std::path::Path::new(&home).join("Library/Application Support/com.apple.TCC/TCC.db");
match std::fs::File::open(&probe) {
Ok(_) => true,
Err(e) => e.kind() == std::io::ErrorKind::NotFound,
}
}
/// Open System Settings → Privacy & Security → Full Disk Access so the user can add
/// spaceshell. macOS cannot grant this programmatically; we only deep-link the pane.
#[tauri::command]
pub fn open_full_disk_access_settings() -> Result<(), String> {
std::process::Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")
.spawn()
.map_err(|e| e.to_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]
@@ -455,11 +594,35 @@ pub async fn set_config(
font_size: Option<u16>,
theme: Option<String>,
accent: Option<String>,
background: Option<String>,
background_image: Option<String>,
log_shell_commands: Option<bool>,
) -> Result<Value, String> {
data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent }).await.map_err(|e| e.to_string())?)
data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent, background, background_image, log_shell_commands }).await.map_err(|e| e.to_string())?)
}
/// Read a local image file and return it as a `data:` URL for use as a CSS
/// background. Kept in the GUI bridge (not the daemon) so the bytes load straight
/// into the webview without crossing the socket.
#[tauri::command]
pub async fn read_image_data_url(path: String) -> Result<String, String> {
use base64::Engine as _;
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let mime = match std::path::Path::new(&path).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
_ => "application/octet-stream",
};
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(format!("data:{mime};base64,{b64}"))
}
#[tauri::command]
pub async fn shutdown_daemon(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::Shutdown).await.map_err(|e| e.to_string())?)
// Fire-and-forget: the daemon exits without a flushed reply, so awaiting one
// would time out and trigger a respawn-then-reshutdown loop.
state.fire(Cmd::Shutdown).await;
Ok(Value::Null)
}
+8
View File
@@ -8,6 +8,7 @@ pub fn run() {
// Persist + restore the window's size, position and maximized state across restarts.
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let handle = app.handle().clone();
// Connect the bridge on a tokio runtime, then manage it.
@@ -54,8 +55,15 @@ pub fn run() {
bridge::mark_read,
bridge::clear_events,
bridge::health,
bridge::which_agents,
bridge::check_update,
bridge::open_external,
bridge::has_full_disk_access,
bridge::open_full_disk_access_settings,
bridge::list_fonts,
bridge::get_config,
bridge::set_config,
bridge::read_image_data_url,
bridge::shutdown_daemon,
])
.run(tauri::generate_context!())
+4 -1
View File
@@ -1,6 +1,9 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"externalBin": ["bin/spaceshd"]
"externalBin": ["bin/spaceshd"],
"macOS": {
"entitlements": "Entitlements.plist"
}
}
}
+14 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "spacesh",
"version": "0.1.0",
"productName": "spaceshell",
"version": "0.1.30",
"identifier": "xyz.spacesh.app",
"build": {
"frontendDist": "../dist",
@@ -10,8 +10,18 @@
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [{ "title": "spacesh", "width": 1100, "height": 720 }],
"security": { "csp": null }
"windows": [
{
"title": "spaceshell",
"width": 1100,
"height": 720,
"titleBarStyle": "Overlay",
"hiddenTitle": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
+164 -21
View File
@@ -4,15 +4,19 @@ import { Sidebar } from "./Sidebar";
import { TopBar } from "./TopBar";
import { CenterToolbar } from "./CenterToolbar";
import { Wizard } from "./Wizard";
import { SurfacePicker } from "./SurfacePicker";
import { PRESETS } from "./PresetPicker";
import { ConfirmDelete } from "./ConfirmDelete";
import { Settings } from "./Settings";
import { EventCenter } from "./EventCenter";
import { maybeNotify } from "./notify";
import { COLORS, applyTheme, resolvePalette } from "./theme";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge";
import type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge";
import { COLORS, FONT, applyTheme, resolvePalette } from "./theme";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig, checkUpdate, splitSurface, closeSurfaceCmd, setZoom, readImageDataUrl, hasFullDiskAccess, openFullDiskAccessSettings } from "./socketBridge";
import type { EventRecord, DaemonHealth, ConfigView, UpdateInfo } from "./socketBridge";
import { leafIds } from "./layoutTypes";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
import { HOTKEYS, loadBindings, saveBindings, matches, hasModifier } from "./hotkeys";
import type { Bindings, HotkeyId } from "./hotkeys";
/** Read a boolean UI flag from localStorage, falling back to `def`. */
function loadFlag(key: string, def: boolean): boolean {
@@ -31,11 +35,15 @@ export function App() {
const [states, setStates] = useState<Record<string, SurfaceState>>({});
const [events, setEvents] = useState<EventRecord[]>([]);
const [wizard, setWizard] = useState(false);
// Pending additive preset awaiting the per-panel "what to open" choice.
const [pendingPreset, setPendingPreset] = useState<{ id: string; delta: number; base: number } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<WorkspaceView | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true));
const [health, setHealth] = useState<DaemonHealth | null>(null);
const [update, setUpdate] = useState<UpdateInfo | null>(null);
const [updateChecking, setUpdateChecking] = useState(false);
const [config, setConfigState] = useState<ConfigView | null>(null);
// Bumped when the daemon connection is re-established; used to remount the
// layout so terminals re-attach (snapshot + live stream) to the restarted daemon.
@@ -44,11 +52,20 @@ export function App() {
const [focusedId, setFocusedId] = useState<string | null>(null);
const [searchSurfaceId, setSearchSurfaceId] = useState<string | null>(null);
const [searchNonce, setSearchNonce] = useState(0);
const [bindings, setBindings] = useState<Bindings>(loadBindings);
// Full Disk Access: terminals inherit spaceshell's TCC grants, so without FDA
// tools like tmutil fail inside panels. Default true to avoid a flash before the probe.
const [fdaOk, setFdaOk] = useState(true);
const [fdaDismissed, setFdaDismissed] = useState(() => loadFlag("spacesh.fdaDismissed", false));
const activeRef = useRef<string | null>(null);
const effectiveFocusRef = useRef<string | null>(null);
const wsRef = useRef<WorkspaceView[]>([]);
const leavesRef = useRef<string[]>([]);
const modalOpenRef = useRef(false);
const bindingsRef = useRef<Bindings>(bindings);
activeRef.current = activeId;
wsRef.current = workspaces;
bindingsRef.current = bindings;
const seedEvents = useCallback(async () => {
const log = await getEventLog();
@@ -77,6 +94,13 @@ export function App() {
catch { setConnected(false); }
}, []);
const runUpdateCheck = useCallback(async () => {
setUpdateChecking(true);
try { setUpdate(await checkUpdate()); }
catch { /* offline / server unreachable — leave the last known result */ }
finally { setUpdateChecking(false); }
}, []);
const wsOf = (surfaceId: string): WorkspaceView | undefined =>
wsRef.current.find((w) => surfaceId in w.surfaces);
@@ -84,7 +108,7 @@ export function App() {
void refresh();
void seedEvents();
void loadHealth();
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
void getConfig().then((c) => { setConfigState(c); }).catch(() => {});
const unlisten = onDaemonEvent((evt) => {
if (evt.evt === "event") {
const rec = evt.data.record;
@@ -103,9 +127,7 @@ export function App() {
} else if (evt.evt === "exit") {
void refresh();
} else if (evt.evt === "config_changed") {
const c = evt.data.config;
setConfigState(c);
applyTheme(c.theme, c.accent);
setConfigState(evt.data.config);
} else {
void refresh();
}
@@ -115,7 +137,7 @@ export function App() {
void refresh();
void seedEvents();
void loadHealth();
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
void getConfig().then((c) => { setConfigState(c); }).catch(() => {});
});
const reconnected = onDaemonRawEvent("spacesh:reconnected", () => {
setConnected(true);
@@ -123,25 +145,94 @@ export function App() {
void refresh();
void seedEvents();
void loadHealth();
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
void getConfig().then((c) => { setConfigState(c); }).catch(() => {});
});
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); void reconnected.then((f) => f()); };
}, [refresh, seedEvents, loadHealth]);
// Cycle keyboard focus through the active workspace's panels.
const cycleFocus = useCallback((dir: 1 | -1) => {
const ls = leavesRef.current;
if (ls.length < 2) return;
const cur = effectiveFocusRef.current;
const i = Math.max(0, ls.indexOf(cur ?? ls[0]));
const next = ls[(i + dir + ls.length) % ls.length];
setFocusedId(next);
void focusSurface(next);
}, []);
// Scrollback search is meaningless on an agent surface: claude/codex/… run a
// full-screen TUI in the alternate buffer that repaints every frame, so match
// decorations are drawn and immediately clobbered (the counter updates but no
// highlight shows). Gate the search affordance off for agent panels.
const isAgentSurface = useCallback((id: string | null): boolean => {
if (!id) return false;
const w = wsRef.current.find((ws) => id in ws.surfaces);
return !!w?.surfaces[id]?.spec?.agent_label;
}, []);
// Probe Full Disk Access on launch and again whenever the window regains focus
// (so granting it in System Settings and returning clears the banner immediately).
useEffect(() => {
const recheck = () => { void hasFullDiskAccess().then(setFdaOk); };
recheck();
window.addEventListener("focus", recheck);
return () => window.removeEventListener("focus", recheck);
}, []);
// Action dispatch table for hotkeys. Reads live state through refs so the
// window listener can mount once.
const actions = useMemo<Record<HotkeyId, () => void>>(() => ({
newWorkspace: () => setWizard(true),
openSettings: () => { if (config) setSettingsOpen(true); },
toggleSidebar: () => setSidebarOpen((v) => !v),
toggleEvents: () => setEventsOpen((v) => !v),
splitRight: () => { const f = effectiveFocusRef.current; if (f) void splitSurface(f, "right"); },
splitDown: () => { const f = effectiveFocusRef.current; if (f) void splitSurface(f, "down"); },
closePanel: () => { const f = effectiveFocusRef.current; if (f) void closeSurfaceCmd(f); },
focusNext: () => cycleFocus(1),
focusPrev: () => cycleFocus(-1),
zoomToggle: () => {
const a = wsRef.current.find((w) => w.id === activeRef.current);
if (!a) return;
void setZoom(a.id, a.zoomed ? null : effectiveFocusRef.current);
},
search: () => {
const f = effectiveFocusRef.current;
if (f && !isAgentSurface(f)) { setSearchSurfaceId(f); setSearchNonce((n) => n + 1); }
},
}), [cycleFocus, config, isAgentSurface]);
const actionsRef = useRef(actions);
actionsRef.current = actions;
// Central hotkey listener (mounted once). Skips while a modal is open, and only
// ever swallows keys that carry a modifier so terminal input is never stolen.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
if (activeRef.current && effectiveFocusRef.current) {
if (modalOpenRef.current) return;
for (const h of HOTKEYS) {
const b = bindingsRef.current[h.id];
if (hasModifier(b) && matches(b, e)) {
e.preventDefault();
setSearchSurfaceId(effectiveFocusRef.current); // anchor to the focused panel
setSearchNonce((n) => n + 1);
e.stopPropagation(); // capture phase: keep the chord out of the focused terminal
actionsRef.current[h.id]();
return;
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// Capture phase so the chord is seen even when xterm has keyboard focus.
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, []);
// Update check: once on launch, then every 6h.
useEffect(() => {
void runUpdateCheck();
const id = setInterval(() => { void runUpdateCheck(); }, 6 * 60 * 60 * 1000);
return () => clearInterval(id);
}, [runUpdateCheck]);
useEffect(() => { saveFlag("spacesh.eventsOpen", eventsOpen); }, [eventsOpen]);
useEffect(() => { saveFlag("spacesh.sidebarOpen", sidebarOpen); }, [sidebarOpen]);
@@ -150,8 +241,29 @@ export function App() {
const leaves = active ? leafIds(active.layout) : [];
const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null;
effectiveFocusRef.current = effectiveFocus;
leavesRef.current = leaves;
modalOpenRef.current = wizard || settingsOpen || !!pendingPreset || !!deleteTarget;
const termPalette = useMemo(() => (config ? resolvePalette(config.theme, config.accent) : null), [config?.theme, config?.accent]);
// Apply theme + background fill whenever appearance changes. A custom image is
// loaded (file → data URL) before re-applying so the panel glass and root fill
// paint together. Centralized here so every config source (initial load,
// reconnect, config_changed) flows through one place.
const [bgImage, setBgImage] = useState<string | null>(null);
useEffect(() => {
if (!config) return;
let cancelled = false;
if (config.background === "custom" && config.background_image) {
void readImageDataUrl(config.background_image)
.then((url) => { if (!cancelled) { setBgImage(url); applyTheme(config.theme, config.accent, config.background, url); } })
.catch(() => { if (!cancelled) { setBgImage(null); applyTheme(config.theme, config.accent, "none", null); } });
} else {
setBgImage(null);
applyTheme(config.theme, config.accent, config.background, null);
}
return () => { cancelled = true; };
}, [config?.theme, config?.accent, config?.background, config?.background_image]); // eslint-disable-line react-hooks/exhaustive-deps
const termPalette = useMemo(() => (config ? resolvePalette(config.theme, config.accent, config.background) : null), [config?.theme, config?.accent, config?.background, bgImage]);
const termFont = useMemo(() => (config ? { family: config.font_family, size: config.font_size } : null), [config?.font_family, config?.font_size]);
function selectWorkspace(id: string) {
@@ -161,17 +273,36 @@ export function App() {
}
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} />
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.appBg }}>
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} update={update} updateChecking={updateChecking} onCheckUpdate={() => { void runUpdateCheck(); }} />
{!fdaOk && !fdaDismissed && (
<div style={{ position: "relative", zIndex: 25, display: "flex", alignItems: "center", gap: 12, padding: "8px 14px", background: "rgba(242,184,75,0.12)", borderBottom: `1px solid ${COLORS.stWait}`, fontFamily: FONT.ui, fontSize: 12, color: COLORS.textPrimary }}>
<span style={{ flex: 1 }}>
<b>Нет Full Disk Access.</b> Терминалы внутри spaceshell наследуют права приложения без него команды вроде <code style={{ fontFamily: FONT.mono }}>tmutil</code> и доступ к защищённым папкам падают. Добавьте spaceshell в System Settings Privacy &amp; Security Full Disk Access.
</span>
<button onClick={() => void openFullDiskAccessSettings()}
style={{ flex: "0 0 auto", padding: "5px 12px", background: COLORS.accent, color: COLORS.bgApp, border: "none", borderRadius: 7, fontSize: 12, fontWeight: 600, cursor: "pointer" }}>Открыть настройки</button>
<button onClick={() => void hasFullDiskAccess().then(setFdaOk)}
style={{ flex: "0 0 auto", padding: "5px 12px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12, cursor: "pointer" }}>Проверить снова</button>
<button onClick={() => { setFdaDismissed(true); saveFlag("spacesh.fdaDismissed", true); }} aria-label="Скрыть"
style={{ flex: "0 0 auto", padding: "5px 8px", background: "transparent", color: COLORS.textMuted, border: "none", borderRadius: 7, fontSize: 12, cursor: "pointer" }}></button>
</div>
)}
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
<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) return;
const target = PRESETS.find((x) => x.id === p)?.slots ?? leaves.length;
const delta = target - leaves.length;
if (delta <= 0) { void applyPreset(active.id, p, []); return; } // reshape only — no new panels
setPendingPreset({ id: p, delta, base: leaves.length });
}} onOpenSearch={() => { if (effectiveFocus && !isAgentSurface(effectiveFocus)) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} searchDisabled={isAgentSurface(effectiveFocus)} />
)}
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
{active
? <LayoutEngine key={connEpoch} workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} font={termFont} palette={termPalette} />
? <LayoutEngine key={connEpoch} workspaceId={active.id} workspaceName={active.name} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} font={termFont} palette={termPalette} />
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>}
</div>
</div>
@@ -184,8 +315,20 @@ export function App() {
/>
)}
</div>
{settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />}
{settingsOpen && config && <Settings config={config} health={health} bindings={bindings} onBindingsChange={(b) => { setBindings(b); saveBindings(b); }} onClose={() => setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />}
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
{pendingPreset && active && (
<SurfacePicker
count={pendingPreset.delta}
onCancel={() => setPendingPreset(null)}
onConfirm={(specs) => {
const padded = [...Array(pendingPreset.base).fill({}), ...specs]; // align to daemon's slots.get(existing.len()+j)
const wsId = active.id;
setPendingPreset(null);
void applyPreset(wsId, pendingPreset.id, padded).then(() => void refresh());
}}
/>
)}
{deleteTarget && (
<ConfirmDelete
name={deleteTarget.name}
+7 -6
View File
@@ -3,17 +3,18 @@ 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, searchDisabled = false }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void; paneCount: number; searchDisabled?: boolean }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
<PresetPicker selected={selected} onSelect={onSelect} />
<div style={{ position: "relative", zIndex: 20, display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, background: COLORS.elevatedGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
<PresetPicker selected={selected} onSelect={onSelect} minSlots={paneCount} />
<div style={{ flex: 1 }} />
<div
title="Search scrollback"
onClick={onOpenSearch}
title={searchDisabled ? "Поиск недоступен в панели с агентом (полноэкранный TUI)" : "Search scrollback"}
onClick={searchDisabled ? undefined : onOpenSearch}
style={{
display: "flex", alignItems: "center", gap: 6, height: 24, padding: "0 8px", borderRadius: 6,
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer",
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`,
cursor: searchDisabled ? "not-allowed" : "pointer", opacity: searchDisabled ? 0.4 : 1,
}}>
<Search size={12} color={COLORS.textMuted} />
<span style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.textMuted }}>Search scrollback</span>
+10 -3
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Check, Hourglass, X, Power, Send, MessageSquare, Trash2 } from "lucide-react";
import { Check, CheckCheck, Hourglass, X, Power, Send, MessageSquare, Trash2 } from "lucide-react";
import { COLORS, FONT } from "./theme";
import type { EventRecord } from "./socketBridge";
@@ -34,15 +34,22 @@ export function EventCenter({
onSelect: (surfaceId: string, id: number) => void;
}) {
const [tab, setTab] = useState<Tab>("all");
const unread = events.filter((e) => !e.read).length;
const shown = tab === "unread" ? events.filter((e) => !e.read)
: tab === "errors" ? events.filter((e) => e.kind === "error")
: events;
return (
<div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}>
<div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.sidebarGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}>
<div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
<span onClick={onMarkAllRead} style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.accent, cursor: "pointer", marginRight: 10 }}>Mark all read</span>
<span
title="Mark all read"
onClick={() => { if (unread) onMarkAllRead(); }}
style={{ display: "flex", marginRight: 10, cursor: unread ? "pointer" : "default", opacity: unread ? 1 : 0.4 }}
>
<CheckCheck size={15} color={COLORS.accent} aria-label="Mark all read" />
</span>
<span
title="Clear all events"
onClick={() => { if (events.length) onClear(); }}
+87 -29
View File
@@ -1,14 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react";
import { Maximize2, Minimize2, RotateCw, GripVertical, Play, X } 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, closeSurfaceCmd } from "./socketBridge";
interface Props {
workspaceId: string;
workspaceName: string;
layout: LayoutNode | null;
/** surface_id -> running flag, from the latest status/events. */
running: Record<string, boolean>;
@@ -36,13 +38,7 @@ function edgeAt(clientX: number, clientY: number, r: DOMRect): Edge {
return (Object.keys(d) as Edge[]).reduce((a, b) => (d[b] < d[a] ? b : a), "left");
}
/** Collapse an absolute cwd into a ~/<leaf> style label for the panel header. */
function shortPath(cwd: string): string {
const leaf = cwd.split("/").filter(Boolean).pop();
return leaf ? `~/${leaf}` : cwd;
}
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Props) {
export function LayoutEngine({ workspaceId, workspaceName, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Props) {
// Panel drag-to-reorder. Implemented with raw pointer events rather than the
// HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses.
const [drop, setDrop] = useState<DropTarget | null>(null);
@@ -80,7 +76,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f
if (!layout) {
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace apply a preset to add panels.</div>;
}
const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette };
const shared = { workspaceId, workspaceName, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette };
if (zoomed) {
return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
@@ -96,7 +92,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f
}
interface NodeProps {
workspaceId: string; node: LayoutNode; path: number[];
workspaceId: string; workspaceName: string; node: LayoutNode; path: number[];
running: Record<string, boolean>; states: Record<string, SurfaceState>;
surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void;
zoomed: string | null;
@@ -116,7 +112,45 @@ function Node({ node, path, ...rest }: NodeProps) {
return <SplitView split={node.split} path={path} {...rest} />;
}
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Omit<NodeProps, "node" | "path"> & { id: string }) {
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["term-bg"] ?? 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,
allowTransparency: true, // term-bg may be transparent under a background theme
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, workspaceName, 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;
@@ -126,7 +160,8 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
onMouseDown={() => onFocus(id)}
style={{
position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%",
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
background: "transparent",
borderRadius: 8, overflow: "hidden",
// Constant 2px border, color-only on focus. A width change (1px<->2px)
// would resize the inner content box, fire ResizeObserver -> fit -> PTY
// SIGWINCH, making zsh/powerlevel10k reprint its prompt on every focus
@@ -135,26 +170,45 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
boxSizing: "border-box",
}}
>
{inner}
{/* Glass fill + blur as a layer BEHIND the content. The terminal's transparent
cells show this through. Crucially the terminal canvas is NOT a descendant
of a backdrop-filter element — under WKWebView that clips/smears the WebGL
canvas (first-glyph clip at column 0, smearing on scroll). With "none" the
glass is the solid bg-panel so the classic look is unchanged. */}
<div style={{ position: "absolute", inset: 0, zIndex: 0, background: COLORS.panelGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, pointerEvents: "none" }} />
<div style={{ position: "relative", zIndex: 1, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
{inner}
</div>
{dropEdge && <DropIndicator edge={dropEdge} />}
</div>
);
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>
<button onClick={() => void closeSurfaceCmd(id)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
<X size={13} /> Close
</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>
);
@@ -168,12 +222,12 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
<div
onMouseDown={(e) => { onFocus(id); onStartPanelDrag(id, e); }}
title="Drag to move this panel"
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.bgElevated, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }}
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.elevatedGlass, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }}
>
<GripVertical size={13} color={COLORS.textMuted} />
<StatusRing state={state} running={true} />
<span style={{ fontFamily: FONT.mono, fontSize: 12, fontWeight: 600, color: COLORS.textPrimary }}>{agent}</span>
{spec?.cwd && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{shortPath(spec.cwd)}</span>}
{workspaceName && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{workspaceName}</span>}
<span style={{ flex: 1 }} />
<span style={{ display: "flex", alignItems: "center", height: 16, padding: "0 7px", borderRadius: 8, background: "#000", fontFamily: FONT.mono, fontSize: 10, fontWeight: 600, color: STATE_COLOR[state] }}>
{state}
@@ -183,9 +237,13 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, null); }} />
: <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom"
onMouseDown={(e) => { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />}
<X size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Close panel"
onMouseDown={(e) => { e.stopPropagation(); void closeSurfaceCmd(id); }}
onMouseEnter={(e) => { e.currentTarget.style.color = COLORS.stError; }}
onMouseLeave={(e) => { e.currentTarget.style.color = COLORS.textMuted; }} />
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<TerminalView key={id} surfaceId={id} font={font} palette={palette} />
<TerminalView key={id} surfaceId={id} font={font} palette={palette} focused={focused} />
</div>
{searchSurfaceId === id && (
<SearchBar surfaceId={id} reopenNonce={searchNonce} onClose={onCloseSearch} />
+4 -2
View File
@@ -13,10 +13,12 @@ export const PRESETS: { id: string; label: string; slots: number }[] = [
import { COLORS, FONT } from "./theme";
export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) {
// `minSlots` hides presets smaller than the current pane count — applying a preset
// only ever ADDS panes (never destroys running ones); shrink by closing panels.
export function PresetPicker({ selected, onSelect, minSlots = 0 }: { selected: string; onSelect: (id: string) => void; minSlots?: number }) {
return (
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{PRESETS.map((p) => {
{PRESETS.filter((p) => p.slots >= minSlots).map((p) => {
const on = p.id === selected;
return (
<button key={p.id} onClick={() => onSelect(p.id)}
+169 -10
View File
@@ -1,12 +1,78 @@
import { useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import { COLORS, FONT, ACCENTS } from "./theme";
import { setConfig, restartDaemon } from "./socketBridge";
import { useEffect, useMemo, useRef, useState } from "react";
import { X, Search, Check } from "lucide-react";
import { COLORS, FONT, ACCENTS, BACKGROUNDS, CUSTOM_BACKGROUND } from "./theme";
import { setConfig, restartDaemon, listFonts } from "./socketBridge";
import type { ConfigView, DaemonHealth } from "./socketBridge";
import { HOTKEYS, defaultBindings, eventBinding, formatBinding, hasModifier } from "./hotkeys";
import type { Bindings, HotkeyId } from "./hotkeys";
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"];
export function Settings({ config, health, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void; onReload: () => void }) {
/** 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, bindings, onBindingsChange, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; bindings: Bindings; onBindingsChange: (b: Bindings) => void; onClose: () => void; onReload: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { ref.current?.focus(); }, []);
@@ -17,6 +83,10 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
// Fix 3: controlled shell input — synced from config, committed on blur.
const [shellLocal, setShellLocal] = useState(config.default_shell);
useEffect(() => { setShellLocal(config.default_shell); }, [config.default_shell]);
// Custom background image path — committed on blur (switches background to "custom").
const [bgPathLocal, setBgPathLocal] = useState(config.background_image);
useEffect(() => { setBgPathLocal(config.background_image); }, [config.background_image]);
return (
<div onMouseDown={onClose} style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div ref={ref} tabIndex={-1} onMouseDown={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") onClose(); }}
@@ -30,10 +100,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}
@@ -60,16 +127,108 @@ export function Settings({ config, health, onClose, onReload }: { config: Config
))}
</div>
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Background</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginBottom: 10 }}>
{Object.entries(BACKGROUNDS).map(([id, bg]) => {
const selected = config.background === id;
return (
<button key={id} onClick={() => void setConfig({ background: id })} aria-label={bg.label} title={bg.label}
style={{ display: "flex", flexDirection: "column", gap: 4, padding: 0, cursor: "pointer", background: "transparent", border: "none" }}>
<div style={{ height: 40, borderRadius: 6, background: bg.swatch,
border: selected ? `2px solid ${COLORS.accent}` : `2px solid ${COLORS.borderSubtle}`, boxSizing: "border-box" }} />
<span style={{ fontSize: 10, color: selected ? COLORS.textPrimary : COLORS.textMuted, textAlign: "center", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{bg.label}</span>
</button>
);
})}
<button onClick={() => { if (bgPathLocal) void setConfig({ background: CUSTOM_BACKGROUND, background_image: bgPathLocal }); }}
aria-label="Custom image" title="Custom image"
style={{ display: "flex", flexDirection: "column", gap: 4, padding: 0, cursor: "pointer", background: "transparent", border: "none" }}>
<div style={{ height: 40, borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", color: COLORS.textMuted, fontSize: 10, background: COLORS.bgPanel,
border: config.background === CUSTOM_BACKGROUND ? `2px solid ${COLORS.accent}` : `2px solid ${COLORS.borderSubtle}`, boxSizing: "border-box" }}>Image</div>
<span style={{ fontSize: 10, color: config.background === CUSTOM_BACKGROUND ? COLORS.textPrimary : COLORS.textMuted, textAlign: "center" }}>Custom</span>
</button>
</div>
<input value={bgPathLocal} onChange={(e) => setBgPathLocal(e.target.value)}
onBlur={() => { if (bgPathLocal) void setConfig({ background: CUSTOM_BACKGROUND, background_image: bgPathLocal }); }}
placeholder="/path/to/wallpaper.png — image for the Custom theme"
style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8, fontSize: 12 }} />
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Default shell (empty = auto)</div>
<input value={shellLocal} onChange={(e) => setShellLocal(e.target.value)} onBlur={() => void setConfig({ default_shell: shellLocal })}
style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }} />
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Events</div>
<label style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18, cursor: "pointer" }}>
<button role="switch" aria-checked={config.log_shell_commands} onClick={() => void setConfig({ log_shell_commands: !config.log_shell_commands })}
style={{ position: "relative", width: 38, height: 22, flex: "0 0 auto", borderRadius: 11, border: "none", cursor: "pointer",
background: config.log_shell_commands ? COLORS.accent : COLORS.bgElevated, transition: "background 0.15s" }}>
<span style={{ position: "absolute", top: 2, left: config.log_shell_commands ? 18 : 2, width: 18, height: 18, borderRadius: "50%", background: "#fff", transition: "left 0.15s" }} />
</button>
<span style={{ fontSize: 13, color: COLORS.textPrimary }}>
Log shell commands
<span style={{ display: "block", fontSize: 11, color: COLORS.textMuted }}>Off: only agent activity is logged & notified. Status rings still update.</span>
</span>
</label>
<HotkeysSection bindings={bindings} onChange={onBindingsChange} />
<DaemonSection health={health} onReload={onReload} />
</div>
</div>
);
}
/** Hotkeys list with click-to-record rebinding. */
function HotkeysSection({ bindings, onChange }: { bindings: Bindings; onChange: (b: Bindings) => void }) {
const [recordingId, setRecordingId] = useState<HotkeyId | null>(null);
useEffect(() => {
if (!recordingId) return;
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") { setRecordingId(null); return; }
const b = eventBinding(e);
if (!b || !hasModifier(b)) return; // wait for a real chord with a modifier
onChange({ ...bindings, [recordingId]: b });
setRecordingId(null);
};
// Capture phase so we intercept before the modal's own key handling.
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [recordingId, bindings, onChange]);
const groups = ["Workspace", "Panel"] as const;
return (
<div style={{ marginTop: 8, paddingTop: 16, borderTop: `1px solid ${COLORS.borderSubtle}` }}>
<div style={{ display: "flex", alignItems: "center", marginBottom: 6 }}>
<span style={{ fontSize: 12, color: COLORS.textSecondary, flex: 1 }}>Hotkeys</span>
<button onClick={() => onChange(defaultBindings())}
style={{ fontSize: 11, color: COLORS.textMuted, background: "transparent", border: `1px solid ${COLORS.borderStrong}`, borderRadius: 6, padding: "3px 8px", cursor: "pointer" }}>
Reset
</button>
</div>
{groups.map((g) => (
<div key={g} style={{ marginBottom: 6 }}>
<div style={{ fontSize: 10, textTransform: "uppercase", letterSpacing: "0.05em", color: COLORS.textMuted, margin: "6px 0 2px" }}>{g}</div>
{HOTKEYS.filter((h) => h.group === g).map((h) => (
<div key={h.id} style={{ display: "flex", alignItems: "center", height: 28 }}>
<span style={{ flex: 1, fontSize: 13, color: COLORS.textPrimary }}>{h.label}</span>
<button onClick={() => setRecordingId(h.id)}
style={{ minWidth: 70, fontFamily: FONT.mono, fontSize: 12, padding: "3px 10px", borderRadius: 6, cursor: "pointer",
background: recordingId === h.id ? COLORS.accent : COLORS.bgPanel,
color: recordingId === h.id ? COLORS.bgApp : COLORS.textPrimary,
border: `1px solid ${recordingId === h.id ? COLORS.accent : COLORS.borderStrong}` }}>
{recordingId === h.id ? "Press keys…" : formatBinding(bindings[h.id])}
</button>
</div>
))}
</div>
))}
</div>
);
}
function fmtUptime(ms: number): string {
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
if (s < 60) return `${s}s`;
+2 -2
View File
@@ -192,7 +192,7 @@ export function Sidebar({
...ungrouped,
];
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: 48, flex: "0 0 48px", background: COLORS.bgSidebar, height: "100%", padding: "10px 0", boxSizing: "border-box", borderRight: `1px solid ${COLORS.borderSubtle}`, gap: 8 }}>
<div style={{ position: "relative", zIndex: 20, display: "flex", flexDirection: "column", alignItems: "center", width: 48, flex: "0 0 48px", background: COLORS.sidebarGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, height: "100%", padding: "10px 0", boxSizing: "border-box", borderRight: `1px solid ${COLORS.borderSubtle}`, gap: 8 }}>
<button onClick={onNew} title="New workspace"
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: 8, background: COLORS.bgElevated, border: `1px solid ${COLORS.borderStrong}`, color: COLORS.textPrimary, cursor: "pointer" }}>
<Plus size={15} />
@@ -216,7 +216,7 @@ export function Sidebar({
}
return (
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box" }}>
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.sidebarGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, height: "100%", padding: 14, boxSizing: "border-box" }}>
<button onClick={onNew}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: 8, width: "100%", height: 34, marginBottom: 16,
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { whichAgents } from "./socketBridge";
import { KNOWN_AGENTS, SHELL, CUSTOM, agentLabel, specForChoice } from "./agents";
type SlotSpec = { command?: string; args?: string[] };
/**
* Asks what to open in each new panel before a preset spawns it: Terminal
* (shell), one of the installed CLIs (claude/codex/gemini/deepseek), or a
* custom command. `count` is the number of new panels the preset will add.
*/
export function SurfacePicker({ count, onConfirm, onCancel }: { count: number; onConfirm: (specs: SlotSpec[]) => void; onCancel: () => void }) {
const [installed, setInstalled] = useState<string[]>([]);
const [choices, setChoices] = useState<string[]>([]);
const [customCmds, setCustomCmds] = useState<string[]>([]);
const choiceList = [SHELL, ...installed, CUSTOM];
useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []);
function confirm() {
const specs = Array.from({ length: count }, (_, i) => specForChoice(choices[i] ?? SHELL, customCmds[i] ?? ""));
onConfirm(specs);
}
function onKeyDown(e: React.KeyboardEvent) {
e.stopPropagation();
if (e.key === "Escape") { e.preventDefault(); onCancel(); }
else if (e.key === "Enter" && (e.target as HTMLElement).tagName !== "SELECT") { e.preventDefault(); confirm(); }
}
return (
<div
onMouseDown={onCancel}
style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}
>
<div
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={onKeyDown}
style={{ width: 420, background: "#0E1116", border: "1px solid #323C49", borderRadius: 14, padding: 24, color: "#E6EDF3" }}
>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 4 }}>{count > 1 ? `Open ${count} new panels` : "Open new panel"}</div>
<div style={{ fontSize: 12, color: "#8B97A6", marginBottom: 16 }}>Choose what to run in each new panel.</div>
<div style={{ display: "grid", gridTemplateColumns: count > 1 ? "1fr 1fr" : "1fr", gap: 8, marginBottom: 20 }}>
{Array.from({ length: count }, (_, i) => {
const val = choices[i] ?? SHELL;
return (
<div key={i} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<select value={val} onChange={(e) => setChoices((c) => { const n = [...c]; n[i] = e.target.value; return n; })}
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
{choiceList.map((c) => <option key={c} value={c}>{agentLabel(c)}</option>)}
</select>
{val === CUSTOM && (
<input value={customCmds[i] ?? ""} placeholder="e.g. npm run dev" autoFocus
onChange={(e) => setCustomCmds((c) => { const n = [...c]; n[i] = e.target.value; return n; })}
style={{ padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #4C8DFF", borderRadius: 6, fontFamily: "monospace", fontSize: 12 }} />
)}
</div>
);
})}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
<button onClick={onCancel} style={{ padding: "8px 16px" }}>Cancel</button>
<button onClick={confirm} style={{ padding: "8px 16px", background: "#4C8DFF", color: "#0A0D12", border: "none", borderRadius: 8, fontWeight: 700 }}>
Open
</button>
</div>
</div>
</div>
);
}
+56 -5
View File
@@ -9,20 +9,55 @@ import { registerSearch, unregisterSearch } from "./searchRegistry";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
// xterm.js auto-answers device queries (Device Attributes, cursor/status reports,
// OSC color queries, DECRPM mode reports) by emitting the reply through onData.
// But the daemon's alacritty grid is the authoritative emulator and already
// answers these on the PTY (see spacesh-core grid.rs `take_replies` →
// `write_input`). Forwarding xterm's duplicate — which arrives a full IPC
// roundtrip late — lands in the shell's input buffer after it stopped reading
// the reply, so the shell echoes it as literal escape gibberish and the prompt
// shifts. Drop these standalone reports; never the user's keystrokes/paste/mouse
// (mouse reports end in M/m and are real user input the program asked for).
function isDeviceReport(data: string): boolean {
if (data.charCodeAt(0) !== 0x1b) return false;
return (
/^\x1b\[[?>=]?[0-9;]*[cntR]$/.test(data) || // DA1/DA2 (c), DSR status (n), text-area/cell-size (t), cursor position (R)
/^\x1b\[\?[0-9;]*u$/.test(data) || // kitty keyboard QUERY reply (\x1b[?flags u) — NOT key input \x1b[<code>u
/^\x1b\[\?[0-9;]*\$[py]$/.test(data) || // DECRPM mode report
/^\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\)$/.test(data) || // OSC color / query reply (BEL- or ST-terminated)
/^\x1bP[\s\S]*\x1b\\$/.test(data) || // any DCS report (XTVERSION / DECRQSS / status string)
/^\x1b_[\s\S]*\x1b\\$/.test(data) // any APC report (kitty graphics)
);
}
// 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"],
background: p["term-bg"] ?? p["bg-panel"],
foreground: p["text-primary"],
cursor: p["text-primary"],
selectionBackground: p["search-match"],
};
}
export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null }) {
export function TerminalView({ surfaceId, font, palette, focused }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null; focused?: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const webglRef = useRef<WebglAddon | null>(null);
// A background theme makes term-bg fully transparent so the panel's glass fill
// shows through. allowTransparency is construction-time only, so it's part of the
// effect key to force a remount when it flips. WebGL stays on in both modes — the
// glass/blur lives on a sibling layer (see LayoutEngine), not an ancestor, so the
// WebGL canvas composites its transparent background without the WKWebView
// clipping/smearing artifacts that backdrop-filter ancestors cause.
const transparent = palette?.["term-bg"] === "rgba(0,0,0,0)";
useEffect(() => {
if (!ref.current) return;
@@ -30,11 +65,12 @@ 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,
allowProposedApi: true,
allowTransparency: transparent,
theme: palette ? xtermTheme(palette) : undefined,
});
termRef.current = term;
@@ -76,11 +112,18 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
// Input → daemon.
const inputDisposable = term.onData((data) => {
if (isDeviceReport(data)) return; // daemon answers the PTY authoritatively; xterm's dup arrives late and echoes
void sendInput(surfaceId, encoder.encode(data));
});
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));
@@ -104,7 +147,15 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string;
fitRef.current = null;
webglRef.current = null;
};
}, [surfaceId]); // eslint-disable-line react-hooks/exhaustive-deps
}, [surfaceId, transparent]); // eslint-disable-line react-hooks/exhaustive-deps
// Keyboard focus cycling (cmd+]/[) only changes the focusedId state — it never
// touches the DOM, so the new panel's xterm textarea stays unfocused and keys
// keep flowing to the old terminal. Mouse clicks don't hit this because the
// click lands on the textarea directly. Drive xterm focus from the prop.
useEffect(() => {
if (focused) termRef.current?.focus();
}, [focused]);
// Live re-apply font and theme when config changes without remounting.
// font and palette are memoized in App.tsx so stable identity = no spurious re-applies.
@@ -112,7 +163,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
+105 -6
View File
@@ -1,7 +1,10 @@
import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown } from "lucide-react";
import { useState } from "react";
import { FolderGit2, PanelLeft, PanelRight, Bell, Settings, ChevronDown, CloudDownload, Download } from "lucide-react";
import { COLORS, FONT } from "./theme";
import type { WorkspaceView } from "./layoutTypes";
import { leafIds } from "./layoutTypes";
import { openExternal } from "./socketBridge";
import type { UpdateInfo } from "./socketBridge";
/** Human-readable descriptor of the active workspace layout (mock until a real preset id is tracked). */
function describeLayout(w: WorkspaceView | null): string {
@@ -28,8 +31,85 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl
);
}
/** Update-check button (lights up when the server has a newer version) plus its popover. */
function UpdateControl({ update, checking, onCheck }: { update: UpdateInfo | null; checking: boolean; onCheck: () => void }) {
const [open, setOpen] = useState(false);
const hasUpdate = !!update?.has_update;
return (
<div style={{ position: "relative", display: "flex" }}>
<button
title={hasUpdate ? `Доступна версия ${update?.latest}` : "Проверить обновления"}
onClick={() => setOpen((v) => !v)}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: 26, height: 26, borderRadius: 6,
background: hasUpdate ? "rgba(52,211,194,0.15)" : open ? COLORS.bgElevated : "transparent",
border: `1px solid ${hasUpdate ? COLORS.accent : open ? COLORS.borderSubtle : "transparent"}`,
color: hasUpdate ? COLORS.accent : COLORS.textSecondary,
boxShadow: hasUpdate ? `0 0 10px rgba(52,211,194,0.5)` : "none",
animation: hasUpdate ? "spaceshPulse 2s ease-in-out infinite" : "none",
}}
>
<CloudDownload size={15} style={{ animation: checking ? "spaceshBlink 1s ease-in-out infinite" : "none" }} />
</button>
{open && (
<>
{/* click-away backdrop */}
<div onClick={() => setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 200 }} />
<div style={{
position: "absolute", top: 32, right: 0, zIndex: 201, width: 240,
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, borderRadius: 8,
padding: 12, boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
fontFamily: FONT.ui, color: COLORS.textPrimary,
}}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Обновление</div>
<div style={{ fontSize: 12, color: COLORS.textSecondary, display: "flex", justifyContent: "space-between" }}>
<span>Установлено</span><span style={{ fontFamily: FONT.mono }}>{update?.current ?? "—"}</span>
</div>
<div style={{ fontSize: 12, color: COLORS.textSecondary, display: "flex", justifyContent: "space-between", marginTop: 2 }}>
<span>На сервере</span><span style={{ fontFamily: FONT.mono }}>{update?.latest || "—"}</span>
</div>
{hasUpdate ? (
<button
onClick={() => { void openExternal(update!.url); setOpen(false); }}
style={{
marginTop: 10, width: "100%", height: 30, borderRadius: 6, border: "none", cursor: "pointer",
background: COLORS.accent, color: COLORS.bgApp, fontFamily: FONT.ui, fontSize: 13, fontWeight: 600,
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
}}
>
<Download size={14} /> Скачать {update?.latest}
</button>
) : update?.latest ? (
<div style={{ marginTop: 10, fontSize: 12, color: COLORS.stDone }}>Установлена последняя версия</div>
) : (
<div style={{ marginTop: 10, fontSize: 12, color: COLORS.textMuted }}>Не удалось проверить сервер</div>
)}
<button
onClick={onCheck}
disabled={checking}
style={{
marginTop: 8, width: "100%", height: 28, borderRadius: 6, cursor: checking ? "default" : "pointer",
background: "transparent", border: `1px solid ${COLORS.borderStrong}`, color: COLORS.textSecondary,
fontFamily: FONT.ui, fontSize: 12,
}}
>
{checking ? "Проверяю…" : "Проверить снова"}
</button>
</div>
</>
)}
</div>
);
}
export function TopBar({
active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread, onOpenSettings,
update, updateChecking, onCheckUpdate,
}: {
active: WorkspaceView | null;
eventsOpen: boolean;
@@ -39,23 +119,37 @@ export function TopBar({
onToggleSidebar: () => void;
unread: number;
onOpenSettings: () => void;
update: UpdateInfo | null;
updateChecking: boolean;
onCheckUpdate: () => void;
}) {
return (
<div
// Native titlebar is a transparent Overlay (see tauri.conf.json), so the
// theme fill flows up to the very top and this bar IS the titlebar. Make it
// the OS drag region; child buttons keep their own clicks (Tauri only starts
// a drag on mousedown landing on the drag-region element itself).
data-tauri-drag-region
style={{
display: "flex", alignItems: "center", height: 40, flex: "0 0 40px",
padding: "0 14px", gap: 12, background: COLORS.bgApp,
// Left pad clears the macOS traffic lights overlaid in this strip.
padding: "0 14px 0 78px", gap: 12, background: COLORS.elevatedGlass,
backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur,
borderBottom: `1px solid ${COLORS.borderSubtle}`,
// The glass panels below use backdrop-filter, which creates stacking
// contexts that otherwise paint over this bar's popovers (update/bell).
// Lift the whole bar into its own context above the panel grid.
position: "relative", zIndex: 30,
}}
>
{/* Left: sidebar toggle, flush to the left edge. */}
<IconBtn icon={<PanelLeft size={15} />} onClick={onToggleSidebar} active={sidebarOpen} title="Toggle Sidebar" />
{/* Workspace breadcrumb */}
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<div data-tauri-drag-region style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<FolderGit2 size={15} color={COLORS.textSecondary} />
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 600, color: COLORS.textPrimary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{active?.name ?? "spacesh"}
{active?.name ?? "spaceshell"}
</span>
{active && (
<>
@@ -67,11 +161,16 @@ export function TopBar({
)}
</div>
<div style={{ flex: 1 }} />
<div data-tauri-drag-region style={{ flex: 1, alignSelf: "stretch" }} />
<style>{`
@keyframes spaceshBlink { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
@keyframes spaceshPulse { 0%,100% { box-shadow: 0 0 6px rgba(52,211,194,0.35); } 50% { box-shadow: 0 0 14px rgba(52,211,194,0.7); } }
`}</style>
{/* Right cluster */}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<IconBtn icon={<Search size={16} />} title="Search (mock)" />
<UpdateControl update={update} checking={updateChecking} onCheck={onCheckUpdate} />
<div style={{ position: "relative", display: "flex" }}>
<IconBtn icon={<Bell size={16} />} onClick={onShowEvents} active={eventsOpen} title="Open activity log" />
{unread > 0 && (
+46 -13
View File
@@ -1,16 +1,20 @@
import { useEffect, useRef, useState } from "react";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { PresetPicker, PRESETS } from "./PresetPicker";
import { openWorkspace, applyPreset } from "./socketBridge";
import { openWorkspace, applyPreset, whichAgents } from "./socketBridge";
import { KNOWN_AGENTS, SHELL, CUSTOM, agentLabel, specForChoice } from "./agents";
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,16 +23,32 @@ 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(() => {}); }, []);
// Native folder picker — fills the path field with the chosen absolute directory.
async function browse() {
try {
const picked = await openDialog({ directory: true, multiple: false, title: "Select project folder" });
if (typeof picked === "string") setPath(picked);
} catch { /* user cancelled or dialog unavailable */ }
}
// Close on Escape regardless of which element holds focus (the inner div's
// onKeyDown misses it once focus moves to a preset button / select).
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onCancel(); } };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
async function create() {
if (busy) return;
setBusy(true);
setError(null);
try {
const ws = await openWorkspace(path);
const slotSpecs = Array.from({ length: slots }, (_, i) => {
const a = agents[i] ?? "shell";
return a === "shell" ? {} : { command: a };
});
const slotSpecs = Array.from({ length: slots }, (_, i) => specForChoice(agents[i] ?? SHELL, customCmds[i] ?? ""));
await applyPreset(ws, preset, slotSpecs);
onDone(ws);
} catch (e) {
@@ -56,17 +76,30 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>New workspace</div>
<label style={{ fontSize: 12, color: "#8B97A6" }}>Project folder</label>
<input ref={pathRef} value={path} onChange={(e) => setPath(e.target.value)} style={{ width: "100%", margin: "6px 0 16px", padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
<div style={{ display: "flex", gap: 8, margin: "6px 0 16px" }}>
<input ref={pathRef} value={path} onChange={(e) => setPath(e.target.value)} style={{ flex: 1, minWidth: 0, padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
<button onClick={() => void browse()} style={{ flex: "0 0 auto", padding: "8px 14px", background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8, fontWeight: 600 }}>Browse</button>
</div>
<label style={{ fontSize: 12, color: "#8B97A6" }}>Layout</label>
<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}>{agentLabel(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 }}>
+22
View File
@@ -0,0 +1,22 @@
// Launchable agents/CLIs offered when opening a new panel. Only the installed
// ones are surfaced (probed via whichAgents); "shell" and "custom…" are always
// available. Keep this list as the single source of truth — Wizard and
// SurfacePicker both consume it.
export const KNOWN_AGENTS = ["claude", "codex", "gemini", "deepseek", "opencode"];
export const SHELL = "shell";
export const CUSTOM = "custom…";
/** Human label for an agent choice (the shell is presented as "Terminal"). */
export function agentLabel(choice: string): string {
return choice === SHELL ? "Terminal" : choice;
}
/** Map a picker choice (+ optional custom command line) to an applyPreset slot spec. */
export function specForChoice(choice: string, custom: string): { command?: string; args?: string[] } {
if (choice === SHELL) return {};
if (choice === CUSTOM) {
const parts = (custom ?? "").trim().split(/\s+/).filter(Boolean);
return parts.length ? { command: parts[0], args: parts.slice(1) } : {};
}
return { command: choice };
}
Binary file not shown.
+118
View File
@@ -0,0 +1,118 @@
// GUI keyboard shortcuts. Pure front-end concern (the daemon/CLI don't use these),
// so bindings live in localStorage and are user-rebindable from Settings.
//
// All defaults use ⌘ so they never collide with characters typed into a terminal
// (xterm receives plain keys; ⌘-combos are app shortcuts). The central handler in
// App.tsx requires at least one modifier before it will swallow a key.
export type HotkeyId =
| "newWorkspace"
| "openSettings"
| "toggleSidebar"
| "toggleEvents"
| "splitRight"
| "splitDown"
| "closePanel"
| "focusNext"
| "focusPrev"
| "zoomToggle"
| "search";
export interface Binding {
meta?: boolean;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
key: string; // normalized via normKey()
}
export interface HotkeyDef {
id: HotkeyId;
label: string;
group: "Workspace" | "Panel";
def: Binding;
}
export const HOTKEYS: HotkeyDef[] = [
{ id: "newWorkspace", label: "New workspace", group: "Workspace", def: { meta: true, key: "n" } },
{ id: "openSettings", label: "Open settings", group: "Workspace", def: { meta: true, key: "," } },
{ id: "toggleSidebar", label: "Toggle sidebar", group: "Workspace", def: { meta: true, key: "b" } },
{ id: "toggleEvents", label: "Toggle event center", group: "Workspace", def: { meta: true, key: "e" } },
{ id: "splitRight", label: "Split right", group: "Panel", def: { meta: true, key: "d" } },
{ id: "splitDown", label: "Split down", group: "Panel", def: { meta: true, shift: true, key: "d" } },
{ id: "closePanel", label: "Close panel", group: "Panel", def: { meta: true, key: "w" } },
{ id: "focusNext", label: "Focus next panel", group: "Panel", def: { meta: true, key: "]" } },
{ id: "focusPrev", label: "Focus previous panel", group: "Panel", def: { meta: true, key: "[" } },
{ id: "zoomToggle", label: "Toggle zoom", group: "Panel", def: { meta: true, key: "Enter" } },
{ id: "search", label: "Search scrollback", group: "Panel", def: { meta: true, key: "f" } },
];
const STORE_KEY = "spacesh.hotkeys";
export type Bindings = Record<HotkeyId, Binding>;
export function defaultBindings(): Bindings {
const out = {} as Bindings;
for (const h of HOTKEYS) out[h.id] = h.def;
return out;
}
export function loadBindings(): Bindings {
const out = defaultBindings();
try {
const raw = localStorage.getItem(STORE_KEY);
if (raw) {
const o = JSON.parse(raw) as Partial<Bindings>;
for (const h of HOTKEYS) if (o[h.id]) out[h.id] = o[h.id]!;
}
} catch { /* ignore */ }
return out;
}
export function saveBindings(b: Bindings): void {
try { localStorage.setItem(STORE_KEY, JSON.stringify(b)); } catch { /* ignore */ }
}
/** Stable key token: single chars lowercased, named keys kept verbatim. */
export function normKey(k: string): string {
if (k === " ") return "Space";
return k.length === 1 ? k.toLowerCase() : k;
}
const MOD_KEYS = new Set(["Meta", "Control", "Alt", "Shift"]);
/** Capture a binding from a keydown event (null while only modifiers are held). */
export function eventBinding(e: KeyboardEvent): Binding | null {
if (MOD_KEYS.has(e.key)) return null;
return { meta: e.metaKey, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, key: normKey(e.key) };
}
export function matches(b: Binding, e: KeyboardEvent): boolean {
return (
!!b.meta === e.metaKey &&
!!b.ctrl === e.ctrlKey &&
!!b.alt === e.altKey &&
!!b.shift === e.shiftKey &&
b.key === normKey(e.key)
);
}
export function hasModifier(b: Binding): boolean {
return !!(b.meta || b.ctrl || b.alt);
}
/** Human-readable chord, e.g. "⌘⇧D". */
export function formatBinding(b: Binding): string {
const parts: string[] = [];
if (b.ctrl) parts.push("⌃");
if (b.alt) parts.push("⌥");
if (b.shift) parts.push("⇧");
if (b.meta) parts.push("⌘");
const key =
b.key === "Enter" ? "⏎" :
b.key === "Space" ? "␣" :
b.key === "ArrowUp" ? "↑" : b.key === "ArrowDown" ? "↓" :
b.key === "ArrowLeft" ? "←" : b.key === "ArrowRight" ? "→" :
b.key.length === 1 ? b.key.toUpperCase() : b.key;
return parts.join("") + key;
}
+47 -3
View File
@@ -42,6 +42,9 @@ export interface AttachResult {
snapshot: string;
cols: number;
rows: number;
cursor_row?: number;
cursor_col?: number;
stopped?: boolean;
}
export async function attachSurface(
@@ -147,8 +150,8 @@ export async function applyPreset(workspaceId: string, presetId: string, slots:
return data.surface_ids;
}
export async function restartSurface(surfaceId: string): Promise<void> {
await invoke("restart_surface", { surfaceId });
export async function restartSurface(surfaceId: string, resume = false): Promise<void> {
await invoke("restart_surface", { surfaceId, resume });
}
export async function closeWorkspaceCmd(workspaceId: string): Promise<void> {
@@ -189,6 +192,36 @@ export async function getHealth(): Promise<DaemonHealth> {
return await invoke<DaemonHealth>("health");
}
export interface UpdateInfo { current: string; latest: string; has_update: boolean; url: string }
export async function checkUpdate(): Promise<UpdateInfo> {
return await invoke<UpdateInfo>("check_update");
}
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");
}
/** Whether spaceshell.app has Full Disk Access (terminals inherit its TCC grants). */
export async function hasFullDiskAccess(): Promise<boolean> {
try { return await invoke<boolean>("has_full_disk_access"); } catch { return true; }
}
/** Deep-link System Settings → Privacy & Security → Full Disk Access. */
export async function openFullDiskAccessSettings(): Promise<void> {
try { await invoke("open_full_disk_access_settings"); } catch { /* settings pane unavailable */ }
}
/** 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 });
}
@@ -201,22 +234,33 @@ export interface ConfigView {
font_size: number;
theme: "dark" | "light";
accent: string;
background: string;
background_image: string;
log_shell_commands: boolean;
}
export async function getConfig(): Promise<ConfigView> {
return await invoke<ConfigView>("get_config");
}
export async function setConfig(patch: Partial<Pick<ConfigView, "default_shell" | "font_family" | "font_size" | "theme" | "accent">>): Promise<void> {
export async function setConfig(patch: Partial<Pick<ConfigView, "default_shell" | "font_family" | "font_size" | "theme" | "accent" | "background" | "background_image" | "log_shell_commands">>): Promise<void> {
await invoke("set_config", {
defaultShell: patch.default_shell ?? null,
fontFamily: patch.font_family ?? null,
fontSize: patch.font_size ?? null,
theme: patch.theme ?? null,
accent: patch.accent ?? null,
background: patch.background ?? null,
backgroundImage: patch.background_image ?? null,
logShellCommands: patch.log_shell_commands ?? null,
});
}
/** Read a local image file into a `data:` URL for use as a CSS background. */
export async function readImageDataUrl(path: string): Promise<string> {
return await invoke<string>("read_image_data_url", { path });
}
export async function shutdownDaemon(): Promise<void> {
try { await invoke("shutdown_daemon"); } catch { /* connection drops as the daemon exits — expected */ }
}
+9
View File
@@ -1,3 +1,12 @@
/* Nerd Font symbols (icons, powerline, devicons) used as a fallback in the
terminal so glyphs in the Private Use Area render instead of blank boxes.
Base monospace font is untouched; this only fills missing glyphs. */
@font-face {
font-family: "Symbols Nerd Font Mono";
src: url("./assets/SymbolsNerdFontMono-Regular.ttf") format("truetype");
font-display: swap;
}
:root {
color-scheme: dark;
}
+95 -7
View File
@@ -7,6 +7,11 @@ export const COLORS = {
bgElevated: "var(--c-bg-elevated)",
bgHover: "var(--c-bg-hover)",
bgPanel: "var(--c-bg-panel)",
panelGlass: "var(--c-panel-glass, var(--c-bg-panel))",
panelBlur: "var(--c-panel-blur, none)",
appBg: "var(--app-bg, var(--c-bg-app))",
elevatedGlass: "var(--c-elevated-glass, var(--c-bg-elevated))",
sidebarGlass: "var(--c-sidebar-glass, var(--c-bg-sidebar))",
bgSidebar: "var(--c-bg-sidebar)",
borderStrong: "var(--c-border-strong)",
borderSubtle: "var(--c-border-subtle)",
@@ -92,15 +97,98 @@ export const ACCENTS: Record<string, string> = {
export type ThemeName = "dark" | "light";
/** Real color values for consumers that can't use var() (xterm). Keys are the kebab tokens plus "accent". */
export function resolvePalette(theme: ThemeName, accent: string): Record<string, string> {
const base = theme === "light" ? LIGHT : DARK;
return { ...base, accent: ACCENTS[accent] ?? ACCENTS.blue };
// ---------------------------------------------------------------------------
// Background themes (Warp-style full-window fills)
// ---------------------------------------------------------------------------
/**
* A background theme paints the WHOLE window behind every panel, instead of each
* terminal owning an opaque tile. Panels become semi-transparent glass (rgba over
* a backdrop blur) so the shared fill shows through uniformly across the grid.
*
* `css` is the CSS `background` value for the app root: a gradient, or `""` for
* the classic solid look ("none"), or the sentinel "custom" handled at apply time
* via a user-supplied image. `panelAlpha`/`blur` tune the glass over the fill.
*/
export interface BackgroundTheme {
label: string;
css: string; // app-root `background` value ("" = solid bg-app)
swatch: string; // gallery preview (gradient/color)
panelAlpha: number; // 0..1 — panel glass opacity over the fill
blur: number; // px — panel backdrop blur
}
/** Write the active palette to :root as --c-* custom properties. */
export function applyTheme(theme: ThemeName, accent: string): void {
const p = resolvePalette(theme, accent);
export const CUSTOM_BACKGROUND = "custom";
export const BACKGROUNDS: Record<string, BackgroundTheme> = {
none: { label: "None", css: "", swatch: DARK["bg-panel"], panelAlpha: 1, blur: 0 },
cyberwave: { label: "Cyber Wave", css: "linear-gradient(135deg,#06121f 0%,#0a2a3f 45%,#10183a 100%)", swatch: "linear-gradient(135deg,#06121f,#0a2a3f,#10183a)", panelAlpha: 0.46, blur: 9 },
phenomenon: { label: "Phenomenon", css: "radial-gradient(120% 120% at 80% 0%,#241a2e 0%,#15121d 45%,#0a0910 100%)", swatch: "radial-gradient(120% 120% at 80% 0%,#241a2e,#15121d,#0a0910)", panelAlpha: 0.5, blur: 8 },
dracula: { label: "Dracula", css: "linear-gradient(160deg,#282a36 0%,#21222c 60%,#1a1b23 100%)", swatch: "linear-gradient(160deg,#282a36,#21222c,#1a1b23)", panelAlpha: 0.58, blur: 6 },
aurora: { label: "Aurora", css: "linear-gradient(135deg,#0b3d2e 0%,#0a2c3a 40%,#241147 100%)", swatch: "linear-gradient(135deg,#0b3d2e,#0a2c3a,#241147)", panelAlpha: 0.44, blur: 10 },
ember: { label: "Ember", css: "linear-gradient(135deg,#2a0f12 0%,#3a1410 45%,#160a14 100%)", swatch: "linear-gradient(135deg,#2a0f12,#3a1410,#160a14)", panelAlpha: 0.5, blur: 8 },
referred: { label: "Referred", css: "linear-gradient(120deg,#b9c6ff 0%,#cdb6ff 40%,#ffd6e7 100%)", swatch: "linear-gradient(120deg,#b9c6ff,#cdb6ff,#ffd6e7)", panelAlpha: 0.34, blur: 12 },
};
/** Resolve a background name to its theme, falling back to "none". */
export function backgroundFor(name: string): BackgroundTheme {
return BACKGROUNDS[name] ?? BACKGROUNDS.none;
}
/** Hex (#rrggbb) → rgba() string at the given alpha. */
function hexToRgba(hex: string, alpha: number): string {
const h = hex.replace("#", "");
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
/**
* Real color values for consumers that can't use var() (xterm). Keys are the
* kebab tokens plus "accent" and "term-bg". When a background theme is active the
* terminal renders on transparent glass, so "term-bg" is fully transparent and
* the panel container supplies the visible tint.
*/
export function resolvePalette(theme: ThemeName, accent: string, background: string = "none"): Record<string, string> {
const base = theme === "light" ? LIGHT : DARK;
const active = background !== "none";
return {
...base,
accent: ACCENTS[accent] ?? ACCENTS.blue,
"term-bg": active ? "rgba(0,0,0,0)" : base["bg-panel"],
};
}
/**
* Write the active palette + background fill to :root as --c-* custom properties.
* `imageDataUrl` is only consulted when `background === "custom"`.
*/
export function applyTheme(theme: ThemeName, accent: string, background: string = "none", imageDataUrl: string | null = null): void {
const p = resolvePalette(theme, accent, background);
const root = document.documentElement.style;
for (const [k, v] of Object.entries(p)) root.setProperty(`--c-${k}`, v);
const bg = backgroundFor(background);
const base = theme === "light" ? LIGHT : DARK;
const active = background !== "none";
// App-root fill: custom image (cover) > gradient > solid bg-app.
const appBg = background === CUSTOM_BACKGROUND && imageDataUrl
? `center / cover no-repeat url("${imageDataUrl}")`
: bg.css || base["bg-app"];
root.setProperty("--app-bg", appBg);
// Panel glass: rgba(bg-panel, alpha) over the fill, plus optional backdrop blur.
// When inactive this is the solid bg-panel so the classic look is byte-identical.
const alpha = active ? (background === CUSTOM_BACKGROUND ? 0.5 : bg.panelAlpha) : 1;
const blur = active ? (background === CUSTOM_BACKGROUND ? 8 : bg.blur) : 0;
root.setProperty("--c-panel-glass", alpha < 1 ? hexToRgba(base["bg-panel"], alpha) : base["bg-panel"]);
root.setProperty("--c-panel-blur", blur > 0 ? `blur(${blur}px)` : "none");
// Chrome glass (TopBar / toolbar / sidebar / panel headers) — a touch more
// opaque than the panels so labels and controls stay legible over the fill.
const chromeAlpha = active ? Math.min(alpha + 0.22, 0.92) : 1;
root.setProperty("--c-elevated-glass", chromeAlpha < 1 ? hexToRgba(base["bg-elevated"], chromeAlpha) : base["bg-elevated"]);
root.setProperty("--c-sidebar-glass", chromeAlpha < 1 ? hexToRgba(base["bg-sidebar"], chromeAlpha) : base["bg-sidebar"]);
}
+5 -1
View File
@@ -38,7 +38,11 @@ pub enum Sub {
},
Close { surface_id: String },
Focus { surface_id: String },
Restart { surface_id: String },
Restart {
surface_id: String,
/// Relaunch the agent with its session-continue flag (e.g. claude --continue).
#[arg(long)] resume: bool,
},
Notify {
#[arg(long)] surface: String,
#[arg(long, value_enum)] state: StateArg,
+1 -1
View File
@@ -29,7 +29,7 @@ pub fn to_cmd(sub: Sub) -> Cmd {
},
Sub::Close { surface_id } => Cmd::Close { surface_id: SurfaceId(surface_id) },
Sub::Focus { surface_id } => Cmd::Focus { surface_id: SurfaceId(surface_id) },
Sub::Restart { surface_id } => Cmd::RestartSurface { surface_id: SurfaceId(surface_id) },
Sub::Restart { surface_id, resume } => Cmd::RestartSurface { surface_id: SurfaceId(surface_id), resume },
Sub::Notify { surface, state } => Cmd::SetState { surface_id: SurfaceId(surface), state: state_of(state) },
Sub::ApplyPreset { workspace_id, preset, agents } => Cmd::ApplyPreset {
workspace_id: WorkspaceId(workspace_id),
+3
View File
@@ -7,3 +7,6 @@ version.workspace = true
alacritty_terminal.workspace = true
serde.workspace = true
spacesh-proto = { path = "../spacesh-proto" }
[dev-dependencies]
serde_json.workspace = true
+100 -6
View File
@@ -1,8 +1,10 @@
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};
use alacritty_terminal::vte::ansi::Processor;
use alacritty_terminal::vte::ansi::{NamedColor, Processor, Rgb};
/// Fixed-size terminal dimensions for the daemon-side grid.
#[derive(Clone, Copy)]
@@ -23,24 +25,94 @@ impl Dimensions for GridSize {
}
}
/// One escape sequence the terminal model wants written back to the PTY in
/// response to a query: either a ready-made byte reply (DA/DSR/etc.) or a color
/// report whose value must be resolved from the term's palette at drain time.
enum Reply {
Bytes(Vec<u8>),
Color(usize, Arc<dyn Fn(Rgb) -> String + Send + Sync>),
}
/// Collects the escape sequences the terminal model wants written back to the PTY
/// (Primary/Secondary Device Attributes, DSR cursor/status reports, OSC color
/// queries, etc.). Programs like fish, yazi and vim 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") or render with the wrong theme.
///
/// The daemon is the authoritative responder for the PTY — the GUI's xterm.js is
/// display-only and must NOT echo its own replies back (its duplicate arrives an
/// IPC roundtrip late and gets typed into the shell as literal gibberish).
#[derive(Clone, Default)]
pub struct ReplyCollector {
buf: Arc<Mutex<Vec<Reply>>>,
}
impl EventListener for ReplyCollector {
fn send_event(&self, event: Event) {
let reply = match event {
Event::PtyWrite(text) => Reply::Bytes(text.into_bytes()),
// OSC 10/11/12 color query — alacritty defers the value to the embedder
// (us) via a formatter; resolve it against the palette in take_replies.
Event::ColorRequest(index, fmt) => Reply::Color(index, fmt),
_ => return,
};
if let Ok(mut b) = self.buf.lock() {
b.push(reply);
}
}
}
/// Fallback palette colors when a program queries one the term has not had set
/// explicitly. Matches the GUI's default theme so OSC 11 (background) reports a
/// dark color and light/dark detection in TUIs stays correct.
fn default_color(index: usize) -> Rgb {
if index == NamedColor::Background as usize { Rgb { r: 0x0a, g: 0x0d, b: 0x12 } }
else if index == NamedColor::Foreground as usize { Rgb { r: 0xe6, g: 0xed, b: 0xf3 } }
else if index == NamedColor::Cursor as usize { Rgb { r: 0xe6, g: 0xed, b: 0xf3 } }
else { Rgb { r: 0x80, g: 0x80, b: 0x80 } }
}
/// 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> {
let replies = {
let Ok(mut b) = self.replies.buf.lock() else { return Vec::new(); };
std::mem::take(&mut *b)
};
let mut out = Vec::new();
for reply in replies {
match reply {
Reply::Bytes(bytes) => out.extend_from_slice(&bytes),
Reply::Color(index, fmt) => {
let rgb = self.term.colors()[index].unwrap_or_else(|| default_color(index));
out.extend_from_slice(fmt(rgb).as_bytes());
}
}
}
out
}
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 +128,7 @@ impl GridSurface {
self.term.grid()[point].c
}
pub fn term(&self) -> &Term<VoidListener> {
pub fn term(&self) -> &Term<ReplyCollector> {
&self.term
}
@@ -97,4 +169,26 @@ 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());
}
#[test]
fn osc_background_color_query_gets_a_reply() {
// yazi/vim send OSC 11 ("\x1b]11;?\x07") to detect the background color and
// block on the reply; the daemon must answer it authoritatively.
let mut g = GridSurface::new(20, 5);
g.feed(b"\x1b]11;?\x07");
let reply = String::from_utf8(g.take_replies()).unwrap();
assert!(reply.starts_with("\x1b]11;rgb:"), "expected an OSC 11 reply, got {reply:?}");
assert!(g.take_replies().is_empty());
}
}
+47
View File
@@ -72,6 +72,32 @@ pub fn remove_leaf(root: LayoutNode, target: &SurfaceId) -> Option<LayoutNode> {
}
}
/// Drop duplicate leaves, keeping the first (left-to-right) occurrence of each
/// surface id; collapses now-single-child splits. Returns None if empty.
///
/// Heals a tree corrupted by a duplicate surface id (e.g. an id re-minted after
/// a daemon restart before the counter fix), which otherwise renders the same
/// panel twice and confuses focus/search/output routing keyed by surface id.
pub fn dedupe_leaves(root: LayoutNode) -> Option<LayoutNode> {
let mut seen = std::collections::HashSet::new();
dedupe(root, &mut seen)
}
fn dedupe(node: LayoutNode, seen: &mut std::collections::HashSet<SurfaceId>) -> Option<LayoutNode> {
match node {
LayoutNode::Leaf { surface_id } => {
if seen.insert(surface_id.clone()) { Some(LayoutNode::Leaf { surface_id }) } else { None }
}
LayoutNode::Split { orient, children, .. } => {
let kept: Vec<LayoutNode> = children.into_iter().filter_map(|c| dedupe(c, seen)).collect();
match kept.len() {
0 => None,
1 => Some(kept.into_iter().next().unwrap()),
n => Some(LayoutNode::Split { orient, ratios: even(n), children: kept }),
}
}
}
}
/// Set ratios on the split node addressed by `path` (child indices from root).
/// Normalizes to sum 1.0 and clamps each to >= MIN_RATIO. Returns false if the
/// path is invalid or the length does not match the node's child count.
@@ -247,6 +273,27 @@ mod tests {
assert_eq!(leaves(&after), vec![sid("s_2"), sid("s_1")]);
}
#[test]
fn dedupe_removes_duplicate_leaf_keeping_first() {
// s_1 appears twice (the production corruption): heal to one occurrence.
let root = LayoutNode::Split {
orient: Orient::H, ratios: vec![1.0/3.0; 3],
children: vec![LayoutNode::leaf(sid("s_0")), LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_1"))],
};
let healed = dedupe_leaves(root).unwrap();
assert_eq!(leaves(&healed), vec![sid("s_0"), sid("s_1")]);
}
#[test]
fn dedupe_clean_tree_is_unchanged() {
let root = LayoutNode::Split {
orient: Orient::H, ratios: vec![0.5, 0.5],
children: vec![LayoutNode::leaf(sid("s_0")), LayoutNode::leaf(sid("s_1"))],
};
let out = dedupe_leaves(root.clone()).unwrap();
assert_eq!(leaves(&out), vec![sid("s_0"), sid("s_1")]);
}
#[test]
fn move_onto_self_is_noop() {
let root = LayoutNode::leaf(sid("s_1"));
+14 -2
View File
@@ -1,11 +1,11 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
use alacritty_terminal::index::Point;
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::vte::ansi::Color;
use crate::grid::GridSurface;
/// Serializable snapshot returned by `attach`.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Snapshot {
/// ANSI byte dump suitable for `xterm.write()`.
pub ansi: String,
@@ -120,6 +120,18 @@ mod tests {
assert_eq!(a.rows, 3);
}
#[test]
fn snapshot_round_trips_through_json() {
let mut g = GridSurface::new(20, 4);
g.feed(b"hello");
let snap = snapshot_ansi(&g);
let json = serde_json::to_string(&snap).unwrap();
let back: Snapshot = serde_json::from_str(&json).unwrap();
assert_eq!(back.ansi, snap.ansi);
assert_eq!((back.cols, back.rows), (snap.cols, snap.rows));
assert_eq!((back.cursor_row, back.cursor_col), (snap.cursor_row, snap.cursor_col));
}
#[test]
fn cursor_is_one_based_after_input() {
let mut g = GridSurface::new(10, 3);
+15
View File
@@ -8,6 +8,19 @@ pub struct ConfigView {
pub font_size: u16,
pub theme: String,
pub accent: String,
/// Background-theme name (Warp-style full-window fill). "none" = solid.
#[serde(default = "default_background")]
pub background: String,
/// Absolute path to a custom background image (used when background == "custom").
#[serde(default)]
pub background_image: String,
/// Whether shell-command status (OSC 133) is logged; agent activity always is.
#[serde(default)]
pub log_shell_commands: bool,
}
fn default_background() -> String {
"none".into()
}
#[cfg(test)]
@@ -18,6 +31,8 @@ mod tests {
let c = ConfigView {
default_shell: "/bin/zsh".into(), font_family: "JetBrains Mono".into(),
font_size: 13, theme: "dark".into(), accent: "blue".into(),
background: "none".into(), background_image: String::new(),
log_shell_commands: false,
};
let back: ConfigView = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(back, c);
+28 -1
View File
@@ -92,7 +92,11 @@ pub enum Cmd {
SetRatios { workspace_id: WorkspaceId, node_path: Vec<u32>, ratios: Vec<f32> },
MoveSurface { surface_id: SurfaceId, target_surface_id: SurfaceId, edge: Edge },
ApplyPreset { workspace_id: WorkspaceId, preset_id: String, slots: Vec<PresetSlot> },
RestartSurface { surface_id: SurfaceId },
RestartSurface {
surface_id: SurfaceId,
#[serde(default)]
resume: bool,
},
CloseWorkspace { workspace_id: WorkspaceId },
SetWorkspaceMeta {
workspace_id: WorkspaceId,
@@ -131,6 +135,8 @@ pub enum Cmd {
surface_id: Option<SurfaceId>,
},
Health,
/// Which of the given CLI candidates are actually installed on the spawn PATH.
WhichAgents { candidates: Vec<String> },
Status,
Shutdown,
GetConfig,
@@ -145,6 +151,12 @@ pub enum Cmd {
theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
accent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
background: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
background_image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
log_shell_commands: Option<bool>,
},
}
@@ -366,6 +378,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 } };
+8
View File
@@ -48,6 +48,14 @@ impl PtyHandle {
if !spec.env.iter().any(|(k, _)| k == "COLORTERM") {
cmd.env("COLORTERM", "truecolor");
}
// Guarantee a UTF-8 locale. A GUI/launchd-launched daemon often has no LANG,
// so a directly-spawned agent (e.g. `claude`, no shell to set it) renders
// wide/box-drawing glyphs as mojibake — visible in Claude Code's usage bar.
// An interactive shell sets LANG in its init, which is why it looks fine there.
// Respect any inherited or caller-provided value.
if !spec.env.iter().any(|(k, _)| k == "LANG") && std::env::var_os("LANG").is_none() {
cmd.env("LANG", "en_US.UTF-8");
}
for (k, v) in &spec.env {
cmd.env(k, v);
}
+182
View File
@@ -20,6 +20,26 @@ pub struct AppearanceConfig {
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<String>,
/// Background-theme name (Warp-style full-window fill). "none" = solid.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
/// Absolute path to a custom background image (used when background == "custom").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_image: 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)]
@@ -32,6 +52,15 @@ 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>,
/// Log/notify shell-command status (OSC 133 / fallback) in plain panels.
/// Off by default — only agent activity (claude/codex/… hooks) is logged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_shell_commands: Option<bool>,
}
impl Config {
@@ -43,9 +72,17 @@ impl Config {
font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20),
theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()),
accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()),
background: self.appearance.background.clone().unwrap_or_else(|| "none".into()),
background_image: self.appearance.background_image.clone().unwrap_or_default(),
log_shell_commands: self.log_shell_commands(),
}
}
/// Whether shell-command status events are logged (default false).
pub fn log_shell_commands(&self) -> bool {
self.log_shell_commands.unwrap_or(false)
}
/// Shell for a plain panel using THIS in-memory config
/// (env -> config -> passwd -> $SHELL -> /bin/sh).
pub fn resolved_shell(&self) -> String {
@@ -85,6 +122,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 +166,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 +354,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);
}
}
+38 -33
View File
@@ -18,35 +18,39 @@ fn dir_for(home: &PathBuf, sid: &SurfaceId) -> PathBuf {
home.join(".spacesh").join("hooks").join(&sid.0)
}
/// Build the settings.json contents wiring Stop/Notification/UserPromptSubmit
/// to `spacesh notify`. `spacesh_bin` is the absolute path to the CLI.
/// Our Stop/Notification/UserPromptSubmit hooks as a JSON value (the `hooks` object).
fn our_hooks(spacesh_bin: &str) -> serde_json::Value {
let entry = |state: &str| serde_json::json!({
"hooks": [{
"type": "command",
"command": format!("{spacesh_bin} notify --surface $SPACESH_SURFACE_ID --state {state}")
}]
});
serde_json::json!({
"Stop": [entry("done")],
"Notification": [entry("wait")],
"UserPromptSubmit": [entry("work")],
})
}
/// Our hooks as a standalone settings JSON string. Passed to `claude` via the
/// `--settings` flag so they layer ON TOP of the user's real config WITHOUT
/// relocating CLAUDE_CONFIG_DIR — which is the whole point: claude only reads the
/// macOS Keychain login (and onboarding state) for its DEFAULT config dir, so any
/// override left the agent "Not logged in". `--settings` keeps the default dir.
pub fn settings_json(spacesh_bin: &str) -> String {
let line = |state: &str| {
format!(
"{{\"hooks\":[{{\"type\":\"command\",\"command\":\"{spacesh_bin} notify --surface $SPACESH_SURFACE_ID --state {state}\"}}]}}"
)
};
format!(
"{{\"hooks\":{{\"Stop\":[{}],\"Notification\":[{}],\"UserPromptSubmit\":[{}]}}}}",
line("done"), line("wait"), line("work")
)
serde_json::to_string(&serde_json::json!({ "hooks": our_hooks(spacesh_bin) }))
.unwrap_or_else(|_| "{\"hooks\":{}}".to_string())
}
/// Prepare the per-surface hook config; return env pairs to merge into the spawn.
/// Best-effort: on any I/O error returns an empty vec (spawn proceeds without hooks).
pub fn prepare(sid: &SurfaceId, spacesh_bin: &str) -> Vec<(String, String)> {
let Some(home) = dirs::home_dir() else { return vec![] };
let dir = dir_for(&home, sid);
if std::fs::create_dir_all(&dir).is_err() {
return vec![];
}
if std::fs::write(dir.join("settings.json"), settings_json(spacesh_bin)).is_err() {
return vec![];
}
vec![("CLAUDE_CONFIG_DIR".to_string(), dir.to_string_lossy().to_string())]
/// Extra CLI args injecting our notify hooks into a spawned `claude`, leaving its
/// default config dir (Keychain auth + onboarding) untouched.
pub fn claude_settings_args(spacesh_bin: &str) -> Vec<String> {
vec!["--settings".to_string(), settings_json(spacesh_bin)]
}
/// Remove the per-surface hook dir (best-effort) on close.
/// Remove the legacy per-surface hook dir (best-effort) on close. No longer
/// written, but cleans up dirs left by older builds that used CLAUDE_CONFIG_DIR.
pub fn cleanup(sid: &SurfaceId) {
if let Some(home) = dirs::home_dir() {
let _ = std::fs::remove_dir_all(dir_for(&home, sid));
@@ -129,15 +133,16 @@ mod tests {
}
#[test]
fn prepare_writes_config_and_cleanup_removes_it() {
let sid = SurfaceId(format!("s_test_{}", std::process::id()));
let env = prepare(&sid, "/abs/spacesh");
assert_eq!(env.len(), 1);
assert_eq!(env[0].0, "CLAUDE_CONFIG_DIR");
let dir = std::path::PathBuf::from(&env[0].1);
assert!(dir.join("settings.json").exists());
cleanup(&sid);
assert!(!dir.exists());
fn claude_settings_args_pass_hooks_via_settings_flag() {
let args = claude_settings_args("/abs/spacesh");
assert_eq!(args.len(), 2);
assert_eq!(args[0], "--settings");
// The second arg is valid JSON carrying all three hook events.
let v: serde_json::Value = serde_json::from_str(&args[1]).unwrap();
assert!(v["hooks"]["Stop"].is_array());
assert!(args[1].contains("/abs/spacesh notify --surface $SPACESH_SURFACE_ID --state done"));
assert!(args[1].contains("--state wait"));
assert!(args[1].contains("--state work"));
}
#[test]
+5 -1
View File
@@ -7,6 +7,7 @@ mod lifecycle;
mod persist;
mod registry;
mod server;
mod snapshot_store;
mod state_store;
mod surface;
@@ -60,6 +61,9 @@ async fn run_daemon() -> Result<()> {
let events_path = lifecycle::spacesh_dir()?.join("events.json");
let event_store: std::sync::Arc<dyn event_store::EventStore> =
std::sync::Arc::new(event_store::JsonEventStore::new(events_path));
let snapshots_dir = lifecycle::spacesh_dir()?.join("snapshots");
let snapshot_store: std::sync::Arc<dyn snapshot_store::SnapshotStore> =
std::sync::Arc::new(snapshot_store::JsonSnapshotStore::new(snapshots_dir));
eprintln!("spaceshd listening on {}", sock.display());
server::serve(&sock, store, event_store).await
server::serve(&sock, store, event_store, snapshot_store).await
}
+77
View File
@@ -23,6 +23,12 @@ pub struct Registry {
states: HashMap<SurfaceId, SurfaceState>,
}
/// Parse the hex numeric suffix of an id (`"s_1f"` → `0x1f`). None if malformed.
/// All ids are minted as `format!("{prefix}_{n:x}")`, so the suffix is hex.
fn id_num(id: &str) -> Option<u64> {
id.rsplit_once('_').and_then(|(_, hex)| u64::from_str_radix(hex, 16).ok())
}
impl Registry {
pub fn new() -> Self {
Self::default()
@@ -114,6 +120,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 ----
@@ -189,8 +199,29 @@ impl Registry {
self.by_path.clear();
self.live.clear();
self.states.clear();
// Advance the id counter past every restored id. The in-memory counter
// resets to 0 on each daemon start; without this reseed, after a restart
// `new_surface_id()` re-mints ids that already exist — producing duplicate
// leaves in a workspace tree (same panel rendered twice, focus/search/
// output routing keyed by surface id all break) and cross-workspace id
// collisions.
let mut max_id = 0u64;
for gid in self.groups.keys() {
if let Some(n) = id_num(&gid.0) { max_id = max_id.max(n + 1); }
}
for w in &state.workspaces {
if let Some(n) = id_num(&w.id.0) { max_id = max_id.max(n + 1); }
for sid in w.surfaces.keys() {
if let Some(n) = id_num(&sid.0) { max_id = max_id.max(n + 1); }
}
}
self.counter.store(max_id, Ordering::Relaxed);
for w in state.workspaces {
let mut w = w;
// Heal a tree already corrupted by a duplicate leaf (pre-fix state).
w.layout = w.layout.take().and_then(spacesh_core::ops::dedupe_leaves);
if let Some(z) = &w.zoomed {
if !w.surfaces.contains_key(z) { w.zoomed = None; }
}
@@ -248,6 +279,52 @@ mod tests {
assert_eq!(w.layout, Some(LN::leaf(s1))); // split collapsed
}
#[test]
fn restore_advances_counter_past_existing_ids() {
// After a daemon restart the counter must not re-mint a restored id.
let mut r = Registry::new();
let mut surfaces = HashMap::new();
surfaces.insert(SurfaceId("s_5".into()), spec());
let st = PersistState {
version: 1, groups: vec![],
workspaces: vec![Workspace {
id: WorkspaceId("w_2".into()), path: "/p".into(), name: "p".into(),
group_id: None, order: 0, unread: false, pinned: false,
layout: Some(LN::leaf(SurfaceId("s_5".into()))), zoomed: None, surfaces,
}],
};
r.restore(st);
// max restored id is s_5 (hex 5) → next minted must be s_6, no collision.
assert_eq!(r.new_surface_id(), SurfaceId("s_6".into()));
}
#[test]
fn restore_heals_duplicate_leaf() {
// A persisted tree with s_1 twice (the production corruption) heals to one.
let mut r = Registry::new();
let mut surfaces = HashMap::new();
surfaces.insert(SurfaceId("s_0".into()), spec());
surfaces.insert(SurfaceId("s_1".into()), spec());
let tree = LN::Split {
orient: Orient::H, ratios: vec![1.0 / 3.0; 3],
children: vec![LN::leaf(SurfaceId("s_0".into())), LN::leaf(SurfaceId("s_1".into())), LN::leaf(SurfaceId("s_1".into()))],
};
let st = PersistState {
version: 1, groups: vec![],
workspaces: vec![Workspace {
id: WorkspaceId("w_0".into()), path: "/p".into(), name: "p".into(),
group_id: None, order: 0, unread: false, pinned: false,
layout: Some(tree), zoomed: None, surfaces,
}],
};
r.restore(st);
let w = r.workspace(&WorkspaceId("w_0".into())).unwrap();
assert_eq!(
spacesh_core::ops::leaves(w.layout.as_ref().unwrap()),
vec![SurfaceId("s_0".into()), SurfaceId("s_1".into())]
);
}
#[test]
fn restore_round_trips_through_persist_state() {
let mut r = Registry::new();
+239 -62
View File
@@ -13,6 +13,7 @@ use crate::event_log::EventLog;
use crate::event_store::{self, EventPersister, EventStore};
use crate::persist::{self, Persister};
use crate::registry::Registry;
use crate::snapshot_store::{SnapshotStore, SnapshotMsg, spawn_writer};
use crate::state_store::StateStore;
use crate::surface::{SurfaceMsg};
@@ -33,11 +34,13 @@ enum ServerMsg {
ClientDisconnected { client: ClientId },
/// A status change detected internally (OSC 133 / fallback) by a surface actor.
StateDetected { surface_id: SurfaceId, state: SurfaceState },
/// Periodic snapshot tick: ask all live surfaces for a snapshot and persist dirty ones.
SnapshotTick,
}
type ClientId = u64;
pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<dyn EventStore>) -> Result<()> {
pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<dyn EventStore>, snapshot_store: Arc<dyn SnapshotStore>) -> Result<()> {
let listener = UnixListener::bind(socket)?;
let (router_tx, router_rx) = mpsc::channel::<ServerMsg>(256);
@@ -58,6 +61,20 @@ pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<d
}
});
let snapshot_tx = spawn_writer(snapshot_store.clone());
// Periodic snapshot tick → router.
let tick_router = router_tx.clone();
let interval_secs = crate::config::Config::load().snapshot_interval_secs();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(interval_secs));
tick.tick().await; // consume the immediate first tick
loop {
tick.tick().await;
if tick_router.send(ServerMsg::SnapshotTick).await.is_err() { break; }
}
});
let persister = persist::spawn(store.clone(), Duration::from_millis(500));
let initial = store.load().unwrap_or_default();
let event_persister = event_store::spawn(event_store.clone(), Duration::from_millis(500));
@@ -66,7 +83,7 @@ pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<d
let shutdown = tokio::spawn(router(
router_rx, router_tx.clone(), exit_tx, state_tx,
persister, initial, event_persister, event_initial,
started_at_ms,
started_at_ms, snapshot_store, snapshot_tx,
));
let mut next_client: ClientId = 0;
@@ -128,6 +145,8 @@ async fn router(
event_persister: EventPersister,
event_initial: crate::event_log::EventLogState,
started_at_ms: u64,
snapshot_store: Arc<dyn SnapshotStore>,
snapshot_tx: mpsc::UnboundedSender<SnapshotMsg>,
) {
let mut reg = Registry::new();
reg.restore(initial);
@@ -170,15 +189,34 @@ async fn router(
if reg.is_running(&surface_id) {
reg.set_state(&surface_id, state);
broadcast_evt(&clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state }));
if let Some(kind) = kind_for_state(state) {
record_event(&reg, &mut event_log, &event_persister, &clients, &surface_id, kind);
// StateDetected is the shell path (OSC 133 / fallback scanner). Off by
// default it stays a live status ring only — no log entry, no notification.
// Agent activity flows through Cmd::SetState (hooks) and is always logged.
if config.log_shell_commands() {
if let Some(kind) = kind_for_state(state) {
record_event(&reg, &mut event_log, &event_persister, &clients, &surface_id, kind);
}
}
}
}
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,15 +279,39 @@ 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 env = crate::hooks::prepare(sid, &crate::hooks::spacesh_bin());
let active = !env.is_empty();
(env, active)
let (mut env, active) = if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
// Hooks are injected as `--settings` CLI args at spawn (see spawn_from_spec),
// not via env — that keeps claude on its default config dir so Keychain login
// and onboarding survive. The agent still has a deterministic hook source.
(vec![], true)
} else if crate::hooks::is_zsh(&spec.command) {
(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 +340,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 +370,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 +402,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 +459,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 +523,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 +613,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 +650,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 +705,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 +738,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);
}
@@ -667,7 +762,7 @@ async fn handle_request(
}
}
Cmd::SetConfig { default_shell, font_family, font_size, theme, accent } => {
Cmd::SetConfig { default_shell, font_family, font_size, theme, accent, background, background_image, log_shell_commands } => {
if let Some(v) = &theme {
if v != "dark" && v != "light" { let _ = out.send(err(id, "BAD_CONFIG", "theme")).await; return; }
}
@@ -675,12 +770,19 @@ async fn handle_request(
const ACCENTS: [&str; 5] = ["blue", "teal", "purple", "green", "orange"];
if !ACCENTS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "accent")).await; return; }
}
if let Some(v) = &background {
const BACKGROUNDS: [&str; 8] = ["none", "cyberwave", "phenomenon", "dracula", "aurora", "ember", "referred", "custom"];
if !BACKGROUNDS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "background")).await; return; }
}
let mut next = config.clone();
if let Some(v) = default_shell { next.default_shell = if v.is_empty() { None } else { Some(v) }; }
if let Some(v) = font_family { next.terminal.font_family = if v.is_empty() { None } else { Some(v) }; }
if let Some(v) = font_size { next.terminal.font_size = Some(v.clamp(10, 20)); }
if let Some(v) = theme { next.appearance.theme = Some(v); }
if let Some(v) = accent { next.appearance.accent = Some(v); }
if let Some(v) = background { next.appearance.background = if v == "none" { None } else { Some(v) }; }
if let Some(v) = background_image { next.appearance.background_image = if v.is_empty() { None } else { Some(v) }; }
if let Some(v) = log_shell_commands { next.log_shell_commands = Some(v); }
let to_save = next.clone();
match tokio::task::spawn_blocking(move || to_save.save()).await {
Ok(Ok(())) => {
@@ -728,6 +830,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 +879,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 +921,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 +947,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 +986,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 +1016,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 +1060,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 +1077,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 +1099,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 +1137,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 +1190,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 +1242,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.
@@ -1154,8 +1257,7 @@ mod tests {
}).await;
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
// Observer connection: receives all broadcast events (the detected-state path
// flows through ServerMsg::StateDetected → record_event → Evt::Event).
// Observer connection: receives all broadcast events.
let mut observer = UnixStream::connect(&sock).await.unwrap();
// Drive the PTY output by attaching the control connection.
@@ -1163,21 +1265,27 @@ mod tests {
surface_id: spacesh_proto::SurfaceId(sid.clone()),
}).await;
// Expect an Evt::Event (kind=done) for this surface from the OSC 133 Done detection.
let mut found = None;
// Shell-command status (OSC 133) updates the live status ring (Evt::State) but is
// NOT logged by default — log_shell_commands defaults to false, so no Evt::Event
// is recorded for plain shell panels. (Agent activity flows through Cmd::SetState.)
let mut saw_state_done = false;
let mut saw_event = false;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(Some(env))) =
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await {
if let Envelope::Evt(Evt::Event { record }) = env {
if record.surface_id.0 == sid { found = Some(record); break; }
match env {
Envelope::Evt(Evt::State { surface_id, state })
if surface_id.0 == sid && state == spacesh_proto::status::SurfaceState::Done => { saw_state_done = true; }
// Exit (process end) is always logged; only command-status events are gated.
Envelope::Evt(Evt::Event { record })
if record.surface_id.0 == sid && record.kind != spacesh_proto::event::EventKind::Exit => { saw_event = true; }
_ => {}
}
}
}
let rec = found.expect("expected an Evt::Event from the OSC 133 detected state");
assert_eq!(rec.kind, spacesh_proto::event::EventKind::Done);
assert!(!rec.read);
assert_eq!(rec.workspace_id.0, ws);
assert!(saw_state_done, "expected an Evt::State(done) from the OSC 133 detection");
assert!(!saw_event, "shell-command events must not be logged when log_shell_commands is off");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1190,7 +1298,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 +1366,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 +1408,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 +1436,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 +1498,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 +1521,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 +1568,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 +1608,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);
}
}
+165
View File
@@ -0,0 +1,165 @@
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 that do not persist snapshots. Test-only — gated so
/// release builds don't warn about an unconstructed struct.
#[cfg(test)]
pub struct NullSnapshotStore;
#[cfg(test)]
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);
}
}
+101 -16
View File
@@ -7,6 +7,7 @@ use spacesh_proto::workspace::SurfaceSpec;
use spacesh_pty::{PtyHandle, SpawnSpec};
use tokio::sync::{broadcast, mpsc, oneshot};
use tokio::time::{Duration, Instant};
use crate::snapshot_store::SnapshotMsg;
/// Spawn (or restart) a surface actor from a persisted spec. Injects
/// SPACESH_SURFACE_ID into the child env, mirroring `new_surface`.
@@ -24,18 +25,26 @@ 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);
// For a Claude agent, inject our notify hooks via `--settings` so they layer on
// top of the user's real config without relocating CLAUDE_CONFIG_DIR (which would
// hide the Keychain login). Surface id reaches the hook through SPACESH_SURFACE_ID.
let mut args = spec.args.clone();
if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
args.extend(crate::hooks::claude_settings_args(&crate::hooks::spacesh_bin()));
}
let spawn_spec = SpawnSpec {
command: spec.command.clone(),
args: spec.args.clone(),
args,
cwd: std::path::PathBuf::from(&spec.cwd),
cols: spec.cols,
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 +62,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 +87,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 +109,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 +135,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 +152,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 +173,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 +191,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 +221,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 +237,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 +269,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 +281,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 +298,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 +311,7 @@ fn flush(
}
}
let _ = bcast.send(std::mem::take(pending));
replies
}
#[cfg(test)]
@@ -299,7 +336,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 +362,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 +376,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 +399,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 +445,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 +463,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 +480,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 +493,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"),
}
}
}
+12
View File
@@ -25,6 +25,18 @@ mkdir -p $SSH_REMOTE_DIR/download
server, then `docker compose pull && up -d`.
- **DMG** → built on macOS, uploaded by hand: `make deploy-dmg`
(sets the stable `download/spacesh.dmg`). Tauri can't build a macOS bundle in CI.
The same command also publishes a **versioned** copy to the Gitea package
registry (`Packages` tab) when `GITEA_TOKEN` is set:
```bash
GITEA_TOKEN=<token-with-package:write> make deploy-dmg # server + Gitea Packages
make publish-dmg GITEA_TOKEN=<token> # build + Gitea Packages only
```
Published at `{GITEA_URL}/api/packages/{GITEA_OWNER}/generic/spacesh/<version>/spacesh-<version>.dmg`
(override `GITEA_URL`/`GITEA_OWNER`/`GITEA_PKG` if needed). Version comes from
`tauri.conf.json`, bumped on every `make dmg`. Without `GITEA_TOKEN` the publish
step is skipped (server copy still happens).
## Gitea secrets required
+9 -3
View File
@@ -4,18 +4,24 @@
services:
landing:
# LANDING_IMAGE is written to .env by the CI deploy job (exact registry path + tag).
image: ${LANDING_IMAGE:-git.realmanual.ru/spacesh/spacesh-landing:latest}
image: ${LANDING_IMAGE:-git.realmanual.ru/pub/spacesh-landing:latest}
restart: unless-stopped
expose:
- "80"
networks:
# Must share a network with `proxy`, else proxy_pass to `landing` can't resolve.
- spaceshell-network
proxy:
image: nginx:1.27-alpine
# Stable name so NPM can forward by name (spacesh-proxy:80) instead of a
# pinned IP that another webproxy container could grab.
container_name: spacesh-proxy
restart: unless-stopped
depends_on:
- landing
ports:
- "80:80"
expose:
- "80"
volumes:
- ./proxy.conf:/etc/nginx/conf.d/default.conf:ro
- ./download:/srv/download:ro
+9 -5
View File
@@ -1,14 +1,16 @@
# Front nginx for spaceshell.ru — reverse-proxies the landing container and
# serves macOS .dmg downloads from the host-mounted ./download volume.
upstream landing_upstream {
server landing:80;
}
server {
listen 80;
listen [::]:80;
server_name spaceshell.ru www.spaceshell.ru;
# Resolve `landing` at request time via Docker's embedded DNS, so nginx
# starts even if the landing container is momentarily down (a static
# `upstream { server landing:80; }` makes nginx fail to boot when the name
# can't be resolved, restart-looping the proxy → flapping page).
resolver 127.0.0.11 valid=10s ipv6=off;
# Stable download URL: /download/spacesh.dmg → ./download/spacesh.dmg on host.
location /download/ {
alias /srv/download/;
@@ -21,7 +23,9 @@ server {
}
location / {
proxy_pass http://landing_upstream;
# Variable in proxy_pass forces runtime resolution (with the resolver above).
set $landing http://landing:80;
proxy_pass $landing;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+1 -1
View File
@@ -1 +1 @@
0.1.1
0.1.3
+15 -14
View File
@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>spacesh — терминал-воркспейс для AI-агентов на macOS</title>
<title>spaceshell — терминал-воркспейс для AI-агентов на macOS</title>
<meta name="description" content="Запускай Claude Code, Codex, Gemini и shell параллельно. Фоновый демон держит сессии живыми: закрыл окно — агенты работают. Скачать для macOS.">
<link rel="canonical" href="https://spaceshell.ru">
<meta property="og:title" content="spacesh — терминал-воркспейс для AI-агентов">
<meta property="og:title" content="spaceshell — терминал-воркспейс для AI-агентов">
<meta property="og:description" content="Десяток AI-агентов параллельно. Демон держит сессии живыми — закрой окно, агенты работают.">
<meta property="og:url" content="https://spaceshell.ru">
<meta property="og:image" content="https://spaceshell.ru/og.png">
@@ -812,15 +812,15 @@
<div class="header-inner">
<a href="/" class="logo">
<span class="logo-icon">>_</span>
spacesh
spaceshell
</a>
<nav class="nav">
<a href="#features">Возможности</a>
<a href="#how">Как работает</a>
<a href="#cli">CLI</a>
<a href="https://github.com/spacesh" target="_blank" rel="noopener">GitHub</a>
<a href="https://git.realmanual.ru/pub/spaceshell" target="_blank" rel="noopener">GitHub</a>
</nav>
<a href="#download" class="btn btn-primary">Скачать для macOS</a>
<a href="https://spaceshell.ru/download/spacesh.dmg" download class="btn btn-primary">Скачать для macOS</a>
<button class="mobile-nav-toggle" aria-label="Меню"></button>
</div>
</div>
@@ -836,11 +836,11 @@
Гоняй десяток AI-агентов параллельно. <span class="accent">Не теряй ни одного.</span>
</h1>
<p class="hero-subtitle">
spacesh держит живые сессии Claude Code, Codex, Gemini и shell в фоновом демоне.
spaceshell держит живые сессии Claude Code, Codex, Gemini и shell в фоновом демоне.
Закрыл окно, обновил приложение, словил краш — агенты продолжают работать.
</p>
<div class="hero-buttons">
<a href="#download" class="btn btn-primary btn-large">
<a href="https://spaceshell.ru/download/spacesh.dmg" download class="btn btn-primary btn-large">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 100 16A8 8 0 008 0zm3.5 9l-3 3a.7.7 0 01-1 0l-3-3a.7.7 0 011-1L7 9.5V4a1 1 0 012 0v5.5L10.5 8a.7.7 0 011 1z"/>
</svg>
@@ -860,7 +860,7 @@
<span class="terminal-dot"></span>
<span class="terminal-dot"></span>
</div>
<span class="terminal-title">spacesh — workspace</span>
<span class="terminal-title">spaceshell — workspace</span>
</div>
<div class="terminal-grid">
<div class="terminal-pane">
@@ -928,6 +928,7 @@
<span class="agent-tag">Codex</span>
<span class="agent-tag">Gemini</span>
<span class="agent-tag">opencode</span>
<span class="agent-tag">deepseek</span>
<span class="agent-tag">shell</span>
</div>
</div>
@@ -946,7 +947,7 @@
</div>
<div class="solution-card">
<p class="card-label">Решение</p>
<h3 class="card-title">spacesh разрывает эту связь.</h3>
<h3 class="card-title">spaceshell разрывает эту связь.</h3>
<p class="card-text">
Сессиями владеет фоновый демон, а не окно. Интерфейс — всего лишь вид поверх него.
</p>
@@ -999,7 +1000,7 @@
<h3 class="feature-title">CLI как первый класс</h3>
<p class="feature-text">
spacesh status --json, focus, new-surface, notify — те же команды, что и в интерфейсе, плюс shell-completions.
Встраивай spacesh в свои пайплайны.
Встраивай spaceshell в свои пайплайны.
</p>
</div>
<div class="feature-card reveal">
@@ -1098,14 +1099,14 @@
<div class="container">
<h2 class="cta-title reveal">Готов гонять агентов пачками?</h2>
<div class="cta-buttons reveal">
<a href="/download/spacesh.dmg" class="btn btn-primary btn-large">
<a href="https://spaceshell.ru/download/spacesh.dmg" download class="btn btn-primary btn-large">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 100 16A8 8 0 008 0zm3.5 9l-3 3a.7.7 0 01-1 0l-3-3a.7.7 0 011-1L7 9.5V4a1 1 0 012 0v5.5L10.5 8a.7.7 0 011 1z"/>
</svg>
Скачать для macOS
</a>
</div>
<a href="https://github.com/spacesh" target="_blank" rel="noopener" class="cta-github">
<a href="https://git.realmanual.ru/pub/spaceshell" target="_blank" rel="noopener" class="cta-github">
Исходники на GitHub →
</a>
</div>
@@ -1117,10 +1118,10 @@
<div class="footer-inner">
<div class="footer-left">
<span class="footer-logo">spaceshell.ru</span>
<span class="footer-copy">© 2026 spacesh</span>
<span class="footer-copy">© 2026 spaceshell</span>
</div>
<div class="footer-links">
<a href="https://github.com/spacesh" target="_blank" rel="noopener">GitHub</a>
<a href="https://git.realmanual.ru/pub/spaceshell" target="_blank" rel="noopener">GitHub</a>
<a href="#">Документация</a>
<a href="#">Лицензия</a>
</div>
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
// Bump the patch version in lockstep for the GUI (app/src-tauri/tauri.conf.json)
// and the daemon/CLI (root Cargo.toml [workspace.package].version), so the app's
// update check and the daemon's reported version never drift apart.
import { readFileSync, writeFileSync } from "node:fs";
const TAURI_CONF = "app/src-tauri/tauri.conf.json";
const CARGO_TOML = "Cargo.toml";
const conf = JSON.parse(readFileSync(TAURI_CONF, "utf8"));
const parts = conf.version.split(".").map((n) => parseInt(n, 10) || 0);
parts[2] = (parts[2] || 0) + 1;
const next = parts.join(".");
conf.version = next;
writeFileSync(TAURI_CONF, JSON.stringify(conf, null, 2) + "\n");
let cargo = readFileSync(CARGO_TOML, "utf8");
// Replace only the version inside [workspace.package], not dependency versions.
cargo = cargo.replace(
/(\[workspace\.package\][\s\S]*?version\s*=\s*")[^"]+(")/,
`$1${next}$2`
);
writeFileSync(CARGO_TOML, cargo);
console.log(`version → ${next} (GUI + daemon)`);