# 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.rs` — `Cmd::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.rs` — `get_config` / `set_config` Tauri commands.
- `app/src/socketBridge.ts` — `getConfig`/`setConfig` + `config_changed` event type.
- `app/src/theme.ts` — `COLORS` → `var(--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`:

```rust
    #[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):

```rust
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`:

```rust
    /// 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**

```bash
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`:

```rust
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:

```rust
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**

```bash
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`):

```rust
    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`):

```rust
    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**

```bash
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`:

```rust
    /// 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:

```rust
    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:

```rust
        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`:

```rust
    #[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**

```bash
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`:

```rust
#[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**

```bash
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:

```ts
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`):

```ts
  | { evt: "config_changed"; data: { config: ConfigView } }
```

- [ ] **Step 2: Typecheck**

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

- [ ] **Step 3: Commit**

```bash
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):

```ts
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**

```bash
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:

```tsx
    void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
```

- In the `onDaemonEvent` switch, add a branch:

```tsx
      } 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**

```bash
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:

```tsx
    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):

```tsx
    // 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**

```bash
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:

```tsx
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:

```tsx
      {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**

```bash
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:

```ts
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:

```ts
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`:

```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**

```bash
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).
