From 80113da066ce41a34105019a795967c333a762ec Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 09:17:20 +0700 Subject: [PATCH 01/12] feat(spaceshd): config terminal+appearance sections and save --- crates/spaceshd/src/config.rs | 80 +++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/crates/spaceshd/src/config.rs b/crates/spaceshd/src/config.rs index 3197e5f..3a251b6 100644 --- a/crates/spaceshd/src/config.rs +++ b/crates/spaceshd/src/config.rs @@ -4,14 +4,34 @@ //! partial config never breaks startup. use std::path::Path; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Default, Deserialize)] +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accent: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Config { /// Shell launched for plain (no-command) panels. When unset, the daemon /// auto-detects the user's login shell. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub default_shell: Option, + #[serde(default)] + pub terminal: TerminalConfig, + #[serde(default)] + pub appearance: AppearanceConfig, } impl Config { @@ -27,6 +47,23 @@ impl Config { Err(_) => Self::default(), } } + + /// Persist to `~/.spacesh/config.toml`. + pub fn save(&self) -> std::io::Result<()> { + let dir = crate::lifecycle::spacesh_dir() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + self.save_to(&dir.join("config.toml")) + } + + /// Persist to an arbitrary path. Creates the parent directory if needed. + pub fn save_to(&self, path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + 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) + } } /// Resolve the shell to spawn for a plain panel. @@ -98,4 +135,41 @@ mod tests { std::env::remove_var("SPACESH_SHELL"); assert_eq!(s, "/tmp/fake-shell"); } + + #[test] + fn parses_terminal_and_appearance() { + let dir = std::env::temp_dir().join(format!("spacesh-cfg-sections-{}", std::process::id())); + 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(format!("spacesh-cfg-save-{}", std::process::id())); + 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); + } } From e990e694b54db0f0036d56dcbecf407024cedb36 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 09:21:22 +0700 Subject: [PATCH 02/12] feat(proto): ConfigView wire type --- crates/spacesh-proto/src/config_view.rs | 25 +++++++++++++++++++++++++ crates/spacesh-proto/src/lib.rs | 2 ++ 2 files changed, 27 insertions(+) create mode 100644 crates/spacesh-proto/src/config_view.rs diff --git a/crates/spacesh-proto/src/config_view.rs b/crates/spacesh-proto/src/config_view.rs new file mode 100644 index 0000000..c41713a --- /dev/null +++ b/crates/spacesh-proto/src/config_view.rs @@ -0,0 +1,25 @@ +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); + } +} diff --git a/crates/spacesh-proto/src/lib.rs b/crates/spacesh-proto/src/lib.rs index addc79f..76d414f 100644 --- a/crates/spacesh-proto/src/lib.rs +++ b/crates/spacesh-proto/src/lib.rs @@ -1,4 +1,5 @@ pub mod codec; +pub mod config_view; pub mod event; pub mod ids; pub mod layout; @@ -6,6 +7,7 @@ pub mod message; pub mod status; pub mod workspace; +pub use config_view::ConfigView; pub use event::{EventKind, EventRecord, MarkReadTarget}; pub use ids::{GroupId, SurfaceId, WorkspaceId}; pub use layout::{LayoutNode, Orient}; From c4746f986479ecc64dc59e36f77e471990423fe9 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 09:22:39 +0700 Subject: [PATCH 03/12] feat(proto): GetConfig/SetConfig commands and ConfigChanged event --- crates/spacesh-proto/src/message.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/spacesh-proto/src/message.rs b/crates/spacesh-proto/src/message.rs index ed8015a..4e23e72 100644 --- a/crates/spacesh-proto/src/message.rs +++ b/crates/spacesh-proto/src/message.rs @@ -132,6 +132,19 @@ pub enum Cmd { Health, Status, Shutdown, + 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, + }, } /// Daemon → subscribers push events. The active subset for M0+M1. @@ -150,6 +163,7 @@ pub enum Evt { State { surface_id: SurfaceId, state: SurfaceState }, Event { record: EventRecord }, EventsRead { ids: Vec }, + ConfigChanged { config: crate::config_view::ConfigView }, } #[cfg(test)] From ad296653524595c90d685e54b5706e70f45894e2 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 09:24:51 +0700 Subject: [PATCH 04/12] feat(spaceshd): GetConfig/SetConfig handlers with live ConfigChanged broadcast --- crates/spaceshd/src/config.rs | 34 +++++++++++++++++++++++++++ crates/spaceshd/src/server.rs | 44 +++++++++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/spaceshd/src/config.rs b/crates/spaceshd/src/config.rs index 3a251b6..3db549b 100644 --- a/crates/spaceshd/src/config.rs +++ b/crates/spaceshd/src/config.rs @@ -35,6 +35,27 @@ pub struct Config { } 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() + } + /// Load `~/.spacesh/config.toml`. Any error (missing file, bad TOML) yields defaults. pub fn load() -> Self { let Ok(dir) = crate::lifecycle::spacesh_dir() else { return Self::default() }; @@ -71,6 +92,8 @@ impl Config { /// Order: `SPACESH_SHELL` env → `config.toml` `default_shell` → login shell /// from the passwd DB → `$SHELL` → `/bin/sh`. The passwd lookup matters under /// launchd, where `$SHELL` is typically absent (so a bash fallback would win). +// retained for the env-override test and potential startup use +#[allow(dead_code)] pub fn default_shell() -> String { if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } @@ -158,6 +181,17 @@ mod tests { assert!(c.appearance.theme.is_none()); } + #[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); + assert_eq!(v.theme, "dark"); + assert_eq!(v.accent, "blue"); + assert!(!v.font_family.is_empty()); + } + #[test] fn save_then_reload_round_trips() { let dir = std::env::temp_dir().join(format!("spacesh-cfg-save-{}", std::process::id())); diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index 74ac084..04a4bdd 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -135,6 +135,7 @@ async fn router( let mut clients: HashMap = HashMap::new(); // surface_id → set of client ids subscribed (attached). let mut subs: HashMap> = HashMap::new(); + let mut config = crate::config::Config::load(); while let Some(msg) = rx.recv().await { match msg { @@ -177,7 +178,7 @@ async fn router( ServerMsg::Request { id, cmd, client, out } => { handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx, &state_tx, &persister, - &mut event_log, &event_persister, started_at_ms).await; + &mut event_log, &event_persister, started_at_ms, &mut config).await; } } } @@ -276,6 +277,7 @@ async fn handle_request( event_log: &mut EventLog, event_persister: &EventPersister, started_at_ms: u64, + config: &mut crate::config::Config, ) { use spacesh_proto::message::SplitDir; use spacesh_proto::layout::{LayoutNode, Orient}; @@ -298,7 +300,7 @@ async fn handle_request( let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return; }; let sid = reg.new_surface_id(); - let shell = command.clone().unwrap_or_else(crate::config::default_shell); + let shell = command.clone().unwrap_or_else(|| config.resolved_shell()); let spec = SurfaceSpec { command: shell, args: args.clone(), cwd: ws.path.clone(), agent_label: command, cols, rows, autostart: false, @@ -333,7 +335,7 @@ async fn handle_request( }; let ws = reg.workspace(&ws_id).cloned().unwrap(); let new_sid = reg.new_surface_id(); - let shell = command.clone().unwrap_or_else(crate::config::default_shell); + let shell = command.clone().unwrap_or_else(|| config.resolved_shell()); let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false }; let (env, hooks_active) = spawn_env(&new_sid, &spec); match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) { @@ -406,7 +408,7 @@ async fn handle_request( let slot = slots.get(i); let new_sid = reg.new_surface_id(); let command = slot.and_then(|s| s.command.clone()); - let shell = command.clone().unwrap_or_else(crate::config::default_shell); + let shell = command.clone().unwrap_or_else(|| config.resolved_shell()); let args = slot.map(|s| s.args.clone()).unwrap_or_default(); let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false }; let (env, hooks_active) = spawn_env(&new_sid, &spec); @@ -649,6 +651,40 @@ async fn handle_request( let _ = out.send(ok(id, serde_json::Value::Null)).await; std::process::exit(0); } + + Cmd::GetConfig => { + match serde_json::to_value(config.to_view()) { + Ok(v) => { let _ = out.send(ok(id, v)).await; } + Err(e) => { let _ = out.send(err(id, "INTERNAL", &e.to_string())).await; } + } + } + + Cmd::SetConfig { default_shell, font_family, font_size, theme, accent } => { + if let Some(v) = &theme { + if v != "dark" && v != "light" { let _ = out.send(err(id, "BAD_CONFIG", "theme")).await; return; } + } + 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; } + } + let mut next = config.clone(); + if let Some(v) = default_shell { next.default_shell = if v.is_empty() { None } else { Some(v) }; } + if let Some(v) = font_family { next.terminal.font_family = if v.is_empty() { None } else { Some(v) }; } + if let Some(v) = font_size { next.terminal.font_size = Some(v.clamp(10, 20)); } + if let Some(v) = theme { next.appearance.theme = Some(v); } + if let Some(v) = accent { next.appearance.accent = Some(v); } + let to_save = next.clone(); + match tokio::task::spawn_blocking(move || to_save.save()).await { + Ok(Ok(())) => { + *config = next; + let view = config.to_view(); + broadcast_evt(clients, &Envelope::Evt(Evt::ConfigChanged { config: view })); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } + Ok(Err(e)) => { let _ = out.send(err(id, "SAVE_FAILED", &e.to_string())).await; } + Err(e) => { let _ = out.send(err(id, "SAVE_FAILED", &e.to_string())).await; } + } + } } } From 62f1f8e9a8472515e9535c69ecb531f3d0a9df6b Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 09:36:53 +0700 Subject: [PATCH 05/12] feat(app): tauri get_config/set_config bridge commands --- app/src-tauri/src/bridge.rs | 19 +++++++++++++++++++ app/src-tauri/src/lib.rs | 2 ++ 2 files changed, 21 insertions(+) diff --git a/app/src-tauri/src/bridge.rs b/app/src-tauri/src/bridge.rs index 1b20ade..9919d1a 100644 --- a/app/src-tauri/src/bridge.rs +++ b/app/src-tauri/src/bridge.rs @@ -327,3 +327,22 @@ pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result) -> Result { data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?) } + +// ---- Settings commands ---- + +#[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())?) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 35a4bc9..a80a005 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -53,6 +53,8 @@ pub fn run() { bridge::event_log, bridge::mark_read, bridge::health, + bridge::get_config, + bridge::set_config, ]) .run(tauri::generate_context!()) .expect("error while running spacesh"); From b9f46a407d0f88923a197c92b81d0dd95c05f436 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 09:37:45 +0700 Subject: [PATCH 06/12] feat(app): socketBridge getConfig/setConfig + config_changed --- app/src/socketBridge.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/src/socketBridge.ts b/app/src/socketBridge.ts index da1cdbc..7c93628 100644 --- a/app/src/socketBridge.ts +++ b/app/src/socketBridge.ts @@ -94,6 +94,7 @@ export type DaemonEvt = | { evt: "layout_changed"; data: { workspace_id: string } } | { evt: "workspace_changed"; data: unknown } | { evt: "groups_changed"; data: unknown } + | { evt: "config_changed"; data: { config: ConfigView } } | { evt: "event"; data: { record: EventRecord } } | { evt: "events_read"; data: { ids: number[] } }; @@ -186,3 +187,27 @@ export async function getHealth(): Promise { export async function setZoom(workspaceId: string, surfaceId: string | null): Promise { await invoke("set_zoom", { workspaceId, surfaceId }); } + +// ---- Settings ---- + +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, + }); +} From dc9538187009586c8f1e566574735ad7cc5352ca Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 09:39:07 +0700 Subject: [PATCH 07/12] feat(app): CSS-variable theming with dark/light palettes and accents Co-Authored-By: Claude Sonnet 4.6 --- app/src/theme.ts | 115 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 92 insertions(+), 23 deletions(-) diff --git a/app/src/theme.ts b/app/src/theme.ts index e1421ce..879df39 100644 --- a/app/src/theme.ts +++ b/app/src/theme.ts @@ -2,36 +2,105 @@ import type { SurfaceState } from "./layoutTypes"; /** Design tokens — mirror of DOCS/space-sh.pen variables. Single source for the UI. */ export const COLORS = { - accent: "#4C8DFF", - bgApp: "#0E1116", - bgElevated: "#1A2029", - bgHover: "#222A35", - bgPanel: "#0A0D12", - bgSidebar: "#13171F", - borderStrong: "#323C49", - borderSubtle: "#232A33", - textPrimary: "#E6EDF3", - textSecondary: "#8B97A6", - textMuted: "#5A6573", - stWork: "#4C8DFF", - stWait: "#F2B84B", - stDone: "#3FB950", - stError: "#F4544E", - stIdle: "#5A6573", - searchMatch: "#5A4A1F", + accent: "var(--c-accent)", + bgApp: "var(--c-bg-app)", + bgElevated: "var(--c-bg-elevated)", + bgHover: "var(--c-bg-hover)", + bgPanel: "var(--c-bg-panel)", + bgSidebar: "var(--c-bg-sidebar)", + 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; export const FONT = { - ui: "Inter, system-ui, sans-serif", + ui: "Inter, system-ui, sans-serif", mono: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", } as const; /** Status color by surface state, plus the stopped pseudo-state. */ export const STATE_COLOR: Record = { - work: COLORS.stWork, - wait: COLORS.stWait, - done: COLORS.stDone, - error: COLORS.stError, - idle: COLORS.stIdle, + work: COLORS.stWork, + wait: COLORS.stWait, + done: COLORS.stDone, + error: COLORS.stError, + idle: COLORS.stIdle, stopped: COLORS.stIdle, }; + +// --------------------------------------------------------------------------- +// Palettes +// --------------------------------------------------------------------------- + +type Palette = Record; + +/** Dark palette — hex values identical to what COLORS contained before. */ +const DARK: Palette = { + "bg-app": "#0E1116", + "bg-elevated": "#1A2029", + "bg-hover": "#222A35", + "bg-panel": "#0A0D12", + "bg-sidebar": "#13171F", + "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", +}; + +/** Light palette — a readable counterpart for every token. */ +const LIGHT: Palette = { + "bg-app": "#F5F7FA", + "bg-elevated": "#FFFFFF", + "bg-hover": "#E8EDF3", + "bg-panel": "#EBEEF3", + "bg-sidebar": "#DDE3EC", + "border-strong": "#B0BAC7", + "border-subtle": "#CDD4DE", + "text-primary": "#0D1117", + "text-secondary": "#4A5568", + "text-muted": "#8898AA", + "st-work": "#2266CC", + "st-wait": "#C07800", + "st-done": "#1A7A30", + "st-error": "#C4231F", + "st-idle": "#8898AA", + "search-match": "#FDE68A", +}; + +export 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). Keys are the kebab tokens plus "accent". */ +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); +} From 0f28be13009260dbf1e8f790401cc6b947f6940f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 09:41:15 +0700 Subject: [PATCH 08/12] feat(app): apply theme from daemon config on load and live Co-Authored-By: Claude Sonnet 4.6 --- app/src/App.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index b9b4d2e..feb9b08 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -7,9 +7,9 @@ import { Wizard } from "./Wizard"; import { ConfirmDelete } from "./ConfirmDelete"; import { EventCenter } from "./EventCenter"; import { maybeNotify } from "./notify"; -import { COLORS } from "./theme"; -import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth, closeWorkspaceCmd } from "./socketBridge"; -import type { EventRecord, DaemonHealth } from "./socketBridge"; +import { COLORS, applyTheme } from "./theme"; +import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge"; +import type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge"; import { leafIds } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; @@ -34,6 +34,7 @@ export function App() { const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true)); const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true)); const [health, setHealth] = useState(null); + const [config, setConfigState] = useState(null); const [connected, setConnected] = useState(false); const [focusedId, setFocusedId] = useState(null); const [searchSurfaceId, setSearchSurfaceId] = useState(null); @@ -78,6 +79,7 @@ export function App() { void refresh(); void seedEvents(); void loadHealth(); + void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {}); const unlisten = onDaemonEvent((evt) => { if (evt.evt === "event") { const rec = evt.data.record; @@ -93,6 +95,10 @@ export function App() { void refresh(); } else if (evt.evt === "exit") { void refresh(); + } else if (evt.evt === "config_changed") { + const c = evt.data.config; + setConfigState(c); + applyTheme(c.theme, c.accent); } else { void refresh(); } @@ -102,6 +108,7 @@ export function App() { void refresh(); void seedEvents(); void loadHealth(); + void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {}); }); return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); }; }, [refresh, seedEvents, loadHealth]); From 61c69adb17cc2c056389b4d62edb04fd0d7faa6b Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 09:43:38 +0700 Subject: [PATCH 09/12] feat(app): terminal font and xterm theme from daemon config Co-Authored-By: Claude Sonnet 4.6 --- app/src/App.tsx | 7 +++++-- app/src/LayoutEngine.tsx | 12 +++++++---- app/src/TerminalView.tsx | 45 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index feb9b08..07f592c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -7,7 +7,7 @@ import { Wizard } from "./Wizard"; import { ConfirmDelete } from "./ConfirmDelete"; import { EventCenter } from "./EventCenter"; import { maybeNotify } from "./notify"; -import { COLORS, applyTheme } from "./theme"; +import { COLORS, applyTheme, resolvePalette } from "./theme"; import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge"; import type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge"; import { leafIds } from "./layoutTypes"; @@ -136,6 +136,9 @@ export function App() { const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null; effectiveFocusRef.current = effectiveFocus; + const termFont = config ? { family: config.font_family, size: config.font_size } : null; + const termPalette = config ? resolvePalette(config.theme, config.accent) : null; + function selectWorkspace(id: string) { setActiveId(id); setFocusedId(null); @@ -153,7 +156,7 @@ export function App() { )}
{active - ? setSearchSurfaceId(null)} /> + ? setSearchSurfaceId(null)} font={termFont} palette={termPalette} /> :
No workspace — create one to begin.
}
diff --git a/app/src/LayoutEngine.tsx b/app/src/LayoutEngine.tsx index 21be0e3..74b6570 100644 --- a/app/src/LayoutEngine.tsx +++ b/app/src/LayoutEngine.tsx @@ -22,6 +22,8 @@ interface Props { searchSurfaceId: string | null; searchNonce: number; onCloseSearch: () => void; + font: { family: string; size: number } | null; + palette: Record | null; } type Edge = "left" | "right" | "top" | "bottom"; @@ -40,7 +42,7 @@ function shortPath(cwd: string): string { return leaf ? `~/${leaf}` : cwd; } -export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch }: Props) { +export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Props) { // Panel drag-to-reorder. Implemented with raw pointer events rather than the // HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses. const [drop, setDrop] = useState(null); @@ -78,7 +80,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f if (!layout) { return
Empty workspace — apply a preset to add panels.
; } - const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch }; + const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }; if (zoomed) { return (
@@ -103,6 +105,8 @@ interface NodeProps { searchSurfaceId: string | null; searchNonce: number; onCloseSearch: () => void; + font: { family: string; size: number } | null; + palette: Record | null; } function Node({ node, path, ...rest }: NodeProps) { @@ -112,7 +116,7 @@ function Node({ node, path, ...rest }: NodeProps) { return ; } -function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch }: Omit & { id: string }) { +function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Omit & { id: string }) { const focused = focusedId === id; const dropEdge = drop && drop.id === id ? drop.edge : null; @@ -181,7 +185,7 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, onMouseDown={(e) => { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />}
- +
{searchSurfaceId === id && ( diff --git a/app/src/TerminalView.tsx b/app/src/TerminalView.tsx index f16ebb6..35c0361 100644 --- a/app/src/TerminalView.tsx +++ b/app/src/TerminalView.tsx @@ -9,15 +9,35 @@ import { registerSearch, unregisterSearch } from "./searchRegistry"; const decoder = new TextDecoder(); const encoder = new TextEncoder(); -export function TerminalView({ surfaceId }: { surfaceId: string }) { +function xtermTheme(p: Record) { + return { + background: p["bg-panel"], + foreground: p["text-primary"], + cursor: p["text-primary"], + selectionBackground: p["search-match"], + }; +} + +export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record | null }) { const ref = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(null); useEffect(() => { if (!ref.current) return; // allowProposedApi is required by the search addon: its match decorations // call registerMarker/registerDecoration (proposed API). Without it findNext // throws and the scrollback search counter never updates. - const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000, allowProposedApi: true }); + 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 ? xtermTheme(palette) : undefined, + }); + termRef.current = term; + try { term.loadAddon(new WebglAddon()); } catch { @@ -31,6 +51,7 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) { const fit = new FitAddon(); term.loadAddon(fit); + fitRef.current = fit; // Fit the grid to the container and tell the daemon the new size. Coalesced // through rAF so a burst of resize callbacks yields one resize per frame. @@ -76,8 +97,26 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) { void detachSurface(surfaceId); unregisterSearch(surfaceId); term.dispose(); + termRef.current = null; + fitRef.current = null; }; - }, [surfaceId]); + }, [surfaceId]); // eslint-disable-line react-hooks/exhaustive-deps + + // Live re-apply font and theme when config changes without remounting. + // palette is a new object each render so we depend on a stable key instead. + const paletteKey = palette + ? `${palette["bg-panel"]}|${palette["text-primary"]}|${palette["search-match"]}` + : null; + 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 = xtermTheme(palette); + requestAnimationFrame(() => { try { fitRef.current?.fit(); } catch { /* ignore */ } }); + }, [font?.family, font?.size, paletteKey]); // eslint-disable-line react-hooks/exhaustive-deps return
; } From a2087a0de524ec1a3b8ccc8392eb63667cdc1d1f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 18:34:14 +0700 Subject: [PATCH 10/12] =?UTF-8?q?feat(app):=20settings=20modal=20=E2=80=94?= =?UTF-8?q?=20terminal,=20appearance,=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/src/App.tsx | 5 +++- app/src/Settings.tsx | 60 ++++++++++++++++++++++++++++++++++++++++++++ app/src/TopBar.tsx | 5 ++-- 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 app/src/Settings.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 07f592c..8169f28 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -5,6 +5,7 @@ import { TopBar } from "./TopBar"; import { CenterToolbar } from "./CenterToolbar"; import { Wizard } from "./Wizard"; import { ConfirmDelete } from "./ConfirmDelete"; +import { Settings } from "./Settings"; import { EventCenter } from "./EventCenter"; import { maybeNotify } from "./notify"; import { COLORS, applyTheme, resolvePalette } from "./theme"; @@ -31,6 +32,7 @@ export function App() { const [events, setEvents] = useState([]); const [wizard, setWizard] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true)); const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true)); const [health, setHealth] = useState(null); @@ -147,7 +149,7 @@ export function App() { return (
- setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} /> + setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => setSettingsOpen(true)} />
{sidebarOpen && setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />}
@@ -168,6 +170,7 @@ export function App() { /> )}
+ {settingsOpen && config && setSettingsOpen(false)} />} {wizard && { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />} {deleteTarget && ( 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 font
+ +
+ Size {config.font_size} + void setConfig({ font_size: Number(e.target.value) })} style={{ flex: 1 }} /> +
+ +
Theme
+
+ {(["dark", "light"] as const).map((t) => ( + + ))} +
+
Accent
+
+ {ACCENTS.map((a) => ( +
+ +
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 }} /> + + +
+
+ ); +} + +// Placeholder — fleshed out in Task 11. Keep the signature stable. +function DaemonSection(_props: { health: DaemonHealth | null }) { return null; } diff --git a/app/src/TopBar.tsx b/app/src/TopBar.tsx index df90abc..fbff10c 100644 --- a/app/src/TopBar.tsx +++ b/app/src/TopBar.tsx @@ -29,7 +29,7 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl } export function TopBar({ - active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread, + active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread, onOpenSettings, }: { active: WorkspaceView | null; eventsOpen: boolean; @@ -38,6 +38,7 @@ export function TopBar({ sidebarOpen: boolean; onToggleSidebar: () => void; unread: number; + onOpenSettings: () => void; }) { return (
} onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" /> - } title="Settings (mock)" /> + } onClick={onOpenSettings} title="Settings" /> + +
+ {confirm && ( +
+
+ {confirm === "stop" ? "Stop the daemon? All sessions end." : "Restart the daemon? Sessions end and respawn."} +
+
+ + +
+
+ )} +
+ ); +} diff --git a/app/src/socketBridge.ts b/app/src/socketBridge.ts index 7c93628..5cffde0 100644 --- a/app/src/socketBridge.ts +++ b/app/src/socketBridge.ts @@ -211,3 +211,15 @@ export async function setConfig(patch: Partial { + try { await invoke("shutdown_daemon"); } catch { /* connection drops as the daemon exits — expected */ } +} + +export async function restartDaemon(): Promise { + await shutdownDaemon(); + // Let the old process exit; the next request triggers the bridge's + // ensure_daemon respawn (or launchd KeepAlive) and reconnects. + await new Promise((r) => setTimeout(r, 600)); + try { await getHealth(); } catch { /* reconnect loop will retry */ } +} From 5e6cf4d982143ea624363db71ee12f3f73ed07bb Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 19:10:19 +0700 Subject: [PATCH 12/12] =?UTF-8?q?fix(app):=20settings=20review=20=E2=80=94?= =?UTF-8?q?=20startup=20theme=20default,=20slider/shell=20input=20UX,=20de?= =?UTF-8?q?dupe=20accents,=20memoize=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/src/App.tsx | 6 +++--- app/src/Settings.tsx | 31 +++++++++++++++++++------------ app/src/TerminalView.tsx | 7 ++----- app/src/main.tsx | 5 +++++ 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 8169f28..600a539 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -138,8 +138,8 @@ export function App() { const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null; effectiveFocusRef.current = effectiveFocus; - const termFont = config ? { family: config.font_family, size: config.font_size } : null; - const termPalette = config ? resolvePalette(config.theme, config.accent) : null; + const termPalette = useMemo(() => (config ? resolvePalette(config.theme, config.accent) : null), [config?.theme, config?.accent]); + const termFont = useMemo(() => (config ? { family: config.font_family, size: config.font_size } : null), [config?.font_family, config?.font_size]); function selectWorkspace(id: string) { setActiveId(id); @@ -149,7 +149,7 @@ export function App() { return (
- setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => setSettingsOpen(true)} /> + setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} />
{sidebarOpen && setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />}
diff --git a/app/src/Settings.tsx b/app/src/Settings.tsx index 85f9b10..4334fe5 100644 --- a/app/src/Settings.tsx +++ b/app/src/Settings.tsx @@ -1,17 +1,21 @@ import { useEffect, useRef, useState } from "react"; -import { COLORS, FONT } from "./theme"; +import { COLORS, FONT, ACCENTS } from "./theme"; import { setConfig, shutdownDaemon, restartDaemon } from "./socketBridge"; import type { ConfigView, 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(); }, []); + + // Fix 2: local state for font-size slider — committed only on pointer release. + const [sizeLocal, setSizeLocal] = useState(config.font_size); + useEffect(() => { setSizeLocal(config.font_size); }, [config.font_size]); + + // Fix 3: controlled shell input — synced from config, committed on blur. + const [shellLocal, setShellLocal] = useState(config.default_shell); + useEffect(() => { setShellLocal(config.default_shell); }, [config.default_shell]); return (
e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") onClose(); }} @@ -24,8 +28,11 @@ export function Settings({ config, health, onClose }: { config: ConfigView; heal {FONTS.map((f) => )}
- Size {config.font_size} - void setConfig({ font_size: Number(e.target.value) })} style={{ flex: 1 }} /> + Size {sizeLocal} + setSizeLocal(Number(e.target.value))} + onPointerUp={() => void setConfig({ font_size: sizeLocal })} + style={{ flex: 1 }} />
Theme
@@ -39,15 +46,15 @@ export function Settings({ config, health, onClose }: { config: ConfigView; heal
Accent
- {ACCENTS.map((a) => ( -
Default shell (empty = auto)
- void setConfig({ default_shell: e.target.value })} + setShellLocal(e.target.value)} onBlur={() => void setConfig({ default_shell: shellLocal })} style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }} /> diff --git a/app/src/TerminalView.tsx b/app/src/TerminalView.tsx index 35c0361..cc5817a 100644 --- a/app/src/TerminalView.tsx +++ b/app/src/TerminalView.tsx @@ -103,10 +103,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; }, [surfaceId]); // eslint-disable-line react-hooks/exhaustive-deps // Live re-apply font and theme when config changes without remounting. - // palette is a new object each render so we depend on a stable key instead. - const paletteKey = palette - ? `${palette["bg-panel"]}|${palette["text-primary"]}|${palette["search-match"]}` - : null; + // font and palette are memoized in App.tsx so stable identity = no spurious re-applies. useEffect(() => { const t = termRef.current; if (!t) return; @@ -116,7 +113,7 @@ export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; } if (palette) t.options.theme = xtermTheme(palette); requestAnimationFrame(() => { try { fitRef.current?.fit(); } catch { /* ignore */ } }); - }, [font?.family, font?.size, paletteKey]); // eslint-disable-line react-hooks/exhaustive-deps + }, [font, palette]); // eslint-disable-line react-hooks/exhaustive-deps return
; } diff --git a/app/src/main.tsx b/app/src/main.tsx index 4340d68..09c0067 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App"; +import { applyTheme } from "./theme"; import "@fontsource/inter/400.css"; import "@fontsource/inter/500.css"; import "@fontsource/inter/600.css"; @@ -9,6 +10,10 @@ import "@fontsource-variable/jetbrains-mono"; import "@xterm/xterm/css/xterm.css"; import "./styles.css"; +// Apply default theme before React renders so CSS vars are never unset, +// even if the daemon is slow or offline. getConfig() overrides this later. +applyTheme("dark", "blue"); + ReactDOM.createRoot(document.getElementById("root")!).render(