# spacesh SP1 + SP3 + SP4 — Design **Status:** Design (approved for plan authoring) **Date:** 2026-06-10 **Scope:** Three independent sub-projects from the "frontend mocks → backend" decomposition, batched into one design doc. Each section is independently implementable and gets its own task group in the plan. - **SP1 — Daemon observability:** real `spaceshd · live · ` sidebar footer (replaces the hardcoded mock). - **SP3 — Scrollback search:** working `⌘F` find-in-terminal for the focused panel (replaces the mock pill). - **SP4 — Panel zoom:** maximize one panel to fill the grid, persisted across restarts (replaces the mock zoom icon). ## Architecture invariants honored - **Daemon is the single source of truth.** SP1 health and SP4 zoom state live in `spaceshd`; the GUI mirrors them. SP3 search is a *display* concern (operates on the xterm.js buffer the user sees) and deliberately stays client-side. - **One socket, one protocol.** SP1/SP4 add new `Cmd` variants in `spacesh-proto`; no new transport. - **Display (xterm.js) vs analysis (alacritty grid) split.** SP3 uses xterm.js's own buffer + search addon — searching what is displayed is a display task. The daemon's authoritative grid is untouched; a daemon-side grid search can be added later as a separate command if headless/CLI/cross-panel search is ever needed. --- ## SP1 — Daemon observability ### Problem The sidebar footer shows a hardcoded `spaceshd · live` / `3d 4h`. There is no command to learn the daemon's version or how long it has been running. ### Proto New command (in `spacesh-proto/src/message.rs`): - `Cmd::Health` → response data `{ "version": String, "pid": u32, "started_at_ms": u64 }`. `version` is the daemon crate version; `pid` is the daemon process id; `started_at_ms` is the unix-epoch-millis timestamp captured when the daemon began serving. Uptime is derived GUI-side (`now - started_at_ms`) so it can tick without polling. ### Daemon - `serve()` captures `started_at_ms` once (`SystemTime::now()` → millis) and threads it into `router`, which holds it for the lifetime of the process. - `handle_request` gains a `Cmd::Health` arm returning `{ version: env!("CARGO_PKG_VERSION").to_string(), pid: std::process::id(), started_at_ms }`. ### GUI - `socketBridge.ts`: `getHealth(): Promise<{ version: string; pid: number; started_at_ms: number }>`. - `App.tsx`: fetch health on connect (and on reconnect); track a `connected` boolean (true after a successful `getStatusFull`/`getHealth`, false on `spacesh:disconnected`). Pass `health` + `connected` to `Sidebar`. - `Sidebar.tsx` footer: the `live` dot is green when `connected`, grey otherwise; the label reads `spaceshd · live` when connected, `spaceshd · offline` otherwise; the right-hand value shows uptime formatted from `started_at_ms` (e.g. `3d 4h`, `5h 12m`, `47s`), recomputed on a ~30s interval; the daemon version is shown via the element's `title` tooltip. When `health` is null (not yet fetched / offline) the uptime slot is blank. ### Edge cases - Health requested before connect → the bridge call rejects; GUI keeps `connected=false` and shows offline. - Uptime formatting: `<1m` → `Ns`; `<1h` → `Nm`; `<1d` → `Nh Mm`; else `Nd Mh`. ### Tests - proto: `Cmd::Health` serde round-trip. - daemon: integration test — `Cmd::Health` returns a non-empty `version`, a plausible `pid`, and a `started_at_ms` ≤ now. --- ## SP3 — Scrollback search (xterm addon-search) ### Problem The center toolbar's `Search scrollback ⌘F` pill is a mock. Users need to find text in a panel's output. ### Approach Use xterm.js's official `@xterm/addon-search` on the **focused** panel's terminal. Searching the displayed buffer (including its scrollback) is a display concern, so this is entirely client-side — no daemon changes. The xterm scrollback is raised so "full scrollback" is meaningful. ### Components - **Dependency:** `@xterm/addon-search`. - **`TerminalView.tsx`:** construct the `Terminal` with `scrollback: 10000`; load a `SearchAddon`; register it in a module-level `Map` keyed by `surfaceId` on mount and delete the entry on unmount. This registry lets the search bar reach the focused panel's addon without prop-drilling through the layout tree. - **`SearchBar.tsx` (new):** a small overlay input. Given the focused `surfaceId`, it looks up the addon in the registry and drives it: - typing / Enter → `addon.findNext(term, opts)`; Shift+Enter → `addon.findPrevious(term, opts)`. - match count + current index from `addon.onDidChangeResults(({ resultIndex, resultCount }) => …)` rendered as `i/N` (or `0/0`). - `opts` sets decoration colors (match / active-match) from the theme (`COLORS.stWait` active, a dim variant for others). - `Esc` closes the bar and calls `addon.clearDecorations()`. - **`App.tsx`:** owns `searchOpen` state and the focused surface (`effectiveFocus`, already tracked). A global `keydown` handler opens the bar on `⌘F`/`Ctrl+F` (preventing the browser default) when a workspace is active; clicking the toolbar pill also opens it. The bar targets `effectiveFocus`; if focus changes while open, the bar re-targets and clears the previous panel's decorations. ### Edge cases - No focused panel / focused panel has no registered addon (e.g. stopped) → bar shows `0/0` and is a no-op. - Empty query → clear decorations, `0/0`. - Closing the bar always clears decorations on the targeted addon. ### Tests - Frontend type-check + build (`npm run build`). No headless smoke for xterm search (xterm needs a real DOM/canvas; covered by manual testing). Manual scenario added to `RUNNING.md`. --- ## SP4 — Panel zoom (persisted) ### Problem The panel-header zoom icon (`maximize-2`) is a mock. Users want to maximize one panel to fill the grid, and have that survive restarts. ### Proto - `Workspace` (persisted) and `WorkspaceView` (status) gain `zoomed: Option` (at most one zoomed panel per workspace; `None` = normal grid). - New `Cmd::SetZoom { workspace_id: WorkspaceId, surface_id: Option }` — `Some(sid)` zooms that panel, `None` clears zoom. ### Daemon - The registry stores `zoomed` on each workspace; it is included when building `WorkspaceView` and persisted in `state.json`. - `Cmd::SetZoom` handler: if `Some(sid)`, validate the surface belongs to the workspace (else `NOT_FOUND`); set `zoomed`; if `None`, clear it. Persist (`mark_dirty`) and broadcast `Evt::WorkspaceChanged { workspace: view }` so all clients re-render. Respond `ok`. - **Auto-clear:** when a surface is removed (`remove_surface`, used by Close/ApplyPreset/CloseWorkspace) or a workspace's structure changes such that the zoomed surface no longer exists, clear `zoomed` if it pointed at the removed surface. (Applying a new preset replaces surfaces, so it must also clear zoom for that workspace.) ### GUI - `WorkspaceView` type gains `zoomed: string | null`. - `socketBridge.ts`: `setZoom(workspaceId: string, surfaceId: string | null)`. - `LayoutEngine.tsx`: accept the workspace's `zoomed`. When `zoomed` is set and that surface is present & running, render ONLY that leaf at full size (the split tree and splitters are bypassed). Otherwise render the normal tree. - Panel header zoom control: when not zoomed, `Maximize2` → `setZoom(workspaceId, sid)`; when this panel is the zoomed one, show `Minimize2` → `setZoom(workspaceId, null)`. The control stops propagation so it doesn't also trigger focus. - `App.tsx` passes `active.zoomed` into `LayoutEngine`. ### Edge cases - Zoomed surface stopped (process exited but still in tree): still render it zoomed, showing the "Process exited / Restart" state full-screen (consistent with normal panes). Auto-clear only on actual removal from the tree. - Zoom set on a workspace, then a different workspace selected: zoom is per-workspace, so switching away and back preserves it. ### Tests - proto: `Cmd::SetZoom` serde round-trip; `Workspace`/`WorkspaceView` round-trip with `zoomed` set. - daemon: integration — SetZoom(Some) reflects in `status` (`zoomed == sid`) and broadcasts WorkspaceChanged; SetZoom(None) clears; closing the zoomed surface auto-clears `zoomed`. - frontend: build clean; manual scenario in `RUNNING.md`. --- ## Out of scope - Daemon-side grid search / CLI search (SP3 stays client-side). - Multiple simultaneous zoomed panels (one per workspace only). - Health metrics beyond version/pid/uptime (no CPU/mem/session counts). - The top-bar `search`/`settings`/account menu and Telegram/MAX channels remain mocked (other sub-projects / out of v1). ## Build order within the plan SP1 (smallest, proto+daemon+sidebar) → SP4 (proto+daemon+layout) → SP3 (frontend-only). Each is independent; this order front-loads the cheap daemon work and ends with the isolated frontend feature.