From a707895200352be84775ceb8c7b6278fc002b5f5 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 10 Jun 2026 11:52:39 +0700 Subject: [PATCH] =?UTF-8?q?docs:=20SP1+SP3+SP4=20design=20=E2=80=94=20obse?= =?UTF-8?q?rvability,=20scrollback=20search,=20panel=20zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-10-spacesh-sp1-sp3-sp4-design.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 DOCS/superpowers/specs/2026-06-10-spacesh-sp1-sp3-sp4-design.md diff --git a/DOCS/superpowers/specs/2026-06-10-spacesh-sp1-sp3-sp4-design.md b/DOCS/superpowers/specs/2026-06-10-spacesh-sp1-sp3-sp4-design.md new file mode 100644 index 0000000..9f99272 --- /dev/null +++ b/DOCS/superpowers/specs/2026-06-10-spacesh-sp1-sp3-sp4-design.md @@ -0,0 +1,115 @@ +# 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.