Files
spaceshell/DOCS/superpowers/plans/2026-06-14-settings-modal.md
vasyansk 052f484142 docs: settings modal implementation plan
11 TDD tasks: daemon config model/save, ConfigView + Get/Set/ConfigChanged
protocol, bridge + socketBridge, CSS-var theming, TerminalView font/theme,
settings modal, daemon Stop/Restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:05:22 +07:00

34 KiB

Settings Modal 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: A Settings modal that configures terminal font, appearance (Dark/Light + accent), default shell, and daemon status/control — persisted in ~/.spacesh/config.toml via the daemon and applied live to all clients.

Architecture: The daemon owns config (load/save config.toml, hold it in server state). New GetConfig/SetConfig commands and a ConfigChanged event push changes to clients. The GUI is a thin view: a Settings modal reads/writes config and applies theme via CSS custom properties (low-churn — COLORS.* keys keep, values become var(--c-*)), plus xterm font/theme from resolved colors.

Tech Stack: Rust (tokio, serde, toml), spacesh-proto JSON protocol, Tauri 2 bridge, React 18 + TypeScript, @xterm/xterm.

Spec: docs/superpowers/specs/2026-06-14-settings-modal-design.md


File Structure

  • crates/spaceshd/src/config.rs — extend Config with terminal/appearance sections; add save() and an effective-config accessor.
  • crates/spacesh-proto/src/message.rsCmd::GetConfig, Cmd::SetConfig, Evt::ConfigChanged.
  • crates/spacesh-proto/src/config_view.rs (new) — wire type ConfigView shared by daemon + clients.
  • crates/spaceshd/src/server.rs — hold Config in router state; handle GetConfig/SetConfig; consult in-memory config in default_shell.
  • app/src-tauri/src/bridge.rsget_config / set_config Tauri commands.
  • app/src/socketBridge.tsgetConfig/setConfig + config_changed event type.
  • app/src/theme.tsCOLORSvar(--c-*), PALETTES, ACCENTS, applyTheme, resolvePalette.
  • app/src/TerminalView.tsx — font + xterm theme from config; live re-apply.
  • app/src/Settings.tsx (new) — the modal.
  • app/src/App.tsx — apply theme on load + config_changed; wire gear → Settings; pass config to TerminalView.

Task 1: Daemon config model — terminal + appearance sections

Files:

  • Modify: crates/spaceshd/src/config.rs

  • Step 1: Write the failing tests

Add to the tests module in crates/spaceshd/src/config.rs:

    #[test]
    fn parses_terminal_and_appearance() {
        let dir = std::env::temp_dir().join("spacesh-cfg-sections");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("config.toml");
        std::fs::write(&path,
            "default_shell = \"/bin/zsh\"\n[terminal]\nfont_family = \"Menlo\"\nfont_size = 15\n[appearance]\ntheme = \"light\"\naccent = \"teal\"\n").unwrap();
        let c = Config::from_path(&path);
        assert_eq!(c.terminal.font_family.as_deref(), Some("Menlo"));
        assert_eq!(c.terminal.font_size, Some(15));
        assert_eq!(c.appearance.theme.as_deref(), Some("light"));
        assert_eq!(c.appearance.accent.as_deref(), Some("teal"));
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn missing_sections_default() {
        let c = Config::from_path(Path::new("/no/such/cfg.toml"));
        assert!(c.terminal.font_family.is_none());
        assert!(c.appearance.theme.is_none());
    }

    #[test]
    fn save_then_reload_round_trips() {
        let dir = std::env::temp_dir().join("spacesh-cfg-save");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("config.toml");
        let mut c = Config::default();
        c.terminal.font_size = Some(14);
        c.appearance.accent = Some("purple".into());
        c.save_to(&path).unwrap();
        let back = Config::from_path(&path);
        assert_eq!(back.terminal.font_size, Some(14));
        assert_eq!(back.appearance.accent.as_deref(), Some("purple"));
        let _ = std::fs::remove_file(&path);
    }
  • Step 2: Run tests to verify they fail

Run: cargo test -p spaceshd config 2>&1 | tail Expected: compile error (terminal/appearance/save_to unknown).

  • Step 3: Extend Config with sections + serialization

In crates/spaceshd/src/config.rs, replace the Config struct and add section structs + Serialize (for save):

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct TerminalConfig {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub font_family: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub font_size: Option<u16>,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct AppearanceConfig {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub theme: Option<String>,   // "dark" | "light"
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub accent: Option<String>,  // blue|teal|purple|green|orange
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Config {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_shell: Option<String>,
    #[serde(default)]
    pub terminal: TerminalConfig,
    #[serde(default)]
    pub appearance: AppearanceConfig,
}

(Change the existing use serde::Deserialize; to the Serialize, Deserialize line above.)

  • Step 4: Add save_to / save

Add to impl Config:

    /// Persist to `~/.spacesh/config.toml`.
    pub fn save(&self) -> std::io::Result<()> {
        let dir = crate::lifecycle::spacesh_dir()?;
        self.save_to(&dir.join("config.toml"))
    }

    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        let s = toml::to_string_pretty(self)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
        std::fs::write(path, s)
    }
  • Step 5: Run tests to verify they pass

Run: cargo test -p spaceshd config 2>&1 | tail Expected: PASS (all config tests).

  • Step 6: Commit
git add crates/spaceshd/src/config.rs
git commit -m "feat(spaceshd): config terminal+appearance sections and save"

Task 2: Shared ConfigView wire type

Files:

  • Create: crates/spacesh-proto/src/config_view.rs

  • Modify: crates/spacesh-proto/src/lib.rs (module export)

  • Step 1: Create the wire type

crates/spacesh-proto/src/config_view.rs:

use serde::{Deserialize, Serialize};

/// Effective (resolved) daemon configuration sent to clients.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConfigView {
    pub default_shell: String,
    pub font_family: String,
    pub font_size: u16,
    pub theme: String,
    pub accent: String,
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn config_view_round_trips() {
        let c = ConfigView {
            default_shell: "/bin/zsh".into(), font_family: "JetBrains Mono".into(),
            font_size: 13, theme: "dark".into(), accent: "blue".into(),
        };
        let back: ConfigView = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
        assert_eq!(back, c);
    }
}
  • Step 2: Export the module

In crates/spacesh-proto/src/lib.rs, add alongside the other pub mod lines:

pub mod config_view;

And add a re-export next to the existing ones (match the file's style, e.g. pub use config_view::ConfigView;).

  • Step 3: Run test

Run: cargo test -p spacesh-proto config_view 2>&1 | tail Expected: PASS.

  • Step 4: Commit
git add crates/spacesh-proto/src/config_view.rs crates/spacesh-proto/src/lib.rs
git commit -m "feat(proto): ConfigView wire type"

Task 3: Protocol — GetConfig / SetConfig / ConfigChanged

Files:

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

  • Step 1: Add the commands and event

In crates/spacesh-proto/src/message.rs, add to the Cmd enum (near Shutdown):

    GetConfig,
    SetConfig {
        #[serde(default, skip_serializing_if = "Option::is_none")]
        default_shell: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        font_family: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        font_size: Option<u16>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        theme: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        accent: Option<String>,
    },

Add to the Evt enum (after EventsRead):

    ConfigChanged { config: crate::config_view::ConfigView },
  • Step 2: Build to verify the protocol compiles

Run: cargo build -p spacesh-proto 2>&1 | tail Expected: builds (the daemon won't yet — handled in Task 4).

  • Step 3: Commit
git add crates/spacesh-proto/src/message.rs
git commit -m "feat(proto): GetConfig/SetConfig commands and ConfigChanged event"

Task 4: Daemon — hold config in state, handle Get/Set, broadcast

Files:

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

  • Modify: crates/spaceshd/src/config.rs (effective accessor)

  • Step 1: Add an effective-config accessor on Config

In crates/spaceshd/src/config.rs add to impl Config:

    /// Resolve to a client-facing view, applying defaults and the shell resolver.
    pub fn to_view(&self) -> spacesh_proto::config_view::ConfigView {
        spacesh_proto::config_view::ConfigView {
            default_shell: self.resolved_shell(),
            font_family: self.terminal.font_family.clone().unwrap_or_else(|| "JetBrains Mono".into()),
            font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20),
            theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()),
            accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()),
        }
    }

    /// Shell for a plain panel using THIS in-memory config (env → config → passwd → $SHELL → /bin/sh).
    pub fn resolved_shell(&self) -> String {
        if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } }
        if let Some(s) = &self.default_shell { if !s.is_empty() { return s.clone(); } }
        if let Some(s) = login_shell() { return s; }
        if let Ok(s) = std::env::var("SHELL") { if !s.is_empty() { return s; } }
        "/bin/sh".into()
    }

Keep the existing free default_shell() (used by spawn sites) — it already loads config from disk; leave it for now. Note in a comment that resolved_shell is the in-memory variant.

  • Step 2: Hold a Config in the router and thread it into handle_request

In crates/spaceshd/src/server.rs, in the router setup (near where persister/clients are created, around line 126-135), load config once:

    let mut config = crate::config::Config::load();

Add config: &mut crate::config::Config to the handle_request signature (line 264) and pass &mut config at its call site in the router loop.

  • Step 3: Implement the handlers

In the match cmd of handle_request (e.g. after the Cmd::Shutdown arm), add:

        Cmd::GetConfig => {
            let view = config.to_view();
            let _ = out.send(ok(id, serde_json::to_value(view).unwrap_or(serde_json::Value::Null))).await;
        }
        Cmd::SetConfig { default_shell, font_family, font_size, theme, accent } => {
            if let Some(v) = default_shell { config.default_shell = if v.is_empty() { None } else { Some(v) }; }
            if let Some(v) = font_family { config.terminal.font_family = Some(v); }
            if let Some(v) = font_size { config.terminal.font_size = Some(v.clamp(10, 20)); }
            if let Some(v) = theme {
                if v != "dark" && v != "light" { let _ = out.send(err(id, "BAD_CONFIG", "theme")).await; return; }
                config.appearance.theme = Some(v);
            }
            if let Some(v) = accent {
                const ACCENTS: [&str; 5] = ["blue", "teal", "purple", "green", "orange"];
                if !ACCENTS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "accent")).await; return; }
                config.appearance.accent = Some(v);
            }
            let _ = config.save();
            let view = config.to_view();
            broadcast_evt(clients, &Envelope::Evt(Evt::ConfigChanged { config: view }));
            let _ = out.send(ok(id, serde_json::Value::Null)).await;
        }
  • Step 4: Build the daemon

Run: cargo build -p spaceshd 2>&1 | tail Expected: builds.

  • Step 5: Add a handler test (config round-trips through Set→Get)

If server.rs has an integration-test harness (look for existing #[tokio::test] that drives handle_request or a client), add a test that sends SetConfig { font_size: Some(15), .. } then GetConfig and asserts font_size == 15. If the harness shape is unclear, instead assert via config.to_view() in a focused unit test in config.rs:

    #[test]
    fn to_view_applies_defaults_and_clamp() {
        let mut c = Config::default();
        c.terminal.font_size = Some(99);
        let v = c.to_view();
        assert_eq!(v.font_size, 20);              // clamped
        assert_eq!(v.theme, "dark");              // default
        assert_eq!(v.accent, "blue");             // default
        assert!(!v.font_family.is_empty());
    }
  • Step 6: Run tests

Run: cargo test -p spaceshd 2>&1 | tail Expected: PASS.

  • Step 7: Commit
git add crates/spaceshd/src/server.rs crates/spaceshd/src/config.rs
git commit -m "feat(spaceshd): GetConfig/SetConfig handlers with live ConfigChanged broadcast"

Task 5: Tauri bridge — get_config / set_config

Files:

  • Modify: app/src-tauri/src/bridge.rs

  • Modify: the Tauri command registration list (search invoke_handler / generate_handler! in app/src-tauri/src/).

  • Step 1: Add the bridge commands

In app/src-tauri/src/bridge.rs, mirroring set_workspace_meta:

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

#[tauri::command]
pub async fn set_config(state: BridgeState<'_>, default_shell: Option<String>, font_family: Option<String>, font_size: Option<u16>, theme: Option<String>, accent: Option<String>) -> Result<Value, String> {
    data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent }).await.map_err(|e| e.to_string())?)
}
  • Step 2: Register the commands

Find the tauri::generate_handler![...] macro (grep generate_handler in app/src-tauri/src/) and add bridge::get_config, bridge::set_config to the list (match the existing bridge:: prefix style).

  • Step 3: Build the bridge

Run: cd app/src-tauri && cargo build 2>&1 | tail Expected: builds.

  • Step 4: Commit
git add app/src-tauri/src
git commit -m "feat(app): tauri get_config/set_config bridge commands"

Task 6: socketBridge — getConfig/setConfig + config_changed event

Files:

  • Modify: app/src/socketBridge.ts

  • Step 1: Add the config types, fns, and event variant

In app/src/socketBridge.ts add:

export interface ConfigView {
  default_shell: string;
  font_family: string;
  font_size: number;
  theme: "dark" | "light";
  accent: string;
}

export async function getConfig(): Promise<ConfigView> {
  return await invoke<ConfigView>("get_config");
}

export async function setConfig(patch: Partial<Pick<ConfigView, "default_shell" | "font_family" | "font_size" | "theme" | "accent">>): Promise<void> {
  await invoke("set_config", {
    defaultShell: patch.default_shell ?? null,
    fontFamily: patch.font_family ?? null,
    fontSize: patch.font_size ?? null,
    theme: patch.theme ?? null,
    accent: patch.accent ?? null,
  });
}

Add to the DaemonEvent union (next to workspace_changed):

  | { evt: "config_changed"; data: { config: ConfigView } }
  • Step 2: Typecheck

Run: cd app && npx tsc --noEmit Expected: No errors.

  • Step 3: Commit
git add app/src/socketBridge.ts
git commit -m "feat(app): socketBridge getConfig/setConfig + config_changed"

Task 7: theme.ts — CSS variables, palettes, applyTheme, resolvePalette

Files:

  • Modify: app/src/theme.ts

  • Step 1: Convert COLORS to var() refs and add palettes

Rewrite app/src/theme.ts so COLORS/STATE_COLOR keep their KEYS but map to CSS vars, and add palettes + helpers. Keep FONT as-is. Example (extend to every existing key — do not drop any key currently in the file):

export const COLORS = {
  accent: "var(--c-accent)",
  bgApp: "var(--c-bg-app)",
  bgElevated: "var(--c-bg-elevated)",
  bgSidebar: "var(--c-bg-sidebar)",
  bgPanel: "var(--c-bg-panel)",
  borderStrong: "var(--c-border-strong)",
  borderSubtle: "var(--c-border-subtle)",
  textPrimary: "var(--c-text-primary)",
  textSecondary: "var(--c-text-secondary)",
  textMuted: "var(--c-text-muted)",
  stWork: "var(--c-st-work)",
  stWait: "var(--c-st-wait)",
  stDone: "var(--c-st-done)",
  stError: "var(--c-st-error)",
  stIdle: "var(--c-st-idle)",
  searchMatch: "var(--c-search-match)",
} as const;

type Palette = Record<string, string>;

const DARK: Palette = {
  "bg-app": "#0E1116", "bg-elevated": "#1A2029", "bg-sidebar": "#0C0F14",
  "bg-panel": "#0A0D12", "border-strong": "#323C49", "border-subtle": "#232A33",
  "text-primary": "#E6EDF3", "text-secondary": "#8B97A6", "text-muted": "#5A6573",
  "st-work": "#4C8DFF", "st-wait": "#F2B84B", "st-done": "#3FB950",
  "st-error": "#F4544E", "st-idle": "#5A6573", "search-match": "#5A4A1F",
};

const LIGHT: Palette = {
  "bg-app": "#FFFFFF", "bg-elevated": "#F1F3F6", "bg-sidebar": "#F6F8FA",
  "bg-panel": "#FFFFFF", "border-strong": "#C5CDD6", "border-subtle": "#E2E7EC",
  "text-primary": "#1A2029", "text-secondary": "#55606E", "text-muted": "#8B97A6",
  "st-work": "#2F6FE0", "st-wait": "#B9831A", "st-done": "#2DA44E",
  "st-error": "#D1322C", "st-idle": "#8B97A6", "search-match": "#FFE9A8",
};

const ACCENTS: Record<string, string> = {
  blue: "#4C8DFF", teal: "#34D3C2", purple: "#9B7BFF", green: "#3FB950", orange: "#F2934B",
};

export type ThemeName = "dark" | "light";

/** Real color values for consumers that can't use var() (xterm). */
export function resolvePalette(theme: ThemeName, accent: string): Record<string, string> {
  const base = theme === "light" ? LIGHT : DARK;
  return { ...base, accent: ACCENTS[accent] ?? ACCENTS.blue };
}

/** Write the active palette to :root as --c-* custom properties. */
export function applyTheme(theme: ThemeName, accent: string): void {
  const p = resolvePalette(theme, accent);
  const root = document.documentElement.style;
  for (const [k, v] of Object.entries(p)) root.setProperty(`--c-${k}`, v);
}

Note: STATE_COLOR should keep its keys (work/wait/done/error/idle/stopped) and point at the matching COLORS.st* var refs, exactly as today but via the new var-based COLORS.

  • Step 2: Typecheck

Run: cd app && npx tsc --noEmit Expected: No errors. (Components compile unchanged — only COLORS values changed.)

  • Step 3: Commit
git add app/src/theme.ts
git commit -m "feat(app): CSS-variable theming with dark/light palettes and accents"

Task 8: App — apply theme on load and on config_changed; hold config

Files:

  • Modify: app/src/App.tsx

  • Step 1: Load config, apply theme, store in state

In app/src/App.tsx:

  • Import: import { getConfig, setConfig } from "./socketBridge"; import type { ConfigView } from "./socketBridge"; import { applyTheme } from "./theme";
  • Add state: const [config, setConfigState] = useState<ConfigView | null>(null);
  • In the initial effect (where refresh/loadHealth run), add a loader:
    void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
  • In the onDaemonEvent switch, add a branch:
      } else if (evt.evt === "config_changed") {
        const c = evt.data.config;
        setConfigState(c);
        applyTheme(c.theme, c.accent);
  • On reconnect (onDaemonRawEvent("spacesh:disconnected", …)) also re-fetch config: add void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});

  • Step 2: Typecheck

Run: cd app && npx tsc --noEmit Expected: No errors.

  • Step 3: Commit
git add app/src/App.tsx
git commit -m "feat(app): apply theme from daemon config on load and live"

Task 9: TerminalView — font + xterm theme from config

Files:

  • Modify: app/src/TerminalView.tsx

  • Modify: app/src/App.tsx (pass config down to LayoutEngine → TerminalView)

  • Step 1: Thread config to TerminalView

TerminalView takes a new optional prop font: { family: string; size: number } and palette: Record<string,string> (from resolvePalette). Thread config from App → LayoutEngine (add to Props + shared + Leaf, mirroring the search props) → <TerminalView … font={…} palette={…} />. When config is null, fall back to current hardcoded defaults.

  • Step 2: Use font + theme at terminal creation

In TerminalView.tsx, change the new Terminal({...}) to use the props:

    const term = new Terminal({
      fontFamily: font ? `'${font.family}', monospace` : "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
      fontSize: font?.size ?? 13,
      convertEol: false, scrollback: 10000, allowProposedApi: true,
      theme: palette ? {
        background: palette["bg-panel"], foreground: palette["text-primary"],
        cursor: palette["text-primary"], selectionBackground: palette["search-match"],
      } : undefined,
    });
  • Step 3: Live-apply on prop change

Add an effect that updates the live terminal when font/palette change (store the term in a ref so a second effect can mutate it):

    // after creating term and storing termRef.current = term
    // separate effect:
    useEffect(() => {
      const t = termRef.current; if (!t) return;
      if (font) { t.options.fontFamily = `'${font.family}', monospace`; t.options.fontSize = font.size; }
      if (palette) t.options.theme = { background: palette["bg-panel"], foreground: palette["text-primary"], cursor: palette["text-primary"], selectionBackground: palette["search-match"] };
      // refit after font change so rows/cols stay correct
      requestAnimationFrame(() => { try { fitRef.current?.fit(); } catch {} });
    }, [font?.family, font?.size, palette]);

(Introduce termRef/fitRef refs alongside the existing mount effect; keep the existing mount/attach logic intact.)

  • Step 4: Typecheck

Run: cd app && npx tsc --noEmit Expected: No errors.

  • Step 5: Commit
git add app/src/TerminalView.tsx app/src/LayoutEngine.tsx app/src/App.tsx
git commit -m "feat(app): terminal font and xterm theme from daemon config"

Task 10: Settings modal — Terminal / Appearance / Shell

Files:

  • Create: app/src/Settings.tsx

  • Modify: app/src/TopBar.tsx (gear → onOpenSettings)

  • Modify: app/src/App.tsx (settings open state + render)

  • Step 1: Create the modal

app/src/Settings.tsx — overlay/focus pattern like ConfirmDelete. Props: config: ConfigView, health: DaemonHealth | null, onClose. Each control calls setConfig({...}); rely on the config_changed broadcast to update the live config prop (so the modal reflects the daemon truth). Sections:

import { useEffect, useRef } from "react";
import { COLORS, FONT } from "./theme";
import { setConfig } from "./socketBridge";
import type { ConfigView } from "./socketBridge";
import type { DaemonHealth } from "./socketBridge";

const FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"];
const ACCENTS: { id: string; hex: string }[] = [
  { id: "blue", hex: "#4C8DFF" }, { id: "teal", hex: "#34D3C2" }, { id: "purple", hex: "#9B7BFF" },
  { id: "green", hex: "#3FB950" }, { id: "orange", hex: "#F2934B" },
];

export function Settings({ config, health, onClose }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => { ref.current?.focus(); }, []);
  return (
    <div onMouseDown={onClose} style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
      <div ref={ref} tabIndex={-1} onMouseDown={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") onClose(); }}
        style={{ width: 520, maxHeight: "80vh", overflowY: "auto", background: COLORS.bgApp, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 14, padding: 24, color: COLORS.textPrimary, fontFamily: FONT.ui }}>
        <div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>Settings</div>

        {/* Terminal */}
        <div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Terminal font</div>
        <select value={config.font_family} onChange={(e) => void setConfig({ font_family: e.target.value })}
          style={{ width: "100%", padding: 8, marginBottom: 10, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }}>
          {FONTS.map((f) => <option key={f} value={f}>{f}</option>)}
        </select>
        <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18 }}>
          <span style={{ fontSize: 12, color: COLORS.textSecondary }}>Size {config.font_size}</span>
          <input type="range" min={10} max={20} value={config.font_size} onChange={(e) => void setConfig({ font_size: Number(e.target.value) })} style={{ flex: 1 }} />
        </div>

        {/* Appearance */}
        <div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Theme</div>
        <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
          {(["dark", "light"] as const).map((t) => (
            <button key={t} onClick={() => void setConfig({ theme: t })}
              style={{ flex: 1, padding: "8px 0", borderRadius: 8, fontSize: 13, textTransform: "capitalize",
                background: config.theme === t ? COLORS.accent : COLORS.bgElevated, color: config.theme === t ? "#fff" : COLORS.textPrimary,
                border: `1px solid ${COLORS.borderStrong}` }}>{t}</button>
          ))}
        </div>
        <div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Accent</div>
        <div style={{ display: "flex", gap: 10, marginBottom: 18 }}>
          {ACCENTS.map((a) => (
            <button key={a.id} onClick={() => void setConfig({ accent: a.id })} aria-label={a.id}
              style={{ width: 26, height: 26, borderRadius: "50%", background: a.hex, cursor: "pointer",
                border: config.accent === a.id ? `2px solid ${COLORS.textPrimary}` : "2px solid transparent" }} />
          ))}
        </div>

        {/* Shell */}
        <div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Default shell (empty = auto)</div>
        <input defaultValue={config.default_shell} onBlur={(e) => void setConfig({ default_shell: e.target.value })}
          style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }} />

        {/* Daemon section is added in Task 11 */}
        <DaemonSection health={health} />
      </div>
    </div>
  );
}

Task 11 defines DaemonSection. For Step 1, temporarily render null in its place; replace in Task 11. To keep the file compiling now, add function DaemonSection(_: { health: DaemonHealth | null }) { return null; } at the bottom and flesh it out in Task 11.

  • Step 2: Wire the gear

In app/src/TopBar.tsx, the Settings gear IconBtn gets onClick={onOpenSettings} and TopBar gains an onOpenSettings: () => void prop (mirror how onShowEvents was added).

In app/src/App.tsx: add const [settingsOpen, setSettingsOpen] = useState(false);, pass onOpenSettings={() => setSettingsOpen(true)} to <TopBar>, and render:

      {settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} />}
  • Step 3: Typecheck

Run: cd app && npx tsc --noEmit Expected: No errors.

  • Step 4: Commit
git add app/src/Settings.tsx app/src/TopBar.tsx app/src/App.tsx
git commit -m "feat(app): settings modal — terminal, appearance, shell"

Task 11: Daemon section — status + Stop / Restart

Files:

  • Modify: app/src/Settings.tsx

  • Modify: app/src/socketBridge.ts (shutdown + reconnect helpers if missing)

  • Step 1: Add shutdown/restart bridge helpers

In app/src/socketBridge.ts, confirm/add a shutdown call. If a shutdown Tauri command does not exist, add a bridge command shutdown_daemon that sends Cmd::Shutdown (in bridge.rs, mirror get_config), register it, and expose:

export async function shutdownDaemon(): Promise<void> { await invoke("shutdown_daemon"); }

For restart, after shutdown the bridge's ensure_daemon respawns on the next request; expose a no-op "ping" (getHealth) to trigger reconnect:

export async function restartDaemon(): Promise<void> {
  await shutdownDaemon();
  // give the old process time to exit, then a request triggers ensure_daemon respawn
  await new Promise((r) => setTimeout(r, 600));
  try { await getHealth(); } catch { /* reconnect loop will retry */ }
}
  • Step 2: Implement DaemonSection

Replace the placeholder DaemonSection in Settings.tsx:

import { shutdownDaemon, restartDaemon } from "./socketBridge";
import { useState } from "react";

function fmtUptime(ms: number): string {
  const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
  if (s < 60) return `${s}s`;
  if (s < 3600) return `${Math.floor(s / 60)}m`;
  return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
}

function DaemonSection({ health }: { health: DaemonHealth | null }) {
  const [confirm, setConfirm] = useState<null | "stop" | "restart">(null);
  return (
    <div style={{ marginTop: 8, paddingTop: 16, borderTop: `1px solid ${COLORS.borderSubtle}` }}>
      <div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>Daemon</div>
      <div style={{ fontFamily: FONT.mono, fontSize: 12, color: COLORS.textSecondary, lineHeight: 1.7 }}>
        {health ? (<>
          <div>version {health.version} · pid {health.pid}</div>
          <div>uptime {fmtUptime(health.started_at_ms)}</div>
        </>) : <div>offline</div>}
      </div>
      <div style={{ display: "flex", gap: 8, marginTop: 12 }}>
        <button onClick={() => setConfirm("restart")} style={{ padding: "7px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 13 }}>Restart</button>
        <button onClick={() => setConfirm("stop")} style={{ padding: "7px 14px", background: "transparent", color: COLORS.stError, border: `1px solid ${COLORS.stError}`, borderRadius: 7, fontSize: 13 }}>Stop</button>
      </div>
      {confirm && (
        <div style={{ marginTop: 10, padding: 10, borderRadius: 8, background: COLORS.bgPanel, border: `1px solid ${COLORS.borderStrong}` }}>
          <div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>
            {confirm === "stop" ? "Stop the daemon? All sessions end." : "Restart the daemon? Sessions end and respawn."}
          </div>
          <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
            <button onClick={() => setConfirm(null)} style={{ padding: "5px 12px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 6, fontSize: 12 }}>Cancel</button>
            <button onClick={() => { const c = confirm; setConfirm(null); void (c === "stop" ? shutdownDaemon() : restartDaemon()); }}
              style={{ padding: "5px 12px", background: COLORS.stError, color: "#fff", border: "none", borderRadius: 6, fontSize: 12, fontWeight: 600 }}>
              {confirm === "stop" ? "Stop" : "Restart"}
            </button>
          </div>
        </div>
      )}
    </div>
  );
}
  • Step 3: Typecheck + build

Run: cd app && npx tsc --noEmit then cd app/src-tauri && cargo build 2>&1 | tail Expected: No errors; builds.

  • Step 4: Manual verification (tauri dev)

Run the app, open the gear:

  • Change font/size → terminals reflow with the new font live.

  • Toggle Light → whole UI + xterm switch palettes; switch accent → focus border/active match recolor.

  • Set default shell → new panels use it.

  • Restart → daemon respawns, sessions reattach via snapshot; Stop → shows offline.

  • Step 5: Commit

git add app/src/Settings.tsx app/src/socketBridge.ts app/src-tauri/src
git commit -m "feat(app): daemon status with Stop/Restart in settings"

Self-Review Notes

  • Spec §1 config model → Task 1. §2 protocol → Tasks 2-3. §3 daemon handlers → Task 4. §4 theme → Tasks 7-8. §5 TerminalView → Task 9. §6 modal → Tasks 10-11. §7 Stop/Restart → Task 11. All covered.
  • ConfigView field names (default_shell, font_family, font_size, theme, accent) are identical across proto (Task 2), bridge (Task 5), socketBridge (Task 6), and Settings (Tasks 10-11).
  • applyTheme(theme, accent) / resolvePalette(theme, accent) signatures consistent across Tasks 7-9.
  • Restart depends on ensure_daemon respawn (verified present in bridge.rs) or launchd KeepAlive (verified true in launchd.rs). If neither fires in dev (daemon run via cargo run, not launchd, and bridge already connected), the 600ms+getHealth in restartDaemon triggers ensure_daemon on the next request — validate in Task 11 Step 4.
  • Light-mode xterm ANSI colors beyond fg/bg are left at xterm defaults in this plan; if output is unreadable on light bg, extend the theme object with ANSI entries (follow-up, not blocking).