Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
⌘Ffind-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
Cmdvariants inspacesh-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()capturesstarted_at_msonce (SystemTime::now()→ millis) and threads it intorouter, which holds it for the lifetime of the process.handle_requestgains aCmd::Healtharm 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 aconnectedboolean (true after a successfulgetStatusFull/getHealth, false onspacesh:disconnected). Passhealth+connectedtoSidebar.Sidebar.tsxfooter: thelivedot is green whenconnected, grey otherwise; the label readsspaceshd · livewhen connected,spaceshd · offlineotherwise; the right-hand value shows uptime formatted fromstarted_at_ms(e.g.3d 4h,5h 12m,47s), recomputed on a ~30s interval; the daemon version is shown via the element'stitletooltip. Whenhealthis null (not yet fetched / offline) the uptime slot is blank.
Edge cases
- Health requested before connect → the bridge call rejects; GUI keeps
connected=falseand shows offline. - Uptime formatting:
<1m→Ns;<1h→Nm;<1d→Nh Mm; elseNd Mh.
Tests
- proto:
Cmd::Healthserde round-trip. - daemon: integration test —
Cmd::Healthreturns a non-emptyversion, a plausiblepid, and astarted_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 theTerminalwithscrollback: 10000; load aSearchAddon; register it in a module-levelMap<string, SearchAddon>keyed bysurfaceIdon 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 focusedsurfaceId, 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 asi/N(or0/0). optssets decoration colors (match / active-match) from the theme (COLORS.stWaitactive, a dim variant for others).Esccloses the bar and callsaddon.clearDecorations().
- typing / Enter →
App.tsx: ownssearchOpenstate and the focused surface (effectiveFocus, already tracked). A globalkeydownhandler 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 targetseffectiveFocus; 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/0and 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 toRUNNING.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) andWorkspaceView(status) gainzoomed: 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,Noneclears zoom.
Daemon
- The registry stores
zoomedon each workspace; it is included when buildingWorkspaceViewand persisted instate.json. Cmd::SetZoomhandler: ifSome(sid), validate the surface belongs to the workspace (elseNOT_FOUND); setzoomed; ifNone, clear it. Persist (mark_dirty) and broadcastEvt::WorkspaceChanged { workspace: view }so all clients re-render. Respondok.- 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, clearzoomedif it pointed at the removed surface. (Applying a new preset replaces surfaces, so it must also clear zoom for that workspace.)
GUI
WorkspaceViewtype gainszoomed: string | null.socketBridge.ts:setZoom(workspaceId: string, surfaceId: string | null).LayoutEngine.tsx: accept the workspace'szoomed. Whenzoomedis 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, showMinimize2→setZoom(workspaceId, null). The control stops propagation so it doesn't also trigger focus. App.tsxpassesactive.zoomedintoLayoutEngine.
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::SetZoomserde round-trip;Workspace/WorkspaceViewround-trip withzoomedset. - daemon: integration — SetZoom(Some) reflects in
status(zoomed == sid) and broadcasts WorkspaceChanged; SetZoom(None) clears; closing the zoomed surface auto-clearszoomed. - 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.