Files
spaceshell/DOCS/superpowers/specs/2026-06-10-spacesh-sp1-sp3-sp4-design.md
2026-06-10 11:52:39 +07:00

8.6 KiB

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 · <uptime> 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: <1mNs; <1hNm; <1dNh 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.

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<string, SearchAddon> 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<SurfaceId> (at most one zoomed panel per workspace; None = normal grid).
  • New Cmd::SetZoom { workspace_id: WorkspaceId, surface_id: Option<SurfaceId> }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, Maximize2setZoom(workspaceId, sid); when this panel is the zoomed one, show Minimize2setZoom(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.