docs: settings modal implementation plan
11 TDD tasks: daemon config model/save, ConfigView + Get/Set/ConfigChanged protocol, bridge + socketBridge, CSS-var theming, TerminalView font/theme, settings modal, daemon Stop/Restart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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).
|
||||||
Reference in New Issue
Block a user