11 TDD tasks: daemon config model/save, ConfigView + Get/Set/ConfigChanged protocol, bridge + socketBridge, CSS-var theming, TerminalView font/theme, settings modal, daemon Stop/Restart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
34 KiB
Settings Modal Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A Settings modal that configures terminal font, appearance (Dark/Light + accent), default shell, and daemon status/control — persisted in ~/.spacesh/config.toml via the daemon and applied live to all clients.
Architecture: The daemon owns config (load/save config.toml, hold it in server state). New GetConfig/SetConfig commands and a ConfigChanged event push changes to clients. The GUI is a thin view: a Settings modal reads/writes config and applies theme via CSS custom properties (low-churn — COLORS.* keys keep, values become var(--c-*)), plus xterm font/theme from resolved colors.
Tech Stack: Rust (tokio, serde, toml), spacesh-proto JSON protocol, Tauri 2 bridge, React 18 + TypeScript, @xterm/xterm.
Spec: docs/superpowers/specs/2026-06-14-settings-modal-design.md
File Structure
crates/spaceshd/src/config.rs— extendConfigwith terminal/appearance sections; addsave()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 typeConfigViewshared by daemon + clients.crates/spaceshd/src/server.rs— holdConfigin router state; handleGetConfig/SetConfig; consult in-memory config indefault_shell.app/src-tauri/src/bridge.rs—get_config/set_configTauri commands.app/src/socketBridge.ts—getConfig/setConfig+config_changedevent 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:
#[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
Configwith sections + serialization
In crates/spaceshd/src/config.rs, replace the Config struct and add section structs + Serialize (for save):
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct TerminalConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<u16>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct AppearanceConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>, // "dark" | "light"
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<String>, // blue|teal|purple|green|orange
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_shell: Option<String>,
#[serde(default)]
pub terminal: TerminalConfig,
#[serde(default)]
pub appearance: AppearanceConfig,
}
(Change the existing use serde::Deserialize; to the Serialize, Deserialize line above.)
- Step 4: Add
save_to/save
Add to impl Config:
/// Persist to `~/.spacesh/config.toml`.
pub fn save(&self) -> std::io::Result<()> {
let dir = crate::lifecycle::spacesh_dir()?;
self.save_to(&dir.join("config.toml"))
}
pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
let s = toml::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
std::fs::write(path, s)
}
- Step 5: Run tests to verify they pass
Run: cargo test -p spaceshd config 2>&1 | tail
Expected: PASS (all config tests).
- Step 6: Commit
git add crates/spaceshd/src/config.rs
git commit -m "feat(spaceshd): config terminal+appearance sections and save"
Task 2: Shared ConfigView wire type
Files:
-
Create:
crates/spacesh-proto/src/config_view.rs -
Modify:
crates/spacesh-proto/src/lib.rs(module export) -
Step 1: Create the wire type
crates/spacesh-proto/src/config_view.rs:
use serde::{Deserialize, Serialize};
/// Effective (resolved) daemon configuration sent to clients.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConfigView {
pub default_shell: String,
pub font_family: String,
pub font_size: u16,
pub theme: String,
pub accent: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_view_round_trips() {
let c = ConfigView {
default_shell: "/bin/zsh".into(), font_family: "JetBrains Mono".into(),
font_size: 13, theme: "dark".into(), accent: "blue".into(),
};
let back: ConfigView = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(back, c);
}
}
- Step 2: Export the module
In crates/spacesh-proto/src/lib.rs, add alongside the other pub mod lines:
pub mod config_view;
And add a re-export next to the existing ones (match the file's style, e.g. pub use config_view::ConfigView;).
- Step 3: Run test
Run: cargo test -p spacesh-proto config_view 2>&1 | tail
Expected: PASS.
- Step 4: Commit
git add crates/spacesh-proto/src/config_view.rs crates/spacesh-proto/src/lib.rs
git commit -m "feat(proto): ConfigView wire type"
Task 3: Protocol — GetConfig / SetConfig / ConfigChanged
Files:
-
Modify:
crates/spacesh-proto/src/message.rs -
Step 1: Add the commands and event
In crates/spacesh-proto/src/message.rs, add to the Cmd enum (near Shutdown):
GetConfig,
SetConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
default_shell: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
font_family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
font_size: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
accent: Option<String>,
},
Add to the Evt enum (after EventsRead):
ConfigChanged { config: crate::config_view::ConfigView },
- Step 2: Build to verify the protocol compiles
Run: cargo build -p spacesh-proto 2>&1 | tail
Expected: builds (the daemon won't yet — handled in Task 4).
- Step 3: Commit
git add crates/spacesh-proto/src/message.rs
git commit -m "feat(proto): GetConfig/SetConfig commands and ConfigChanged event"
Task 4: Daemon — hold config in state, handle Get/Set, broadcast
Files:
-
Modify:
crates/spaceshd/src/server.rs -
Modify:
crates/spaceshd/src/config.rs(effective accessor) -
Step 1: Add an effective-config accessor on
Config
In crates/spaceshd/src/config.rs add to impl Config:
/// Resolve to a client-facing view, applying defaults and the shell resolver.
pub fn to_view(&self) -> spacesh_proto::config_view::ConfigView {
spacesh_proto::config_view::ConfigView {
default_shell: self.resolved_shell(),
font_family: self.terminal.font_family.clone().unwrap_or_else(|| "JetBrains Mono".into()),
font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20),
theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()),
accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()),
}
}
/// Shell for a plain panel using THIS in-memory config (env → config → passwd → $SHELL → /bin/sh).
pub fn resolved_shell(&self) -> String {
if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } }
if let Some(s) = &self.default_shell { if !s.is_empty() { return s.clone(); } }
if let Some(s) = login_shell() { return s; }
if let Ok(s) = std::env::var("SHELL") { if !s.is_empty() { return s; } }
"/bin/sh".into()
}
Keep the existing free default_shell() (used by spawn sites) — it already loads config from disk; leave it for now. Note in a comment that resolved_shell is the in-memory variant.
- Step 2: Hold a
Configin the router and thread it intohandle_request
In crates/spaceshd/src/server.rs, in the router setup (near where persister/clients are created, around line 126-135), load config once:
let mut config = crate::config::Config::load();
Add config: &mut crate::config::Config to the handle_request signature (line 264) and pass &mut config at its call site in the router loop.
- Step 3: Implement the handlers
In the match cmd of handle_request (e.g. after the Cmd::Shutdown arm), add:
Cmd::GetConfig => {
let view = config.to_view();
let _ = out.send(ok(id, serde_json::to_value(view).unwrap_or(serde_json::Value::Null))).await;
}
Cmd::SetConfig { default_shell, font_family, font_size, theme, accent } => {
if let Some(v) = default_shell { config.default_shell = if v.is_empty() { None } else { Some(v) }; }
if let Some(v) = font_family { config.terminal.font_family = Some(v); }
if let Some(v) = font_size { config.terminal.font_size = Some(v.clamp(10, 20)); }
if let Some(v) = theme {
if v != "dark" && v != "light" { let _ = out.send(err(id, "BAD_CONFIG", "theme")).await; return; }
config.appearance.theme = Some(v);
}
if let Some(v) = accent {
const ACCENTS: [&str; 5] = ["blue", "teal", "purple", "green", "orange"];
if !ACCENTS.contains(&v.as_str()) { let _ = out.send(err(id, "BAD_CONFIG", "accent")).await; return; }
config.appearance.accent = Some(v);
}
let _ = config.save();
let view = config.to_view();
broadcast_evt(clients, &Envelope::Evt(Evt::ConfigChanged { config: view }));
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
- Step 4: Build the daemon
Run: cargo build -p spaceshd 2>&1 | tail
Expected: builds.
- Step 5: Add a handler test (config round-trips through Set→Get)
If server.rs has an integration-test harness (look for existing #[tokio::test] that drives handle_request or a client), add a test that sends SetConfig { font_size: Some(15), .. } then GetConfig and asserts font_size == 15. If the harness shape is unclear, instead assert via config.to_view() in a focused unit test in config.rs:
#[test]
fn to_view_applies_defaults_and_clamp() {
let mut c = Config::default();
c.terminal.font_size = Some(99);
let v = c.to_view();
assert_eq!(v.font_size, 20); // clamped
assert_eq!(v.theme, "dark"); // default
assert_eq!(v.accent, "blue"); // default
assert!(!v.font_family.is_empty());
}
- Step 6: Run tests
Run: cargo test -p spaceshd 2>&1 | tail
Expected: PASS.
- Step 7: Commit
git add crates/spaceshd/src/server.rs crates/spaceshd/src/config.rs
git commit -m "feat(spaceshd): GetConfig/SetConfig handlers with live ConfigChanged broadcast"
Task 5: Tauri bridge — get_config / set_config
Files:
-
Modify:
app/src-tauri/src/bridge.rs -
Modify: the Tauri command registration list (search
invoke_handler/generate_handler!inapp/src-tauri/src/). -
Step 1: Add the bridge commands
In app/src-tauri/src/bridge.rs, mirroring set_workspace_meta:
#[tauri::command]
pub async fn get_config(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::GetConfig).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn set_config(state: BridgeState<'_>, default_shell: Option<String>, font_family: Option<String>, font_size: Option<u16>, theme: Option<String>, accent: Option<String>) -> Result<Value, String> {
data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent }).await.map_err(|e| e.to_string())?)
}
- Step 2: Register the commands
Find the tauri::generate_handler![...] macro (grep generate_handler in app/src-tauri/src/) and add bridge::get_config, bridge::set_config to the list (match the existing bridge:: prefix style).
- Step 3: Build the bridge
Run: cd app/src-tauri && cargo build 2>&1 | tail
Expected: builds.
- Step 4: Commit
git add app/src-tauri/src
git commit -m "feat(app): tauri get_config/set_config bridge commands"
Task 6: socketBridge — getConfig/setConfig + config_changed event
Files:
-
Modify:
app/src/socketBridge.ts -
Step 1: Add the config types, fns, and event variant
In app/src/socketBridge.ts add:
export interface ConfigView {
default_shell: string;
font_family: string;
font_size: number;
theme: "dark" | "light";
accent: string;
}
export async function getConfig(): Promise<ConfigView> {
return await invoke<ConfigView>("get_config");
}
export async function setConfig(patch: Partial<Pick<ConfigView, "default_shell" | "font_family" | "font_size" | "theme" | "accent">>): Promise<void> {
await invoke("set_config", {
defaultShell: patch.default_shell ?? null,
fontFamily: patch.font_family ?? null,
fontSize: patch.font_size ?? null,
theme: patch.theme ?? null,
accent: patch.accent ?? null,
});
}
Add to the DaemonEvent union (next to workspace_changed):
| { evt: "config_changed"; data: { config: ConfigView } }
- Step 2: Typecheck
Run: cd app && npx tsc --noEmit
Expected: No errors.
- Step 3: Commit
git add app/src/socketBridge.ts
git commit -m "feat(app): socketBridge getConfig/setConfig + config_changed"
Task 7: theme.ts — CSS variables, palettes, applyTheme, resolvePalette
Files:
-
Modify:
app/src/theme.ts -
Step 1: Convert COLORS to var() refs and add palettes
Rewrite app/src/theme.ts so COLORS/STATE_COLOR keep their KEYS but map to CSS vars, and add palettes + helpers. Keep FONT as-is. Example (extend to every existing key — do not drop any key currently in the file):
export const COLORS = {
accent: "var(--c-accent)",
bgApp: "var(--c-bg-app)",
bgElevated: "var(--c-bg-elevated)",
bgSidebar: "var(--c-bg-sidebar)",
bgPanel: "var(--c-bg-panel)",
borderStrong: "var(--c-border-strong)",
borderSubtle: "var(--c-border-subtle)",
textPrimary: "var(--c-text-primary)",
textSecondary: "var(--c-text-secondary)",
textMuted: "var(--c-text-muted)",
stWork: "var(--c-st-work)",
stWait: "var(--c-st-wait)",
stDone: "var(--c-st-done)",
stError: "var(--c-st-error)",
stIdle: "var(--c-st-idle)",
searchMatch: "var(--c-search-match)",
} as const;
type Palette = Record<string, string>;
const DARK: Palette = {
"bg-app": "#0E1116", "bg-elevated": "#1A2029", "bg-sidebar": "#0C0F14",
"bg-panel": "#0A0D12", "border-strong": "#323C49", "border-subtle": "#232A33",
"text-primary": "#E6EDF3", "text-secondary": "#8B97A6", "text-muted": "#5A6573",
"st-work": "#4C8DFF", "st-wait": "#F2B84B", "st-done": "#3FB950",
"st-error": "#F4544E", "st-idle": "#5A6573", "search-match": "#5A4A1F",
};
const LIGHT: Palette = {
"bg-app": "#FFFFFF", "bg-elevated": "#F1F3F6", "bg-sidebar": "#F6F8FA",
"bg-panel": "#FFFFFF", "border-strong": "#C5CDD6", "border-subtle": "#E2E7EC",
"text-primary": "#1A2029", "text-secondary": "#55606E", "text-muted": "#8B97A6",
"st-work": "#2F6FE0", "st-wait": "#B9831A", "st-done": "#2DA44E",
"st-error": "#D1322C", "st-idle": "#8B97A6", "search-match": "#FFE9A8",
};
const ACCENTS: Record<string, string> = {
blue: "#4C8DFF", teal: "#34D3C2", purple: "#9B7BFF", green: "#3FB950", orange: "#F2934B",
};
export type ThemeName = "dark" | "light";
/** Real color values for consumers that can't use var() (xterm). */
export function resolvePalette(theme: ThemeName, accent: string): Record<string, string> {
const base = theme === "light" ? LIGHT : DARK;
return { ...base, accent: ACCENTS[accent] ?? ACCENTS.blue };
}
/** Write the active palette to :root as --c-* custom properties. */
export function applyTheme(theme: ThemeName, accent: string): void {
const p = resolvePalette(theme, accent);
const root = document.documentElement.style;
for (const [k, v] of Object.entries(p)) root.setProperty(`--c-${k}`, v);
}
Note:
STATE_COLORshould keep its keys (work/wait/done/error/idle/stopped) and point at the matchingCOLORS.st*var refs, exactly as today but via the new var-basedCOLORS.
- Step 2: Typecheck
Run: cd app && npx tsc --noEmit
Expected: No errors. (Components compile unchanged — only COLORS values changed.)
- Step 3: Commit
git add app/src/theme.ts
git commit -m "feat(app): CSS-variable theming with dark/light palettes and accents"
Task 8: App — apply theme on load and on config_changed; hold config
Files:
-
Modify:
app/src/App.tsx -
Step 1: Load config, apply theme, store in state
In app/src/App.tsx:
- Import:
import { getConfig, setConfig } from "./socketBridge"; import type { ConfigView } from "./socketBridge"; import { applyTheme } from "./theme"; - Add state:
const [config, setConfigState] = useState<ConfigView | null>(null); - In the initial effect (where
refresh/loadHealthrun), add a loader:
void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {});
- In the
onDaemonEventswitch, add a branch:
} else if (evt.evt === "config_changed") {
const c = evt.data.config;
setConfigState(c);
applyTheme(c.theme, c.accent);
-
On reconnect (
onDaemonRawEvent("spacesh:disconnected", …)) also re-fetch config: addvoid getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {}); -
Step 2: Typecheck
Run: cd app && npx tsc --noEmit
Expected: No errors.
- Step 3: Commit
git add app/src/App.tsx
git commit -m "feat(app): apply theme from daemon config on load and live"
Task 9: TerminalView — font + xterm theme from config
Files:
-
Modify:
app/src/TerminalView.tsx -
Modify:
app/src/App.tsx(pass config down to LayoutEngine → TerminalView) -
Step 1: Thread config to TerminalView
TerminalView takes a new optional prop font: { family: string; size: number } and palette: Record<string,string> (from resolvePalette). Thread config from App → LayoutEngine (add to Props + shared + Leaf, mirroring the search props) → <TerminalView … font={…} palette={…} />. When config is null, fall back to current hardcoded defaults.
- Step 2: Use font + theme at terminal creation
In TerminalView.tsx, change the new Terminal({...}) to use the props:
const term = new Terminal({
fontFamily: font ? `'${font.family}', monospace` : "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
fontSize: font?.size ?? 13,
convertEol: false, scrollback: 10000, allowProposedApi: true,
theme: palette ? {
background: palette["bg-panel"], foreground: palette["text-primary"],
cursor: palette["text-primary"], selectionBackground: palette["search-match"],
} : undefined,
});
- Step 3: Live-apply on prop change
Add an effect that updates the live terminal when font/palette change (store the term in a ref so a second effect can mutate it):
// after creating term and storing termRef.current = term
// separate effect:
useEffect(() => {
const t = termRef.current; if (!t) return;
if (font) { t.options.fontFamily = `'${font.family}', monospace`; t.options.fontSize = font.size; }
if (palette) t.options.theme = { background: palette["bg-panel"], foreground: palette["text-primary"], cursor: palette["text-primary"], selectionBackground: palette["search-match"] };
// refit after font change so rows/cols stay correct
requestAnimationFrame(() => { try { fitRef.current?.fit(); } catch {} });
}, [font?.family, font?.size, palette]);
(Introduce termRef/fitRef refs alongside the existing mount effect; keep the existing mount/attach logic intact.)
- Step 4: Typecheck
Run: cd app && npx tsc --noEmit
Expected: No errors.
- Step 5: Commit
git add app/src/TerminalView.tsx app/src/LayoutEngine.tsx app/src/App.tsx
git commit -m "feat(app): terminal font and xterm theme from daemon config"
Task 10: Settings modal — Terminal / Appearance / Shell
Files:
-
Create:
app/src/Settings.tsx -
Modify:
app/src/TopBar.tsx(gear → onOpenSettings) -
Modify:
app/src/App.tsx(settings open state + render) -
Step 1: Create the modal
app/src/Settings.tsx — overlay/focus pattern like ConfirmDelete. Props: config: ConfigView, health: DaemonHealth | null, onClose. Each control calls setConfig({...}); rely on the config_changed broadcast to update the live config prop (so the modal reflects the daemon truth). Sections:
import { useEffect, useRef } from "react";
import { COLORS, FONT } from "./theme";
import { setConfig } from "./socketBridge";
import type { ConfigView } from "./socketBridge";
import type { DaemonHealth } from "./socketBridge";
const FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"];
const ACCENTS: { id: string; hex: string }[] = [
{ id: "blue", hex: "#4C8DFF" }, { id: "teal", hex: "#34D3C2" }, { id: "purple", hex: "#9B7BFF" },
{ id: "green", hex: "#3FB950" }, { id: "orange", hex: "#F2934B" },
];
export function Settings({ config, health, onClose }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { ref.current?.focus(); }, []);
return (
<div onMouseDown={onClose} style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div ref={ref} tabIndex={-1} onMouseDown={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") onClose(); }}
style={{ width: 520, maxHeight: "80vh", overflowY: "auto", background: COLORS.bgApp, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 14, padding: 24, color: COLORS.textPrimary, fontFamily: FONT.ui }}>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>Settings</div>
{/* Terminal */}
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Terminal font</div>
<select value={config.font_family} onChange={(e) => void setConfig({ font_family: e.target.value })}
style={{ width: "100%", padding: 8, marginBottom: 10, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }}>
{FONTS.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 18 }}>
<span style={{ fontSize: 12, color: COLORS.textSecondary }}>Size {config.font_size}</span>
<input type="range" min={10} max={20} value={config.font_size} onChange={(e) => void setConfig({ font_size: Number(e.target.value) })} style={{ flex: 1 }} />
</div>
{/* Appearance */}
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Theme</div>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
{(["dark", "light"] as const).map((t) => (
<button key={t} onClick={() => void setConfig({ theme: t })}
style={{ flex: 1, padding: "8px 0", borderRadius: 8, fontSize: 13, textTransform: "capitalize",
background: config.theme === t ? COLORS.accent : COLORS.bgElevated, color: config.theme === t ? "#fff" : COLORS.textPrimary,
border: `1px solid ${COLORS.borderStrong}` }}>{t}</button>
))}
</div>
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Accent</div>
<div style={{ display: "flex", gap: 10, marginBottom: 18 }}>
{ACCENTS.map((a) => (
<button key={a.id} onClick={() => void setConfig({ accent: a.id })} aria-label={a.id}
style={{ width: 26, height: 26, borderRadius: "50%", background: a.hex, cursor: "pointer",
border: config.accent === a.id ? `2px solid ${COLORS.textPrimary}` : "2px solid transparent" }} />
))}
</div>
{/* Shell */}
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 6 }}>Default shell (empty = auto)</div>
<input defaultValue={config.default_shell} onBlur={(e) => void setConfig({ default_shell: e.target.value })}
style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }} />
{/* Daemon section is added in Task 11 */}
<DaemonSection health={health} />
</div>
</div>
);
}
Task 11 defines
DaemonSection. For Step 1, temporarily rendernullin its place; replace in Task 11. To keep the file compiling now, addfunction DaemonSection(_: { health: DaemonHealth | null }) { return null; }at the bottom and flesh it out in Task 11.
- Step 2: Wire the gear
In app/src/TopBar.tsx, the Settings gear IconBtn gets onClick={onOpenSettings} and TopBar gains an onOpenSettings: () => void prop (mirror how onShowEvents was added).
In app/src/App.tsx: add const [settingsOpen, setSettingsOpen] = useState(false);, pass onOpenSettings={() => setSettingsOpen(true)} to <TopBar>, and render:
{settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} />}
- Step 3: Typecheck
Run: cd app && npx tsc --noEmit
Expected: No errors.
- Step 4: Commit
git add app/src/Settings.tsx app/src/TopBar.tsx app/src/App.tsx
git commit -m "feat(app): settings modal — terminal, appearance, shell"
Task 11: Daemon section — status + Stop / Restart
Files:
-
Modify:
app/src/Settings.tsx -
Modify:
app/src/socketBridge.ts(shutdown + reconnect helpers if missing) -
Step 1: Add shutdown/restart bridge helpers
In app/src/socketBridge.ts, confirm/add a shutdown call. If a shutdown Tauri command does not exist, add a bridge command shutdown_daemon that sends Cmd::Shutdown (in bridge.rs, mirror get_config), register it, and expose:
export async function shutdownDaemon(): Promise<void> { await invoke("shutdown_daemon"); }
For restart, after shutdown the bridge's ensure_daemon respawns on the next request; expose a no-op "ping" (getHealth) to trigger reconnect:
export async function restartDaemon(): Promise<void> {
await shutdownDaemon();
// give the old process time to exit, then a request triggers ensure_daemon respawn
await new Promise((r) => setTimeout(r, 600));
try { await getHealth(); } catch { /* reconnect loop will retry */ }
}
- Step 2: Implement
DaemonSection
Replace the placeholder DaemonSection in Settings.tsx:
import { shutdownDaemon, restartDaemon } from "./socketBridge";
import { useState } from "react";
function fmtUptime(ms: number): string {
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
}
function DaemonSection({ health }: { health: DaemonHealth | null }) {
const [confirm, setConfirm] = useState<null | "stop" | "restart">(null);
return (
<div style={{ marginTop: 8, paddingTop: 16, borderTop: `1px solid ${COLORS.borderSubtle}` }}>
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>Daemon</div>
<div style={{ fontFamily: FONT.mono, fontSize: 12, color: COLORS.textSecondary, lineHeight: 1.7 }}>
{health ? (<>
<div>version {health.version} · pid {health.pid}</div>
<div>uptime {fmtUptime(health.started_at_ms)}</div>
</>) : <div>offline</div>}
</div>
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
<button onClick={() => setConfirm("restart")} style={{ padding: "7px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 13 }}>Restart</button>
<button onClick={() => setConfirm("stop")} style={{ padding: "7px 14px", background: "transparent", color: COLORS.stError, border: `1px solid ${COLORS.stError}`, borderRadius: 7, fontSize: 13 }}>Stop</button>
</div>
{confirm && (
<div style={{ marginTop: 10, padding: 10, borderRadius: 8, background: COLORS.bgPanel, border: `1px solid ${COLORS.borderStrong}` }}>
<div style={{ fontSize: 12, color: COLORS.textSecondary, marginBottom: 8 }}>
{confirm === "stop" ? "Stop the daemon? All sessions end." : "Restart the daemon? Sessions end and respawn."}
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setConfirm(null)} style={{ padding: "5px 12px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 6, fontSize: 12 }}>Cancel</button>
<button onClick={() => { const c = confirm; setConfirm(null); void (c === "stop" ? shutdownDaemon() : restartDaemon()); }}
style={{ padding: "5px 12px", background: COLORS.stError, color: "#fff", border: "none", borderRadius: 6, fontSize: 12, fontWeight: 600 }}>
{confirm === "stop" ? "Stop" : "Restart"}
</button>
</div>
</div>
)}
</div>
);
}
- Step 3: Typecheck + build
Run: cd app && npx tsc --noEmit then cd app/src-tauri && cargo build 2>&1 | tail
Expected: No errors; builds.
- Step 4: Manual verification (tauri dev)
Run the app, open the gear:
-
Change font/size → terminals reflow with the new font live.
-
Toggle Light → whole UI + xterm switch palettes; switch accent → focus border/active match recolor.
-
Set default shell → new panels use it.
-
Restart → daemon respawns, sessions reattach via snapshot; Stop → shows offline.
-
Step 5: Commit
git add app/src/Settings.tsx app/src/socketBridge.ts app/src-tauri/src
git commit -m "feat(app): daemon status with Stop/Restart in settings"
Self-Review Notes
- Spec §1 config model → Task 1. §2 protocol → Tasks 2-3. §3 daemon handlers → Task 4. §4 theme → Tasks 7-8. §5 TerminalView → Task 9. §6 modal → Tasks 10-11. §7 Stop/Restart → Task 11. All covered.
ConfigViewfield 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_daemonrespawn (verified present in bridge.rs) or launchd KeepAlive (verified true in launchd.rs). If neither fires in dev (daemon run viacargo run, not launchd, and bridge already connected), the 600ms+getHealth inrestartDaemontriggersensure_daemonon 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
themeobject with ANSI entries (follow-up, not blocking).