# 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub font_size: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct AppearanceConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub theme: Option, // "dark" | "light" #[serde(default, skip_serializing_if = "Option::is_none")] pub accent: Option, // 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, #[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, #[serde(default, skip_serializing_if = "Option::is_none")] font_family: Option, #[serde(default, skip_serializing_if = "Option::is_none")] font_size: Option, #[serde(default, skip_serializing_if = "Option::is_none")] theme: Option, #[serde(default, skip_serializing_if = "Option::is_none")] accent: Option, }, ``` 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 { 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, font_family: Option, font_size: Option, theme: Option, accent: Option) -> Result { 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 { return await invoke("get_config"); } export async function setConfig(patch: Partial>): Promise { 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; 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 = { 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 { 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(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` (from `resolvePalette`). Thread `config` from App → `LayoutEngine` (add to Props + `shared` + Leaf, mirroring the search props) → ``. 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(null); useEffect(() => { ref.current?.focus(); }, []); return (
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 }}>
Settings
{/* Terminal */}
Terminal font
Size {config.font_size} void setConfig({ font_size: Number(e.target.value) })} style={{ flex: 1 }} />
{/* Appearance */}
Theme
{(["dark", "light"] as const).map((t) => ( ))}
Accent
{ACCENTS.map((a) => (
{/* Shell */}
Default shell (empty = auto)
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 */}
); } ``` > 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 ``, and render: ```tsx {settingsOpen && config && 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 { 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 { 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); return (
Daemon
{health ? (<>
version {health.version} · pid {health.pid}
uptime {fmtUptime(health.started_at_ms)}
) :
offline
}
{confirm && (
{confirm === "stop" ? "Stop the daemon? All sessions end." : "Restart the daemon? Sessions end and respawn."}
)}
); } ``` - [ ] **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).