From 052f4841420a5dc44a50daba8e5fb5c8db6c205c Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 09:05:22 +0700 Subject: [PATCH] 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) --- .../plans/2026-06-14-settings-modal.md | 846 ++++++++++++++++++ 1 file changed, 846 insertions(+) create mode 100644 DOCS/superpowers/plans/2026-06-14-settings-modal.md diff --git a/DOCS/superpowers/plans/2026-06-14-settings-modal.md b/DOCS/superpowers/plans/2026-06-14-settings-modal.md new file mode 100644 index 0000000..cf60bcf --- /dev/null +++ b/DOCS/superpowers/plans/2026-06-14-settings-modal.md @@ -0,0 +1,846 @@ +# 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).