Files
spaceshell/DOCS/superpowers/plans/2026-06-10-spacesh-sp1-sp3-sp4.md
T
vasyansk f18d929c10 docs: SP1+SP3+SP4 implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:57:45 +07:00

32 KiB

spacesh SP1 + SP3 + SP4 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace three frontend mocks with real features — daemon health/uptime in the sidebar footer (SP1), working ⌘F scrollback search on the focused panel (SP3), and persisted single-panel zoom (SP4).

Architecture: SP1 adds a Cmd::Health returning version/pid/started_at; the GUI derives a ticking uptime. SP4 stores zoomed: Option<SurfaceId> per workspace (persisted, broadcast on change, auto-cleared on surface removal) and the GUI renders only the zoomed panel when set. SP3 is client-only: xterm.js's @xterm/addon-search on the focused panel's terminal, driven by a small search bar, with a module-level registry mapping surfaceId → addon.

Tech Stack: Rust (tokio, serde) for spacesh-proto/spaceshd; React + TypeScript + Tauri 2 + xterm.js for the app.

Design spec: DOCS/superpowers/specs/2026-06-10-spacesh-sp1-sp3-sp4-design.md


File Structure

SP1

  • Modify crates/spacesh-proto/src/message.rsCmd::Health variant + test.
  • Modify crates/spaceshd/src/server.rs — capture started_at_ms, thread through serve/router/handle_request, Health arm, test.
  • Modify app/src-tauri/src/bridge.rs + app/src-tauri/src/lib.rshealth command.
  • Modify app/src/socketBridge.tsgetHealth.
  • Modify app/src/App.tsxconnected + health state, pass to Sidebar.
  • Modify app/src/Sidebar.tsx — real footer (live dot, uptime, version tooltip).

SP4

  • Modify crates/spacesh-proto/src/workspace.rszoomed on Workspace + WorkspaceView.
  • Modify crates/spacesh-proto/src/message.rsCmd::SetZoom + test.
  • Modify crates/spaceshd/src/registry.rsto_view includes zoomed; open_workspace sets zoomed: None; remove_surface clears stale zoom.
  • Modify crates/spaceshd/src/server.rsSetZoom arm + test.
  • Modify app/src-tauri/src/bridge.rs + lib.rsset_zoom command.
  • Modify app/src/layoutTypes.tszoomed on WorkspaceView.
  • Modify app/src/socketBridge.tssetZoom.
  • Modify app/src/LayoutEngine.tsx — zoom render + header toggle.
  • Modify app/src/App.tsx — pass active.zoomed.

SP3

  • Add dependency @xterm/addon-search.
  • Create app/src/searchRegistry.tsMap<string, SearchAddon> register/unregister/get.
  • Modify app/src/TerminalView.tsxscrollback: 10000, load SearchAddon, register.
  • Create app/src/SearchBar.tsx — overlay search input.
  • Modify app/src/CenterToolbar.tsx — pill onOpenSearch callback.
  • Modify app/src/App.tsxsearchOpen state, ⌘F handler, render SearchBar.

Final

  • Modify DOCS/RUNNING.md — manual scenarios.

SP1 — Daemon observability

Task 1: proto Cmd::Health

Files: Modify crates/spacesh-proto/src/message.rs

  • Step 1: Add the variant

In the Cmd enum, immediately before Status, add:

    Health,
  • Step 2: Add the test

Append to the tests module in message.rs:

    #[test]
    fn health_cmd_round_trips() {
        let env = Envelope::Req { id: 1, cmd: Cmd::Health };
        let j = serde_json::to_string(&env).unwrap();
        assert!(j.contains(r#""cmd":"health""#));
        let back: Envelope = serde_json::from_str(&j).unwrap();
        assert_eq!(back, env);
    }
  • Step 3: Run

Run: cargo test -p spacesh-proto message::health_cmd_round_trips Expected: PASS.

Note: this adds a Cmd::Health variant. cargo build -p spaceshd will FAIL until Task 2 adds the matching arm (the daemon's handle_request match is exhaustive). That is expected — verify only the proto crate in this task; Task 2 restores a green workspace build.

  • Step 4: Commit
git add crates/spacesh-proto/src/message.rs
git commit -m "feat(proto): Health command"

Task 2: daemon Health handler

Files: Modify crates/spaceshd/src/server.rs

Context: post-SP2, serve(socket, store, event_store); router(rx, router_tx, exit_tx, state_tx, persister, initial, event_persister, event_initial); handle_request(id, cmd, client, out, reg, subs, clients, router_tx, exit_tx, state_tx, persister, event_log, event_persister). We thread a started_at_ms: u64 through all three.

  • Step 1: Capture started_at in serve and pass to router

In serve, just before the router is spawned (where persister/initial are set up), add:

    let started_at_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);

Then add started_at_ms as the final argument to the tokio::spawn(router(...)) call.

  • Step 2: Accept it in router and pass to handle_request

Add started_at_ms: u64, as the final parameter of router. In the ServerMsg::Request arm, pass started_at_ms as the final argument to handle_request(...).

  • Step 3: Accept it in handle_request

Add started_at_ms: u64, as the final parameter of handle_request (after event_persister: &EventPersister,).

  • Step 4: Add the Health arm

Immediately before Cmd::Status => { add:

        Cmd::Health => {
            let _ = out.send(ok(id, serde_json::json!({
                "version": env!("CARGO_PKG_VERSION"),
                "pid": std::process::id(),
                "started_at_ms": started_at_ms,
            }))).await;
        }
  • Step 5: Write the integration test

Add to the mod tests block in server.rs, following the existing pattern (tempdir_path, make_event_store(&dir), serve, wait_for_socket, req, res_data):

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn health_reports_version_pid_started() {
        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);
        let sock_for_task = sock.clone();
        let store2 = store.clone();
        tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
        wait_for_socket(&sock).await;
        let mut s = UnixStream::connect(&sock).await.unwrap();

        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
        let r = req(&mut s, 1, Cmd::Health).await;
        let d = res_data(&r);
        assert!(!d["version"].as_str().unwrap().is_empty());
        assert!(d["pid"].as_u64().unwrap() > 0);
        let started = d["started_at_ms"].as_u64().unwrap();
        assert!(started > 0 && started <= now + 1000, "started_at_ms plausible");
    }
  • Step 6: Run + commit

Run: cargo test -p spaceshd health_reports_version_pid_started → PASS. Then cargo test -p spaceshd → all pass (kill a stale daemon + rm -f ~/.spacesh/daemon.lock if ONLY lock_is_exclusive_within_process fails).

git add crates/spaceshd/src/server.rs
git commit -m "feat(daemon): Health command (version, pid, started_at)"

Files: Modify app/src-tauri/src/bridge.rs, app/src-tauri/src/lib.rs, app/src/socketBridge.ts, app/src/App.tsx, app/src/Sidebar.tsx

  • Step 1: Tauri bridge command

In app/src-tauri/src/bridge.rs, after the status command add:

#[tauri::command]
pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
    data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
}

In app/src-tauri/src/lib.rs, add bridge::health, to the tauri::generate_handler![...] list.

  • Step 2: socketBridge

In app/src/socketBridge.ts add:

export interface DaemonHealth { version: string; pid: number; started_at_ms: number }

export async function getHealth(): Promise<DaemonHealth> {
  return await invoke<DaemonHealth>("health");
}
  • Step 3: App tracks connected + health

In app/src/App.tsx:

  • Import getHealth and the DaemonHealth type from ./socketBridge.
  • Add state:
  const [health, setHealth] = useState<DaemonHealth | null>(null);
  const [connected, setConnected] = useState(false);
  • In the initial useEffect, after void refresh();/void seedEvents();, add a health fetch that also flips connected:
    const loadHealth = async () => {
      try { setHealth(await getHealth()); setConnected(true); }
      catch { setConnected(false); }
    };
    void loadHealth();
  • In the onDaemonRawEvent("spacesh:disconnected", ...) handler, also setConnected(false);. In the reconnect path call void loadHealth(); again. (Define loadHealth with useCallback(async () => {...}, []) so it can be referenced in both places and listed in deps.)

  • Pass to Sidebar: <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} />

  • Step 4: Sidebar footer

In app/src/Sidebar.tsx:

  • Import: import { useState, useEffect } from "react"; (extend the existing import) and import type { DaemonHealth } from "./socketBridge";.
  • Extend props with health: DaemonHealth | null; connected: boolean;.
  • Add an uptime formatter and a 30s ticker above the return:
function fmtUptime(startedMs: number): string {
  const s = Math.max(0, Math.floor((Date.now() - startedMs) / 1000));
  if (s < 60) return `${s}s`;
  if (s < 3600) return `${Math.floor(s / 60)}m`;
  if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
  return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
}

Inside the component body, add a tick to re-render every 30s:

  const [, setTick] = useState(0);
  useEffect(() => {
    const t = setInterval(() => setTick((n) => n + 1), 30000);
    return () => clearInterval(t);
  }, []);

Replace the hardcoded footer block with:

      <div title={health ? `spaceshd v${health.version} · pid ${health.pid}` : "daemon offline"}
        style={{ display: "flex", alignItems: "center", gap: 8, height: 30, marginTop: 10, padding: "0 6px", borderRadius: 6, background: COLORS.bgPanel }}>
        <span style={{ width: 7, height: 7, borderRadius: "50%", background: connected ? COLORS.stDone : COLORS.textMuted, flex: "0 0 7px" }} />
        <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>{connected ? "spaceshd · live" : "spaceshd · offline"}</span>
        <span style={{ flex: 1 }} />
        <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>{health ? fmtUptime(health.started_at_ms) : ""}</span>
      </div>
  • Step 5: Build + commit

Run: cd app && npm run build → clean. cd back.

git add app/src-tauri/src/bridge.rs app/src-tauri/src/lib.rs app/src/socketBridge.ts app/src/App.tsx app/src/Sidebar.tsx
git commit -m "feat(app): real daemon health footer (live, uptime, version)"

SP4 — Panel zoom (persisted)

Task 4: proto zoom field + SetZoom

Files: Modify crates/spacesh-proto/src/workspace.rs, crates/spacesh-proto/src/message.rs

  • Step 1: Add zoomed to Workspace and WorkspaceView

In crates/spacesh-proto/src/workspace.rs, add to Workspace (after the layout field):

    /// The single maximized surface for this workspace, if any.
    #[serde(default)]
    pub zoomed: Option<SurfaceId>,

And to WorkspaceView (after its layout field):

    #[serde(default)]
    pub zoomed: Option<SurfaceId>,
  • Step 2: Fix the existing workspace test constructors

Both workspace_round_trips_with_empty_layout (in workspace.rs) and any other literal Workspace { ... } / WorkspaceView { ... } constructions in the proto crate must add zoomed: None,. Update workspace_round_trips_with_empty_layout's Workspace { ... } literal to include zoomed: None,.

  • Step 3: Add Cmd::SetZoom

In crates/spacesh-proto/src/message.rs, in the Cmd enum before Status,:

    SetZoom {
        workspace_id: WorkspaceId,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        surface_id: Option<SurfaceId>,
    },
  • Step 4: Tests

Append to message.rs tests:

    #[test]
    fn set_zoom_cmd_round_trips() {
        let z = Envelope::Req { id: 1, cmd: Cmd::SetZoom {
            workspace_id: WorkspaceId("w_1".into()), surface_id: Some(SurfaceId("s_1".into())) } };
        let j = serde_json::to_string(&z).unwrap();
        assert!(j.contains(r#""cmd":"set_zoom""#));
        assert_eq!(serde_json::from_str::<Envelope>(&j).unwrap(), z);
        let unz = Envelope::Req { id: 2, cmd: Cmd::SetZoom {
            workspace_id: WorkspaceId("w_1".into()), surface_id: None } };
        assert_eq!(serde_json::from_str::<Envelope>(&serde_json::to_string(&unz).unwrap()).unwrap(), unz);
    }

Append to workspace.rs tests:

    #[test]
    fn workspace_round_trips_with_zoom() {
        let w = Workspace {
            id: WorkspaceId("w_1".into()), path: "/tmp/p".into(), name: "p".into(),
            group_id: None, order: 0, unread: false, layout: None,
            zoomed: Some(SurfaceId("s_1".into())), surfaces: HashMap::new(),
        };
        let back: Workspace = serde_json::from_str(&serde_json::to_string(&w).unwrap()).unwrap();
        assert_eq!(back, w);
    }
  • Step 5: Run + commit

Run: cargo test -p spacesh-proto → all pass.

Note: this adds a Cmd::SetZoom variant. cargo build -p spaceshd will FAIL until Task 5 adds the matching arm. Expected — verify only the proto crate here; Task 5 restores a green workspace build.

git add crates/spacesh-proto/src/workspace.rs crates/spacesh-proto/src/message.rs
git commit -m "feat(proto): workspace zoomed field + SetZoom command"

Task 5: daemon zoom state + handler

Files: Modify crates/spaceshd/src/registry.rs, crates/spaceshd/src/server.rs

  • Step 1: Construct and surface zoomed in the registry

In crates/spaceshd/src/registry.rs:

  • In open_workspace, the Workspace { ... } literal: add zoomed: None,.
  • In to_view, the returned WorkspaceView { ... }: add zoomed: w.zoomed.clone(),.
  • In remove_surface, inside if let Some(w) = self.workspaces.get_mut(&ws) { ... }, after w.surfaces.remove(sid); add:
                if w.zoomed.as_ref() == Some(sid) { w.zoomed = None; }
  • Step 2: Registry test for zoom auto-clear

Append to registry.rs tests:

    #[test]
    fn remove_surface_clears_zoom() {
        let mut r = Registry::new();
        let (ws, _) = r.open_workspace(std::env::temp_dir());
        let s1 = r.new_surface_id();
        r.add_surface_spec(&ws, s1.clone(), spec());
        r.workspace_mut(&ws).unwrap().zoomed = Some(s1.clone());
        r.remove_surface(&s1);
        assert!(r.workspace(&ws).unwrap().zoomed.is_none());
    }
  • Step 3: SetZoom handler in server.rs

Immediately before Cmd::Status => { (and after the SP1 Cmd::Health arm) add:

        Cmd::SetZoom { workspace_id, surface_id } => {
            let Some(w) = reg.workspace(&workspace_id) else {
                let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
            };
            if let Some(sid) = &surface_id {
                if !w.surfaces.contains_key(sid) {
                    let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return;
                }
            }
            if let Some(w) = reg.workspace_mut(&workspace_id) {
                w.zoomed = surface_id.clone();
            }
            if let Some(view) = reg.workspace_view(&workspace_id) {
                broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view }));
            }
            persister.mark_dirty(reg.persist_state());
            let _ = out.send(ok(id, serde_json::Value::Null)).await;
        }
  • Step 4: Integration test

Add to server.rs tests (uses an observer connection for the WorkspaceChanged broadcast; mirrors existing tests):

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn set_zoom_sets_and_clears_and_autoclears() {
        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);
        let sock_for_task = sock.clone();
        let store2 = store.clone();
        tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).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;
        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.clone()),
            command: Some("/bin/sh".into()), args: vec!["-c".into(), "sleep 5".into()], cols: 80, rows: 24,
        }).await;
        let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();

        // Zoom it.
        let _ = req(&mut s, 3, Cmd::SetZoom {
            workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
            surface_id: Some(spacesh_proto::SurfaceId(sid.clone())),
        }).await;
        let st = req(&mut s, 4, Cmd::Status).await;
        let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone();
        assert_eq!(w0["zoomed"], sid);

        // Unzoom.
        let _ = req(&mut s, 5, Cmd::SetZoom {
            workspace_id: spacesh_proto::WorkspaceId(ws.clone()), surface_id: None,
        }).await;
        let st = req(&mut s, 6, Cmd::Status).await;
        let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone();
        assert!(w0["zoomed"].is_null());

        // Re-zoom then close the surface → auto-clear.
        let _ = req(&mut s, 7, Cmd::SetZoom {
            workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
            surface_id: Some(spacesh_proto::SurfaceId(sid.clone())),
        }).await;
        let _ = req(&mut s, 8, Cmd::Close { surface_id: spacesh_proto::SurfaceId(sid.clone()) }).await;
        let st = req(&mut s, 9, Cmd::Status).await;
        let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone();
        assert!(w0["zoomed"].is_null(), "closing the zoomed surface clears zoom");
    }
  • Step 5: Run + commit

Run: cargo test -p spaceshd set_zoom_sets_and_clears_and_autoclears → PASS; cargo test -p spaceshd → all pass (handle the env lock test as before). Note: Cmd::SetZoom will trigger a non-exhaustive match warning/error elsewhere only if other match sites exist — there are none besides handle_request, so the build is clean.

git add crates/spaceshd/src/registry.rs crates/spaceshd/src/server.rs
git commit -m "feat(daemon): SetZoom command + persisted workspace zoom with auto-clear"

Task 6: GUI zoom

Files: Modify app/src-tauri/src/bridge.rs, app/src-tauri/src/lib.rs, app/src/layoutTypes.ts, app/src/socketBridge.ts, app/src/LayoutEngine.tsx, app/src/App.tsx

  • Step 1: Tauri bridge command

In app/src-tauri/src/bridge.rs, after the set_ratios/move_surface area (any spot among the commands) add:

#[tauri::command]
pub async fn set_zoom(state: BridgeState<'_>, workspace_id: String, surface_id: Option<String>) -> Result<Value, String> {
    let cmd = Cmd::SetZoom {
        workspace_id: spacesh_proto::WorkspaceId(workspace_id),
        surface_id: surface_id.map(spacesh_proto::SurfaceId),
    };
    data_of(state.request(cmd).await.map_err(|e| e.to_string())?)
}

In app/src-tauri/src/lib.rs, add bridge::set_zoom, to generate_handler![...].

  • Step 2: layoutTypes

In app/src/layoutTypes.ts, add to the WorkspaceView interface (after layout):

  zoomed: string | null;
  • Step 3: socketBridge

In app/src/socketBridge.ts add:

export async function setZoom(workspaceId: string, surfaceId: string | null): Promise<void> {
  await invoke("set_zoom", { workspaceId, surfaceId });
}
  • Step 4: LayoutEngine zoom render + header toggle

In app/src/LayoutEngine.tsx:

  • Import Minimize2 alongside Maximize2 from lucide-react; import setZoom from ./socketBridge.
  • Add zoomed to the Props interface and the LayoutEngine signature: zoomed: string | null; and thread workspaceId+zoomed down (they are already in scope in LayoutEngine; pass zoomed to the top-level Node via a new prop OR short-circuit before rendering the tree).
  • At the top of LayoutEngine, after the if (!layout) guard, short-circuit on zoom:
  if (zoomed) {
    return (
      <div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
        <Node workspaceId={workspaceId} node={{ leaf: { surface_id: zoomed } }} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
      </div>
    );
  }
  • Thread a zoomed: string | null prop through Node (add to its destructured props and its type, and pass zoomed={zoomed} in the recursive Node render inside the split .map). In the leaf branch, replace the mock zoom icon line:
          <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom (mock)" />

with a real toggle:

          {zoomed === id
            ? <Minimize2 size={13} color={COLORS.textSecondary} style={{ cursor: "pointer" }} aria-label="Unzoom"
                onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, null); }} />
            : <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom"
                onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, id); }} />}

(workspaceId is already a prop of Node. e.stopPropagation() prevents the card's onMouseDown focus from firing.)

  • Step 5: App passes zoomed

In app/src/App.tsx, update the LayoutEngine render to pass zoomed={active.zoomed}:

              ? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} />
  • Step 6: Build + commit

Run: cd app && npm run build → clean. cd back.

git add app/src-tauri/src/bridge.rs app/src-tauri/src/lib.rs app/src/layoutTypes.ts app/src/socketBridge.ts app/src/LayoutEngine.tsx app/src/App.tsx
git commit -m "feat(app): panel zoom — full-grid render + header toggle"

SP3 — Scrollback search

Task 7: search addon + registry in TerminalView

Files: Add dependency; Create app/src/searchRegistry.ts; Modify app/src/TerminalView.tsx

  • Step 1: Install the addon

Run: cd app && npm install @xterm/addon-search && cd .. Expected: adds @xterm/addon-search to app/package.json dependencies.

  • Step 2: Create the registry

Create app/src/searchRegistry.ts:

import type { SearchAddon } from "@xterm/addon-search";

/** Maps a surfaceId to its terminal's SearchAddon so the search bar can reach
 * the focused panel without prop-drilling through the layout tree. */
const registry = new Map<string, SearchAddon>();

export function registerSearch(surfaceId: string, addon: SearchAddon): void {
  registry.set(surfaceId, addon);
}

export function unregisterSearch(surfaceId: string): void {
  registry.delete(surfaceId);
}

export function getSearch(surfaceId: string): SearchAddon | undefined {
  return registry.get(surfaceId);
}
  • Step 3: Wire it into TerminalView

In app/src/TerminalView.tsx:

  • Add imports:
import { SearchAddon } from "@xterm/addon-search";
import { registerSearch, unregisterSearch } from "./searchRegistry";
  • Construct the terminal with a large scrollback:
    const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000 });
  • After term.open(ref.current); (and the WebGL block), load + register the search addon:
    const search = new SearchAddon();
    term.loadAddon(search);
    registerSearch(surfaceId, search);
  • In the cleanup return, before term.dispose(); add:
      unregisterSearch(surfaceId);
  • Step 4: Build (no committed UI yet, just verify it compiles)

Run: cd app && npm run build → clean. cd back.

  • Step 5: Commit
git add app/package.json app/package-lock.json app/src/searchRegistry.ts app/src/TerminalView.tsx
git commit -m "feat(app): load xterm search addon + surface→addon registry"

Task 8: SearchBar + ⌘F wiring

Files: Create app/src/SearchBar.tsx; Modify app/src/CenterToolbar.tsx, app/src/App.tsx

  • Step 1: Create SearchBar

Create app/src/SearchBar.tsx:

import { useEffect, useRef, useState } from "react";
import { ChevronUp, ChevronDown, X } from "lucide-react";
import { COLORS, FONT } from "./theme";
import { getSearch } from "./searchRegistry";

const DECORATIONS = {
  matchBackground: "#5A4A1F",
  matchOverviewRuler: "#F2B84B",
  activeMatchBackground: "#F2B84B",
  activeMatchColorOverviewRuler: "#F2B84B",
};

export function SearchBar({ surfaceId, onClose }: { surfaceId: string | null; onClose: () => void }) {
  const [term, setTerm] = useState("");
  const [count, setCount] = useState({ index: -1, total: 0 });
  const inputRef = useRef<HTMLInputElement>(null);

  // Subscribe to result changes for the active surface's addon.
  useEffect(() => {
    inputRef.current?.focus();
    if (!surfaceId) return;
    const addon = getSearch(surfaceId);
    if (!addon) return;
    const sub = addon.onDidChangeResults((r) => setCount({ index: r.resultIndex, total: r.resultCount }));
    return () => { sub.dispose(); addon.clearDecorations(); };
  }, [surfaceId]);

  function run(forward: boolean) {
    if (!surfaceId) return;
    const addon = getSearch(surfaceId);
    if (!addon || !term) { addon?.clearDecorations(); setCount({ index: -1, total: 0 }); return; }
    const opts = { decorations: DECORATIONS };
    if (forward) addon.findNext(term, opts); else addon.findPrevious(term, opts);
  }

  return (
    <div style={{
      position: "absolute", top: 8, right: 16, zIndex: 50,
      display: "flex", alignItems: "center", gap: 6, height: 32, padding: "0 8px",
      background: COLORS.bgElevated, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8,
      boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
    }}>
      <input ref={inputRef} value={term}
        onChange={(e) => { setTerm(e.target.value); }}
        onKeyDown={(e) => {
          if (e.key === "Enter") { e.preventDefault(); run(!e.shiftKey); }
          else if (e.key === "Escape") { e.preventDefault(); onClose(); }
        }}
        placeholder="Search scrollback"
        style={{ width: 200, background: "transparent", border: "none", outline: "none", color: COLORS.textPrimary, fontFamily: FONT.ui, fontSize: 13 }} />
      <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, minWidth: 40, textAlign: "right" }}>
        {count.total > 0 ? `${count.index + 1}/${count.total}` : "0/0"}
      </span>
      <ChevronUp size={15} color={COLORS.textSecondary} style={{ cursor: "pointer" }} onClick={() => run(false)} />
      <ChevronDown size={15} color={COLORS.textSecondary} style={{ cursor: "pointer" }} onClick={() => run(true)} />
      <X size={15} color={COLORS.textMuted} style={{ cursor: "pointer" }} onClick={onClose} />
    </div>
  );
}
  • Step 2: CenterToolbar opens search

In app/src/CenterToolbar.tsx, add an onOpenSearch: () => void prop and wire it to the pill's onClick:

export function CenterToolbar({ selected, onSelect, onOpenSearch }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void }) {

On the scrollback pill div, add onClick={onOpenSearch} (it already has cursor: "pointer").

  • Step 3: App wires ⌘F + renders SearchBar

In app/src/App.tsx:

  • Import SearchBar.
  • Add state: const [searchOpen, setSearchOpen] = useState(false);
  • Add a global keydown effect:
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
        if (activeRef.current) { e.preventDefault(); setSearchOpen(true); }
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);
  • Pass onOpenSearch={() => setSearchOpen(true)} to CenterToolbar.
  • Render the bar inside the grid column container (the <div style={{ flex: 1, minHeight: 0 }}> that holds LayoutEngine), making that container position: "relative" and adding:
            {searchOpen && active && <SearchBar surfaceId={effectiveFocus} onClose={() => setSearchOpen(false)} />}

Concretely, change that wrapper to <div style={{ flex: 1, minHeight: 0, position: "relative" }}> and place the SearchBar line after the LayoutEngine/empty-state ternary.

  • Step 4: Build + commit

Run: cd app && npm run build → clean. cd back.

git add app/src/SearchBar.tsx app/src/CenterToolbar.tsx app/src/App.tsx
git commit -m "feat(app): scrollback search bar (⌘F) on the focused panel"

Final

Task 9: manual scenarios + full verification

Files: Modify DOCS/RUNNING.md

  • Step 1: Full verification

Run: cargo test --workspace → all green (kill stale daemon + rm -f ~/.spacesh/daemon.lock if only the lock test fails). Run: cd app && npm run build → clean. cd back.

  • Step 2: Document scenarios in DOCS/RUNNING.md

Add a subsection after the SP2 scenario (Russian, matching the doc's style):

### SP1/SP3/SP4 — health, поиск, zoom
- **Health (SP1):** футер сайдбара показывает `spaceshd · live` с зелёной точкой и аптайм (`3d 4h`); версия — в tooltip. При падении демона точка сереет, текст `offline`.
- **Поиск (SP3):** `⌘F` (или клик по пилюле «Search scrollback») открывает строку поиска над активной панелью. Печатай → совпадения подсвечиваются, `Enter`/`Shift+Enter` — next/prev, счётчик `i/N`, `Esc` — закрыть. Поиск идёт по буферу xterm активной панели (scrollback до 10000 строк).
- **Zoom (SP4):** иконка `⤢` в шапке панели разворачивает её на весь грид; `⤡` возвращает. Состояние персистится — переживает рестарт демона; при закрытии развёрнутой панели zoom сбрасывается.

Update §9 "Известные ограничения": remove "зум ... не реализованы" and "поиск по скроллбэку ... не реализованы"; note scrollback search is xterm-buffer-scoped (active panel, up to 10000 lines), and daemon-side/CLI grid search remains future work.

  • Step 3: Commit
git add DOCS/RUNNING.md
git commit -m "docs: SP1/SP3/SP4 manual scenarios and updated limitations"
  • Step 4: Final review handoff

Dispatch a final code reviewer across the SP1/SP3/SP4 commits before merging the branch.


Notes for the implementer

  • Branch: work on spacesh-sp1-sp3-sp4 (already created).
  • Env lock test: lifecycle::tests::lock_is_exclusive_within_process fails if a real daemon holds ~/.spacesh/daemon.lock; that is environmental — pkill -f "target/debug/spaceshd"; rm -f ~/.spacesh/daemon.lock and re-run.
  • TDD: Rust tasks write the test first. Frontend tasks verify via npm run build + the manual scenario.
  • Independence: SP1, SP4, SP3 are independent; if one task group stalls, the others still merge cleanly.