diff --git a/.gitignore b/.gitignore index 5c9e629e..4baa4c10 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ # Development plans plans/ +docs/superpowers/ + +# Superpowers skill state +.superpowers/ # Code review results reviews/ diff --git a/Cargo.lock b/Cargo.lock index 933f9af2..f4ffc16a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -908,6 +908,7 @@ dependencies = [ "tokio", "toml 0.8.23", "tracing", + "uuid", ] [[package]] @@ -996,6 +997,7 @@ dependencies = [ "codirigent-detector", "codirigent-filetree", "codirigent-session", + "codirigent-updater", "core-text", "dirs 5.0.1", "dispatch", @@ -1013,6 +1015,30 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "unicode-width", +] + +[[package]] +name = "codirigent-updater" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "codirigent-core", + "dirs 5.0.1", + "futures-util", + "hex", + "reqwest", + "semver", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-test", + "tokio-util", + "tracing", ] [[package]] @@ -2515,6 +2541,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2523,6 +2550,7 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64", "bytes", "futures-channel", "futures-core", @@ -2530,7 +2558,9 @@ dependencies = [ "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -2779,6 +2809,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -4401,6 +4441,47 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + [[package]] name = "resvg" version = "0.45.1" @@ -4722,6 +4803,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -5540,6 +5625,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5549,6 +5656,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -5660,6 +5768,24 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -6133,6 +6259,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 91edddee..e9629fa4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/codirigent-plugin", "crates/codirigent-verification", "crates/codirigent-hook", + "crates/codirigent-updater", ] [workspace.package] @@ -29,6 +30,7 @@ tokio = { version = "1.49.0", features = ["full"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } chrono = { version = "0.4.43", features = ["serde"] } +uuid = { version = "1", features = ["v4"] } # PTY and terminal portable-pty = "0.9" @@ -69,6 +71,14 @@ serial_test = "3" gpui = { version = "0.2", default-features = false } core-text = "=21.0.0" +# Auto-update +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +semver = { version = "1", features = ["serde"] } +sha2 = "0.10" +hex = "0.4" +futures-util = "0.3" +tokio-util = { version = "0.7", features = ["rt"] } + # Internal crates codirigent-core = { path = "crates/codirigent-core" } codirigent-session = { path = "crates/codirigent-session" } @@ -77,6 +87,7 @@ codirigent-ui = { path = "crates/codirigent-ui" } codirigent-filetree = { path = "crates/codirigent-filetree" } codirigent-plugin = { path = "crates/codirigent-plugin" } codirigent-verification = { path = "crates/codirigent-verification" } +codirigent-updater = { path = "crates/codirigent-updater" } # Root package (the main binary) [package] diff --git a/crates/codirigent-core/Cargo.toml b/crates/codirigent-core/Cargo.toml index f788e6d7..c6dea21e 100644 --- a/crates/codirigent-core/Cargo.toml +++ b/crates/codirigent-core/Cargo.toml @@ -14,6 +14,7 @@ serde_json.workspace = true tokio.workspace = true tracing.workspace = true chrono.workspace = true +uuid.workspace = true toml = "0.8.20" async-trait.workspace = true humantime-serde.workspace = true diff --git a/crates/codirigent-core/src/config.rs b/crates/codirigent-core/src/config.rs index 9595fd31..5fcfb546 100644 --- a/crates/codirigent-core/src/config.rs +++ b/crates/codirigent-core/src/config.rs @@ -187,7 +187,12 @@ pub struct GeneralSettings { /// Default working directory for new sessions. pub default_working_dir: Option, /// Show splash screen on startup. + #[serde(default = "default_true")] pub show_splash: bool, + /// Resume CLI sessions (claude/codex/gemini) on startup restore. + /// When false, sessions open as generic shells with no CLI launched. + #[serde(default = "default_true")] + pub restore_cli_on_startup: bool, } impl Default for GeneralSettings { @@ -197,6 +202,7 @@ impl Default for GeneralSettings { default_shell: String::new(), default_working_dir: None, show_splash: true, + restore_cli_on_startup: true, } } } @@ -312,11 +318,24 @@ impl UserSettings { bindings.insert("switch_session_2".to_string(), format!("{m}+2")); bindings.insert("switch_session_3".to_string(), format!("{m}+3")); bindings.insert("switch_session_4".to_string(), format!("{m}+4")); + bindings.insert("switch_session_5".to_string(), format!("{m}+5")); + bindings.insert("switch_session_6".to_string(), format!("{m}+6")); + bindings.insert("switch_session_7".to_string(), format!("{m}+7")); + bindings.insert("switch_session_8".to_string(), format!("{m}+8")); + bindings.insert("switch_session_9".to_string(), format!("{m}+9")); bindings.insert("new_session".to_string(), format!("{m}+N")); bindings.insert("close_session".to_string(), format!("{m}+W")); - bindings.insert("quick_switch".to_string(), format!("{m}+K")); bindings.insert("toggle_layout".to_string(), format!("{m}+\\")); - bindings.insert("toggle_task_board".to_string(), format!("{m}+B")); + bindings.insert("toggle_sidebar".to_string(), format!("{m}+B")); + bindings.insert("toggle_task_board".to_string(), format!("{m}+T")); + bindings.insert("open_settings".to_string(), format!("{m}+,")); + bindings.insert("quit".to_string(), format!("{m}+Q")); + bindings.insert("paste".to_string(), format!("{m}+V")); + bindings.insert("copy".to_string(), format!("{m}+C")); + bindings.insert("split_horizontal".to_string(), format!("{m}+D")); + bindings.insert("split_vertical".to_string(), format!("{m}+Shift+D")); + bindings.insert("close_pane".to_string(), format!("{m}+Shift+W")); + bindings.insert("quick_switch".to_string(), format!("{m}+K")); bindings } } @@ -345,12 +364,19 @@ pub struct AppearanceSettings { pub font_size: f32, /// Grid gap in pixels. pub grid_gap: u32, + /// Tab status indicator style: "dot", "badge", or "glow". + #[serde(default = "AppearanceSettings::default_tab_status_style")] + pub tab_status_style: String, } impl AppearanceSettings { fn default_font_size() -> f32 { 13.0 } + + fn default_tab_status_style() -> String { + "dot".to_string() + } } impl Default for AppearanceSettings { @@ -359,6 +385,7 @@ impl Default for AppearanceSettings { theme: "dark".to_string(), font_size: 13.0, grid_gap: 4, + tab_status_style: "dot".to_string(), } } } @@ -777,15 +804,34 @@ mod tests { { assert_eq!(bindings.get("new_session"), Some(&"Cmd+N".to_string())); assert_eq!(bindings.get("close_session"), Some(&"Cmd+W".to_string())); - assert_eq!(bindings.get("quick_switch"), Some(&"Cmd+K".to_string())); + assert_eq!(bindings.get("toggle_sidebar"), Some(&"Cmd+B".to_string())); + assert_eq!( + bindings.get("toggle_task_board"), + Some(&"Cmd+T".to_string()) + ); } #[cfg(not(target_os = "macos"))] { assert_eq!(bindings.get("new_session"), Some(&"Ctrl+N".to_string())); assert_eq!(bindings.get("close_session"), Some(&"Ctrl+W".to_string())); - assert_eq!(bindings.get("quick_switch"), Some(&"Ctrl+K".to_string())); + assert_eq!(bindings.get("toggle_sidebar"), Some(&"Ctrl+B".to_string())); + assert_eq!( + bindings.get("toggle_task_board"), + Some(&"Ctrl+T".to_string()) + ); } + assert!(bindings.contains_key("quick_switch")); assert!(bindings.contains_key("toggle_task_board")); + assert!(bindings.contains_key("toggle_sidebar")); + assert!(bindings.contains_key("open_settings")); + assert!(bindings.contains_key("quit")); + assert!(bindings.contains_key("paste")); + assert!(bindings.contains_key("copy")); + assert!(bindings.contains_key("split_horizontal")); + assert!(bindings.contains_key("split_vertical")); + assert!(bindings.contains_key("close_pane")); + assert!(bindings.contains_key("switch_session_5")); + assert!(bindings.contains_key("switch_session_9")); } #[test] @@ -799,6 +845,36 @@ mod tests { assert_ne!(settings1, settings3); } + // GeneralSettings tests + + #[test] + fn test_general_settings_restore_cli_defaults_true() { + let settings = GeneralSettings::default(); + assert!(settings.restore_cli_on_startup); + } + + #[test] + fn test_general_settings_restore_cli_serialization() { + let settings = GeneralSettings { + editor_command: "vim".to_string(), + default_shell: String::new(), + default_working_dir: None, + show_splash: true, + restore_cli_on_startup: false, + }; + let json = serde_json::to_string(&settings).unwrap(); + let parsed: GeneralSettings = serde_json::from_str(&json).unwrap(); + assert!(!parsed.restore_cli_on_startup); + } + + #[test] + fn test_general_settings_restore_cli_backward_compat() { + // Existing settings files lack this field — must deserialize as true + let json = r#"{"editor_command":"code","default_shell":"","show_splash":true}"#; + let parsed: GeneralSettings = serde_json::from_str(json).unwrap(); + assert!(parsed.restore_cli_on_startup); + } + // AppearanceSettings tests #[test] @@ -807,6 +883,7 @@ mod tests { assert_eq!(settings.theme, "dark"); assert_eq!(settings.font_size, 13.0); assert_eq!(settings.grid_gap, 4); + assert_eq!(settings.tab_status_style, "dot"); } #[test] @@ -815,6 +892,7 @@ mod tests { theme: "light".to_string(), font_size: 14.0, grid_gap: 8, + ..Default::default() }; let json = serde_json::to_string(&settings).unwrap(); let parsed: AppearanceSettings = serde_json::from_str(&json).unwrap(); @@ -823,6 +901,26 @@ mod tests { assert_eq!(parsed.grid_gap, 8); } + #[test] + fn test_appearance_settings_tab_status_style_serialization() { + for style in &["dot", "badge", "glow"] { + let settings = AppearanceSettings { + tab_status_style: style.to_string(), + ..Default::default() + }; + let json = serde_json::to_string(&settings).unwrap(); + let parsed: AppearanceSettings = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.tab_status_style, *style); + } + } + + #[test] + fn test_appearance_settings_missing_tab_status_style_defaults() { + let json = r#"{"theme":"dark","font_size":13.0,"grid_gap":4}"#; + let parsed: AppearanceSettings = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.tab_status_style, "dot"); + } + // NotificationSettings tests #[test] diff --git a/crates/codirigent-core/src/events.rs b/crates/codirigent-core/src/events.rs index c2f1d83f..aeaf7130 100644 --- a/crates/codirigent-core/src/events.rs +++ b/crates/codirigent-core/src/events.rs @@ -352,6 +352,33 @@ pub enum CodirigentEvent { /// The new working directory. new_dir: PathBuf, }, + + // === Update Events === + /// A newer stable version is available on GitHub. + UpdateAvailable { + /// The new version string (e.g., "0.2.0"). + version: String, + /// URL to the GitHub release page. + release_url: String, + }, + + /// Download progress for an update artifact. + UpdateDownloadProgress { + /// Percentage complete (0-100). + percent: u8, + }, + + /// The update artifact has been downloaded and verified, ready to apply. + UpdateReadyToApply, + + /// A staged update is being applied on startup — the app should quit. + UpdateApplyingOnStartup, + + /// An update operation failed. + UpdateFailed { + /// Human-readable error description. + error: String, + }, } #[cfg(test)] @@ -1026,6 +1053,16 @@ mod tests { old_dir: PathBuf::from("/old/dir"), new_dir: PathBuf::from("/new/dir"), }, + // Update events + CodirigentEvent::UpdateAvailable { + version: "0.2.0".to_string(), + release_url: "https://github.com/oso95/Codirigent/releases/v0.2.0".to_string(), + }, + CodirigentEvent::UpdateDownloadProgress { percent: 50 }, + CodirigentEvent::UpdateReadyToApply, + CodirigentEvent::UpdateFailed { + error: "Download failed".to_string(), + }, ]; for event in events { diff --git a/crates/codirigent-core/src/persistence.rs b/crates/codirigent-core/src/persistence.rs index 5e7c5813..13561d23 100644 --- a/crates/codirigent-core/src/persistence.rs +++ b/crates/codirigent-core/src/persistence.rs @@ -10,7 +10,10 @@ //! - [`Checkpoint`]: Named snapshot for manual save points //! - [`RecoveryResult`]: Result of session recovery attempt -use crate::types::{CodexExecutionMode, LayoutMode, Session, SessionId, SessionStatus, TaskId}; +use crate::types::{ + session::generate_session_uuid, CodexExecutionMode, LayoutMode, Session, SessionId, + SessionStatus, TaskId, +}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -39,6 +42,9 @@ use std::path::PathBuf; pub struct PersistentSession { /// Session identifier. pub id: SessionId, + /// Immutable stable UUID for this session across renames and restores. + #[serde(default = "generate_session_uuid")] + pub session_uuid: String, /// Session name. pub name: String, /// Last known status. @@ -108,6 +114,7 @@ impl PersistentSession { pub fn from_session(session: &Session) -> Self { Self { id: session.id, + session_uuid: session.session_uuid.clone(), name: session.name.clone(), status: session.status, working_directory: session.working_directory.clone(), @@ -184,6 +191,7 @@ impl PersistentSession { pub fn to_session(&self) -> Session { Session { id: self.id, + session_uuid: self.session_uuid.clone(), name: self.name.clone(), status: SessionStatus::Idle, // Reset status on restore working_directory: self.working_directory.clone(), diff --git a/crates/codirigent-core/src/types/session.rs b/crates/codirigent-core/src/types/session.rs index 6b03c054..e0d5a3e0 100644 --- a/crates/codirigent-core/src/types/session.rs +++ b/crates/codirigent-core/src/types/session.rs @@ -7,6 +7,11 @@ use super::git::GitRepoInfo; use super::ids::{SessionId, TaskId}; use super::status::SessionStatus; +/// Generate a new stable UUID for a session. +pub fn generate_session_uuid() -> String { + uuid::Uuid::new_v4().to_string() +} + /// Effective Codex execution mode for a session. /// /// This is persisted so restored sessions can reuse the same launch flags and @@ -28,6 +33,9 @@ pub enum CodexExecutionMode { pub struct Session { /// Unique session identifier. pub id: SessionId, + /// Immutable stable UUID for this session across renames and restores. + #[serde(default = "generate_session_uuid")] + pub session_uuid: String, /// Human-readable session name. pub name: String, /// Current session status. @@ -73,6 +81,7 @@ impl Session { pub fn new(id: SessionId, name: String, working_directory: PathBuf) -> Self { Self { id, + session_uuid: generate_session_uuid(), name, status: SessionStatus::default(), working_directory, diff --git a/crates/codirigent-core/tests/persistence_tests.rs b/crates/codirigent-core/tests/persistence_tests.rs index bb925ba4..2596e742 100644 --- a/crates/codirigent-core/tests/persistence_tests.rs +++ b/crates/codirigent-core/tests/persistence_tests.rs @@ -4,7 +4,9 @@ use codirigent_core::persistence::{PersistentSession, PersistentState}; use codirigent_core::persistence_service::{DefaultPersistenceService, PersistenceService}; +use codirigent_core::types::session::generate_session_uuid; use codirigent_core::{Session, SessionId, SessionStatus}; +use serde_json::json; use std::path::PathBuf; use tempfile::TempDir; @@ -17,6 +19,7 @@ fn test_save_and_load_state() { // Create a simple state with one session let session = Session { id: SessionId(1), + session_uuid: generate_session_uuid(), name: "Test Session".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), @@ -88,6 +91,7 @@ fn test_overwrite_state() { // Save initial state with 1 session let session1 = Session { id: SessionId(1), + session_uuid: generate_session_uuid(), name: "Session 1".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), @@ -114,6 +118,7 @@ fn test_overwrite_state() { // Overwrite with 2 sessions let session2 = Session { id: SessionId(2), + session_uuid: generate_session_uuid(), name: "Session 2".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), @@ -256,6 +261,7 @@ fn test_multiple_checkpoints_independent() { let mut state1 = PersistentState::default(); let session1 = Session { id: SessionId(1), + session_uuid: generate_session_uuid(), name: "State 1".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), @@ -279,6 +285,7 @@ fn test_multiple_checkpoints_independent() { let mut state2 = PersistentState::default(); let session2 = Session { id: SessionId(2), + session_uuid: generate_session_uuid(), name: "State 2".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), @@ -372,6 +379,7 @@ fn test_persistent_state_defaults() { fn test_session_to_persistent_conversion() { let session = Session { id: SessionId(42), + session_uuid: generate_session_uuid(), name: "Test".to_string(), status: SessionStatus::Working, working_directory: PathBuf::from("/tmp"), @@ -392,7 +400,41 @@ fn test_session_to_persistent_conversion() { let persistent = PersistentSession::from_session(&session); assert_eq!(persistent.id, SessionId(42)); + assert_eq!(persistent.session_uuid, session.session_uuid); assert_eq!(persistent.name, "Test"); assert_eq!(persistent.group, Some("backend".to_string())); assert_eq!(persistent.color, Some("#ff0000".to_string())); } + +/// Test legacy persisted sessions get a UUID assigned and keep it after save-back. +#[test] +fn test_legacy_persistent_session_missing_uuid_gets_generated_and_persists() { + let legacy_json = json!({ + "id": 7, + "name": "Legacy Session", + "status": "Idle", + "working_directory": "/tmp", + "shell": null, + "current_task": null, + "worktree_path": null, + "context_usage": null, + "started_at": "2026-03-15T00:00:00Z", + "last_checkpoint": "2026-03-15T00:00:00Z", + "scrollback_hash": null, + "group": null, + "color": null, + "claude_session_id": null, + "codex_session_id": null, + "codex_execution_mode": null, + "codex_started_at": null, + "gemini_session_id": null, + "claude_permission_mode": null + }); + + let upgraded: PersistentSession = serde_json::from_value(legacy_json).unwrap(); + assert!(!upgraded.session_uuid.is_empty()); + + let round_tripped: PersistentSession = + serde_json::from_str(&serde_json::to_string(&upgraded).unwrap()).unwrap(); + assert_eq!(round_tripped.session_uuid, upgraded.session_uuid); +} diff --git a/crates/codirigent-detector/src/lib.rs b/crates/codirigent-detector/src/lib.rs index b400d34b..f3384f0e 100644 --- a/crates/codirigent-detector/src/lib.rs +++ b/crates/codirigent-detector/src/lib.rs @@ -90,7 +90,7 @@ pub mod platform; pub use detector::{DetectorConfig, InputDetector}; pub use notification::{ notify_error, notify_input_required, notify_task_completed, send_notification, - NotificationManager, NotificationType, + NotificationHandle, NotificationManager, NotificationType, }; pub use patterns::{DEFAULT_PATTERNS, DEFAULT_RECENT_LINES_TO_CHECK}; pub use platform::{NativeMonitor, PlatformMonitor, ProcessInfo, ProcessState}; diff --git a/crates/codirigent-detector/src/notification.rs b/crates/codirigent-detector/src/notification.rs index 5df4893f..16168194 100644 --- a/crates/codirigent-detector/src/notification.rs +++ b/crates/codirigent-detector/src/notification.rs @@ -27,6 +27,9 @@ use std::collections::HashMap; use std::time::Instant; use tracing::{debug, info, warn}; +use std::sync::mpsc; +use std::thread; + /// Types of notifications that can be sent. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum NotificationType { @@ -148,6 +151,186 @@ impl NotificationManager { } } +/// Commands sent from `NotificationHandle` to the background `NotificationActor`. +enum NotificationCommand { + /// Send a desktop notification (subject to toggle/cooldown checks). + Send { + kind: NotificationType, + session_id: SessionId, + session_name: String, + detail: Option, + }, + /// Update notification settings at runtime. + UpdateSettings(NotificationSettings), +} + +/// Background actor that owns notification state and performs blocking OS calls. +/// +/// Runs on a dedicated `std::thread` — never on the UI thread or a tokio worker. +/// Receives commands via `std::sync::mpsc::Receiver`. Exits when the channel closes +/// (all `NotificationHandle` instances dropped). +struct NotificationActor { + rx: mpsc::Receiver, + settings: NotificationSettings, + last_sent: HashMap, +} + +impl NotificationActor { + fn run(mut self) { + debug!("Notification actor started"); + while let Ok(cmd) = self.rx.recv() { + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.handle(cmd))); + if let Err(e) = result { + warn!("Notification actor recovered from panic: {:?}", e); + } + } + debug!("Notification actor stopped: channel closed"); + } + + fn handle(&mut self, cmd: NotificationCommand) { + match cmd { + NotificationCommand::Send { + kind, + session_id, + ref session_name, + ref detail, + } => { + if !self.settings.desktop { + debug!("Notification suppressed: desktop notifications disabled"); + return; + } + if !self.is_type_enabled(&kind) { + debug!(?kind, "Notification suppressed: type disabled"); + return; + } + if !self.cooldown_elapsed(session_id) { + debug!(%session_id, "Notification suppressed: cooldown active"); + return; + } + + match kind { + NotificationType::InputRequired => { + notify_input_required(session_id, session_name); + } + NotificationType::TaskCompleted => { + notify_task_completed(session_id, session_name, true); + } + NotificationType::TaskFailed => { + notify_task_completed(session_id, session_name, false); + } + NotificationType::PermissionPrompt => { + let body = match detail.as_deref() { + Some(tool) => { + format!("'{}' needs permission for {}", session_name, tool) + } + None => format!("'{}' needs your permission", session_name), + }; + send_notification("Codirigent", &body); + } + NotificationType::ResponseReady => { + let body = format!("'{}' finished responding", session_name); + send_notification("Codirigent", &body); + } + NotificationType::Error => { + let error_msg = detail.as_deref().unwrap_or("Unknown error"); + notify_error(session_id, session_name, error_msg); + } + } + + self.last_sent.insert(session_id, Instant::now()); + } + NotificationCommand::UpdateSettings(settings) => { + debug!("Notification actor: settings updated"); + self.settings = settings; + } + } + } + + fn is_type_enabled(&self, kind: &NotificationType) -> bool { + match kind { + NotificationType::InputRequired => self.settings.input_required, + NotificationType::TaskCompleted => self.settings.task_completed, + NotificationType::TaskFailed => self.settings.task_failed, + NotificationType::PermissionPrompt => self.settings.permission_prompt, + NotificationType::ResponseReady => self.settings.response_ready, + NotificationType::Error => self.settings.error, + } + } + + fn cooldown_elapsed(&self, session_id: SessionId) -> bool { + if self.settings.cooldown_seconds == 0 { + return true; + } + match self.last_sent.get(&session_id) { + Some(last) => last.elapsed().as_secs() >= self.settings.cooldown_seconds, + None => true, + } + } +} + +/// Non-blocking handle for sending notifications from any thread. +/// +/// Wraps an `mpsc::Sender` to a background `NotificationActor`. All methods +/// take `&self` and return immediately — the actual notification dispatch +/// (including blocking OS calls) happens on the actor's dedicated thread. +/// +/// The actor thread exits automatically when all `NotificationHandle` clones +/// are dropped. +#[derive(Clone)] +pub struct NotificationHandle { + tx: mpsc::Sender, +} + +impl NotificationHandle { + /// Spawn the background notification actor and return a handle. + /// + /// The actor runs on a dedicated OS thread named `"notification-actor"`. + /// It processes commands sequentially, applying toggle/cooldown checks + /// before making blocking platform notification calls. + pub fn new(settings: NotificationSettings) -> Self { + let (tx, rx) = mpsc::channel(); + let actor = NotificationActor { + rx, + settings, + last_sent: HashMap::new(), + }; + thread::Builder::new() + .name("notification-actor".into()) + .spawn(move || actor.run()) + .expect("failed to spawn notification actor thread"); + Self { tx } + } + + /// Queue a notification for background dispatch. + /// + /// Returns immediately. The actor will check master toggle, per-type + /// toggles, and per-session cooldown before sending. + pub fn send( + &self, + kind: NotificationType, + session_id: SessionId, + session_name: &str, + detail: Option<&str>, + ) { + if let Err(e) = self.tx.send(NotificationCommand::Send { + kind, + session_id, + session_name: session_name.to_owned(), + detail: detail.map(|s| s.to_owned()), + }) { + warn!("Notification actor unreachable: {}", e); + } + } + + /// Update notification settings on the actor (non-blocking). + pub fn update_settings(&self, settings: NotificationSettings) { + if let Err(e) = self.tx.send(NotificationCommand::UpdateSettings(settings)) { + warn!("Notification actor unreachable (settings update): {}", e); + } + } +} + /// Default notification title for input required alerts. pub const DEFAULT_TITLE: &str = "Codirigent - Input Required"; @@ -734,4 +917,174 @@ mod tests { ); assert!(!sent); } + + // ── NotificationActor tests ── + + #[test] + fn test_actor_processes_send_command() { + let (tx, rx) = mpsc::channel(); + let actor = NotificationActor { + rx, + settings: NotificationSettings::default(), + last_sent: HashMap::new(), + }; + tx.send(NotificationCommand::Send { + kind: NotificationType::InputRequired, + session_id: SessionId(1), + session_name: "Test".to_owned(), + detail: None, + }) + .unwrap(); + drop(tx); + actor.run(); + } + + #[test] + fn test_actor_cooldown_suppresses_second_notification() { + let (_tx, rx) = mpsc::channel(); + let settings = NotificationSettings { + cooldown_seconds: 60, + ..Default::default() + }; + let mut actor = NotificationActor { + rx, + settings, + last_sent: HashMap::new(), + }; + + let cmd1 = NotificationCommand::Send { + kind: NotificationType::InputRequired, + session_id: SessionId(1), + session_name: "Test".to_owned(), + detail: None, + }; + actor.handle(cmd1); + assert!(actor.last_sent.contains_key(&SessionId(1))); + + let prev_time = *actor.last_sent.get(&SessionId(1)).unwrap(); + let cmd2 = NotificationCommand::Send { + kind: NotificationType::InputRequired, + session_id: SessionId(1), + session_name: "Test".to_owned(), + detail: None, + }; + actor.handle(cmd2); + assert_eq!(*actor.last_sent.get(&SessionId(1)).unwrap(), prev_time); + } + + #[test] + fn test_actor_per_session_cooldown_independent() { + let (_tx, rx) = mpsc::channel(); + let settings = NotificationSettings { + cooldown_seconds: 60, + ..Default::default() + }; + let mut actor = NotificationActor { + rx, + settings, + last_sent: HashMap::new(), + }; + + let cmd1 = NotificationCommand::Send { + kind: NotificationType::InputRequired, + session_id: SessionId(1), + session_name: "A".to_owned(), + detail: None, + }; + actor.handle(cmd1); + + let cmd2 = NotificationCommand::Send { + kind: NotificationType::InputRequired, + session_id: SessionId(2), + session_name: "B".to_owned(), + detail: None, + }; + actor.handle(cmd2); + + assert!(actor.last_sent.contains_key(&SessionId(1))); + assert!(actor.last_sent.contains_key(&SessionId(2))); + } + + #[test] + fn test_actor_update_settings_disables_notifications() { + let (_tx, rx) = mpsc::channel(); + let mut actor = NotificationActor { + rx, + settings: NotificationSettings::default(), + last_sent: HashMap::new(), + }; + + actor.handle(NotificationCommand::UpdateSettings(NotificationSettings { + desktop: false, + ..Default::default() + })); + + actor.handle(NotificationCommand::Send { + kind: NotificationType::InputRequired, + session_id: SessionId(1), + session_name: "Test".to_owned(), + detail: None, + }); + assert!(actor.last_sent.is_empty()); + } + + #[test] + fn test_actor_type_toggle_respected() { + let (_tx, rx) = mpsc::channel(); + let mut actor = NotificationActor { + rx, + settings: NotificationSettings { + input_required: false, + cooldown_seconds: 0, + ..Default::default() + }, + last_sent: HashMap::new(), + }; + + actor.handle(NotificationCommand::Send { + kind: NotificationType::InputRequired, + session_id: SessionId(1), + session_name: "Test".to_owned(), + detail: None, + }); + assert!(actor.last_sent.is_empty()); + + actor.handle(NotificationCommand::Send { + kind: NotificationType::TaskCompleted, + session_id: SessionId(1), + session_name: "Test".to_owned(), + detail: None, + }); + assert!(actor.last_sent.contains_key(&SessionId(1))); + } + + // ── NotificationHandle tests ── + + #[test] + fn test_notification_handle_send_does_not_panic() { + let handle = NotificationHandle::new(NotificationSettings::default()); + handle.send(NotificationType::InputRequired, SessionId(1), "Test", None); + } + + #[test] + fn test_notification_handle_update_settings() { + let handle = NotificationHandle::new(NotificationSettings::default()); + handle.update_settings(NotificationSettings { + desktop: false, + ..Default::default() + }); + } + + #[test] + fn test_notification_handle_is_clone() { + let handle = NotificationHandle::new(NotificationSettings::default()); + let _clone = handle.clone(); + } + + #[test] + fn test_notification_handle_send_after_clone_drop() { + let handle = NotificationHandle::new(NotificationSettings::default()); + drop(handle.clone()); + handle.send(NotificationType::InputRequired, SessionId(1), "Test", None); + } } diff --git a/crates/codirigent-filetree/src/tree.rs b/crates/codirigent-filetree/src/tree.rs index a6d139c5..062d808b 100644 --- a/crates/codirigent-filetree/src/tree.rs +++ b/crates/codirigent-filetree/src/tree.rs @@ -304,7 +304,10 @@ impl FileTree { /// Returns `None` when the path contains control characters, or when the /// target shell has no safe representation for the path. pub fn quote_path_for_terminal(path: &Path, style: TerminalPathStyle) -> Option { - let path_str = path.to_string_lossy(); + let path_str = match style { + TerminalPathStyle::Posix if cfg!(windows) => path.to_string_lossy().replace('\\', "/"), + _ => path.to_string_lossy().into_owned(), + }; if path_str.chars().any(|c| c.is_control()) { return None; } @@ -751,6 +754,19 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn test_path_for_terminal_posix_normalizes_windows_separators() { + let temp = TempDir::new().unwrap(); + let tree = FileTree::new(temp.path().to_path_buf()).unwrap(); + + let path = PathBuf::from(r"..\..\README.md"); + assert_eq!( + tree.path_for_terminal(&path, TerminalPathStyle::Posix), + Some("../../README.md".to_string()) + ); + } + #[test] fn test_path_for_terminal_powershell_quotes_single_quote() { let temp = TempDir::new().unwrap(); diff --git a/crates/codirigent-hook/src/main.rs b/crates/codirigent-hook/src/main.rs index 23705f80..172ce838 100644 --- a/crates/codirigent-hook/src/main.rs +++ b/crates/codirigent-hook/src/main.rs @@ -49,6 +49,9 @@ struct SignalFile { approval_policy: Option, #[serde(skip_serializing_if = "Option::is_none")] sandbox_policy_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + codirigent_session_uuid: Option, + // Legacy field kept for transition compatibility with older Codirigent builds. codirigent_session_id: Option, ts: u64, } @@ -65,7 +68,8 @@ fn main() { fn handle_payload(payload: HookPayload) { // Only process signals for sessions launched by Codirigent. let codirigent_session_id = env::var("CODIRIGENT_SESSION_ID").ok(); - if codirigent_session_id.is_none() { + let codirigent_session_uuid = env::var("CODIRIGENT_SESSION_UUID").ok(); + if codirigent_session_id.is_none() && codirigent_session_uuid.is_none() { return; } @@ -104,6 +108,7 @@ fn handle_payload(payload: HookPayload) { cli_session_id, approval_policy: payload.approval_policy, sandbox_policy_type: sandbox_policy_type(payload.sandbox_policy.as_ref()), + codirigent_session_uuid, codirigent_session_id, ts: SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/codirigent-session/src/manager.rs b/crates/codirigent-session/src/manager.rs index 84746da7..bcc668cf 100644 --- a/crates/codirigent-session/src/manager.rs +++ b/crates/codirigent-session/src/manager.rs @@ -443,10 +443,19 @@ impl SessionManager for DefaultSessionManager { )); } + // Create session metadata before PTY spawn so the shell inherits the + // same stable UUID that will be stored for this session. + let mut session = Session::new(id, name, working_dir.clone()); + session.shell = shell.clone().filter(|value| !value.is_empty()); + // Inject CODIRIGENT_SESSION_ID so codirigent-hook can match hook signals // back to this exact session without relying on CWD heuristics. let id_str = id.0.to_string(); - let env_vars: &[(&str, &str)] = &[("CODIRIGENT_SESSION_ID", &id_str)]; + let session_uuid = session.session_uuid.clone(); + let env_vars: &[(&str, &str)] = &[ + ("CODIRIGENT_SESSION_ID", &id_str), + ("CODIRIGENT_SESSION_UUID", &session_uuid), + ]; // Spawn PTY: use specific shell if provided, otherwise auto-detect let mut pty = if let Some(ref shell_name) = shell { @@ -536,10 +545,6 @@ impl SessionManager for DefaultSessionManager { }, ); - // Create session metadata - let mut session = Session::new(id, name, working_dir.clone()); - session.shell = shell.filter(|value| !value.is_empty()); - // Detect git info for the working directory session.git_info = self .git_status diff --git a/crates/codirigent-session/src/shell_detection.rs b/crates/codirigent-session/src/shell_detection.rs index 7e805606..fb58bdac 100644 --- a/crates/codirigent-session/src/shell_detection.rs +++ b/crates/codirigent-session/src/shell_detection.rs @@ -120,6 +120,11 @@ const TEST_LINE_FORWARDER_SENTINEL: &str = "__codirigent_test_line_forwarder__"; const POWERSHELL_INIT_COMMAND: &str = concat!( "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; ", "$OutputEncoding=[System.Text.Encoding]::UTF8; ", + "if (Get-Command fnm -ErrorAction SilentlyContinue) { ", + "try { ", + "Invoke-Expression (fnm env --shell powershell) ", + "} catch { } ", + "} ", "function prompt { ", "$gle = $global:LASTEXITCODE; ", "if ($null -eq $gle) { $gle = 0 }; ", @@ -141,7 +146,6 @@ pub fn setup_powershell_command(shell: &str) -> ShellCommand { program: shell.to_string(), args: vec![ "-NoLogo".to_string(), - "-NoProfile".to_string(), "-NoExit".to_string(), "-Command".to_string(), POWERSHELL_INIT_COMMAND.to_string(), @@ -635,5 +639,7 @@ mod tests { assert_eq!(cmd.program, "pwsh.exe"); assert!(cmd.args.contains(&"-NoLogo".to_string())); assert!(cmd.args.contains(&"-NoExit".to_string())); + // Profile must load so user PATH additions (e.g. claude, codex) are available + assert!(!cmd.args.contains(&"-NoProfile".to_string())); } } diff --git a/crates/codirigent-ui/Cargo.toml b/crates/codirigent-ui/Cargo.toml index 9699e089..20d5e72e 100644 --- a/crates/codirigent-ui/Cargo.toml +++ b/crates/codirigent-ui/Cargo.toml @@ -19,6 +19,7 @@ codirigent-core.workspace = true codirigent-session.workspace = true codirigent-detector.workspace = true codirigent-filetree.workspace = true +codirigent-updater.workspace = true anyhow.workspace = true thiserror.workspace = true serde.workspace = true @@ -27,6 +28,7 @@ tokio.workspace = true tracing.workspace = true chrono.workspace = true dirs.workspace = true +unicode-width = "0.2" # Image processing for thumbnail generation image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } diff --git a/crates/codirigent-ui/src/app.rs b/crates/codirigent-ui/src/app.rs index 9b995b02..1866c7ae 100644 --- a/crates/codirigent-ui/src/app.rs +++ b/crates/codirigent-ui/src/app.rs @@ -47,6 +47,8 @@ mod actions_impl { FocusSession9, NextLayout, ToggleSidebar, + ToggleTaskBoard, + QuickSwitch, SplitHorizontal, SplitVertical, ClosePane, @@ -60,6 +62,43 @@ mod actions_impl { pub use actions_impl::*; +/// Build the complete list of default GPUI key bindings. +/// +/// This function is used both at startup (to register the initial binding set) +/// and in the settings-save callback (to re-register the full default set before +/// appending user overrides). Because GPUI's `bind_keys` appends rather than +/// replaces, re-registering the complete default set before user overrides ensures +/// last-registered-wins gives a consistent snapshot on every save. +pub(crate) fn default_gpui_keybindings() -> Vec { + vec![ + KeyBinding::new("secondary-n", NewSession, None), + KeyBinding::new("secondary-w", CloseSession, None), + KeyBinding::new("secondary-q", Quit, None), + KeyBinding::new("secondary-\\", NextLayout, None), + // Ctrl+B / Cmd+B — toggle sidebar (repo drawer) + KeyBinding::new("secondary-b", ToggleSidebar, None), + // Ctrl+T / Cmd+T — toggle task board + KeyBinding::new("secondary-t", ToggleTaskBoard, None), + // Ctrl+K / Cmd+K — quick switch + KeyBinding::new("secondary-k", QuickSwitch, None), + KeyBinding::new("secondary-v", Paste, None), + KeyBinding::new("secondary-c", Copy, None), + KeyBinding::new("secondary-d", SplitHorizontal, None), + KeyBinding::new("secondary-shift-d", SplitVertical, None), + KeyBinding::new("secondary-shift-w", ClosePane, None), + KeyBinding::new("secondary-,", OpenSettings, None), + KeyBinding::new("secondary-1", FocusSession1, None), + KeyBinding::new("secondary-2", FocusSession2, None), + KeyBinding::new("secondary-3", FocusSession3, None), + KeyBinding::new("secondary-4", FocusSession4, None), + KeyBinding::new("secondary-5", FocusSession5, None), + KeyBinding::new("secondary-6", FocusSession6, None), + KeyBinding::new("secondary-7", FocusSession7, None), + KeyBinding::new("secondary-8", FocusSession8, None), + KeyBinding::new("secondary-9", FocusSession9, None), + ] +} + /// Default splash screen duration in milliseconds. const DEFAULT_SPLASH_DURATION_MS: u64 = 2000; @@ -407,29 +446,9 @@ impl CodirigentApp { // Register global actions Self::register_actions(cx); - // Bind keyboard shortcuts to actions - cx.bind_keys([ - KeyBinding::new("cmd-n", NewSession, None), - KeyBinding::new("cmd-w", CloseSession, None), - KeyBinding::new("cmd-q", Quit, None), - KeyBinding::new("cmd-\\", NextLayout, None), - KeyBinding::new("cmd-b", ToggleSidebar, None), - KeyBinding::new("cmd-v", Paste, None), - KeyBinding::new("cmd-c", Copy, None), - KeyBinding::new("cmd-d", SplitHorizontal, None), - KeyBinding::new("cmd-shift-d", SplitVertical, None), - KeyBinding::new("cmd-shift-w", ClosePane, None), - KeyBinding::new("cmd-,", OpenSettings, None), - KeyBinding::new("cmd-1", FocusSession1, None), - KeyBinding::new("cmd-2", FocusSession2, None), - KeyBinding::new("cmd-3", FocusSession3, None), - KeyBinding::new("cmd-4", FocusSession4, None), - KeyBinding::new("cmd-5", FocusSession5, None), - KeyBinding::new("cmd-6", FocusSession6, None), - KeyBinding::new("cmd-7", FocusSession7, None), - KeyBinding::new("cmd-8", FocusSession8, None), - KeyBinding::new("cmd-9", FocusSession9, None), - ]); + // Bind keyboard shortcuts to actions. + // "secondary-" is GPUI's platform-aware modifier: Cmd on macOS, Ctrl elsewhere. + cx.bind_keys(default_gpui_keybindings()); // Create the main window let session_manager = self.session_manager.clone(); @@ -560,6 +579,14 @@ impl CodirigentApp { info!("ToggleSidebar action triggered (global fallback)"); }); + cx.on_action(|_: &ToggleTaskBoard, _cx| { + info!("ToggleTaskBoard action triggered (global fallback)"); + }); + + cx.on_action(|_: &QuickSwitch, _cx| { + info!("QuickSwitch action triggered (global fallback)"); + }); + // Session focus actions (global fallbacks) - use macro to reduce repetition macro_rules! register_focus_fallback { ($cx:expr, $($action:ty),+ $(,)?) => { @@ -638,4 +665,15 @@ mod tests { Duration::from_millis(DEFAULT_SPLASH_DURATION_MS) ); } + + #[test] + fn test_secondary_modifier_bindings_are_valid_keystroke_strings() { + // "secondary-" is GPUI's platform-aware modifier token (Cmd on macOS, Ctrl elsewhere). + // Verify that representative binding strings are parseable by GPUI's keystroke parser. + use gpui::Keystroke; + assert!(Keystroke::parse("secondary-n").is_ok()); + assert!(Keystroke::parse("secondary-shift-d").is_ok()); + assert!(Keystroke::parse("secondary-,").is_ok()); + assert!(Keystroke::parse("secondary-\\").is_ok()); + } } diff --git a/crates/codirigent-ui/src/keybindings.rs b/crates/codirigent-ui/src/keybindings.rs index 5ecedaa6..ef1046c4 100644 --- a/crates/codirigent-ui/src/keybindings.rs +++ b/crates/codirigent-ui/src/keybindings.rs @@ -284,13 +284,13 @@ impl KeybindingManager { manager.set_binding(binding, Action::ToggleLayout); } if let Ok(binding) = Self::parse_binding(&format!("{m}+B")) { - manager.set_binding(binding, Action::ToggleTaskBoard); + manager.set_binding(binding, Action::ToggleSidebar); } if let Ok(binding) = Self::parse_binding(&format!("{m}+Shift+B")) { manager.set_binding(binding, Action::Broadcast); } - if let Ok(binding) = Self::parse_binding(&format!("{m}+E")) { - manager.set_binding(binding, Action::ToggleSidebar); + if let Ok(binding) = Self::parse_binding(&format!("{m}+T")) { + manager.set_binding(binding, Action::ToggleTaskBoard); } // Clipboard diff --git a/crates/codirigent-ui/src/settings/page.rs b/crates/codirigent-ui/src/settings/page.rs index 38736ea4..098c3201 100644 --- a/crates/codirigent-ui/src/settings/page.rs +++ b/crates/codirigent-ui/src/settings/page.rs @@ -74,6 +74,8 @@ pub struct SettingsPage { original_project: ProjectConfig, /// Which keybinding is currently being recorded (action name). pub recording_shortcut: Option, + /// Which shortcut row currently has keyboard focus (action name). + pub focused_shortcut_row: Option, /// Which dropdown is currently open (by ID string). pub open_dropdown: Option, /// Click position (window coordinates) where the dropdown was triggered. @@ -88,6 +90,9 @@ pub struct SettingsPage { pub detected_shells: Vec, /// Monospace fonts detected on the system. pub detected_fonts: Vec, + /// Pre-sorted list of keybinding action names for the Keyboard Shortcuts panel. + /// Rebuilt whenever `user_settings.keybindings` changes. + pub sorted_shortcut_keys: Vec, } impl SettingsPage { @@ -99,6 +104,9 @@ impl SettingsPage { detected_shells: Vec, detected_fonts: Vec, ) -> Self { + let mut sorted_shortcut_keys: Vec = + user_settings.keybindings.keys().cloned().collect(); + sorted_shortcut_keys.sort(); Self { active_category: SettingsCategory::General, original_user: user_settings.clone(), @@ -106,6 +114,7 @@ impl SettingsPage { user_settings, project_config, recording_shortcut: None, + focused_shortcut_row: None, open_dropdown: None, dropdown_click_pos: (0.0, 0.0), user_save_pending: false, @@ -113,9 +122,16 @@ impl SettingsPage { detected_editors, detected_shells, detected_fonts, + sorted_shortcut_keys, } } + /// Rebuild the sorted shortcut key cache after `user_settings.keybindings` changes. + pub fn refresh_sorted_shortcut_keys(&mut self) { + self.sorted_shortcut_keys = self.user_settings.keybindings.keys().cloned().collect(); + self.sorted_shortcut_keys.sort(); + } + /// Get the active category. pub fn active_category(&self) -> SettingsCategory { if self.active_category.is_visible() { @@ -156,6 +172,7 @@ impl SettingsPage { } SettingsCategory::KeyboardShortcuts => { self.user_settings.keybindings = defaults.keybindings; + self.refresh_sorted_shortcut_keys(); } _ => {} } @@ -219,6 +236,18 @@ mod tests { assert_eq!(SettingsCategory::Advanced.label(), "Advanced"); } + #[test] + fn test_settings_page_focused_shortcut_row_default_none() { + let page = SettingsPage::new( + UserSettings::default(), + ProjectConfig::default(), + vec![], + vec![], + vec![], + ); + assert!(page.focused_shortcut_row.is_none()); + } + #[test] fn test_settings_page_new() { let page = SettingsPage::new( diff --git a/crates/codirigent-ui/src/sidebar/tests.rs b/crates/codirigent-ui/src/sidebar/tests.rs index a6e96049..5740b8dd 100644 --- a/crates/codirigent-ui/src/sidebar/tests.rs +++ b/crates/codirigent-ui/src/sidebar/tests.rs @@ -1,11 +1,13 @@ //! Tests for the session sidebar component. use super::*; +use codirigent_core::types::session::generate_session_uuid; use std::path::PathBuf; fn create_test_session(id: u64, name: &str, status: SessionStatus) -> Session { Session { id: SessionId(id), + session_uuid: generate_session_uuid(), name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), @@ -33,6 +35,7 @@ fn create_grouped_session( ) -> Session { Session { id: SessionId(id), + session_uuid: generate_session_uuid(), name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), @@ -457,6 +460,7 @@ fn create_session_with_context( ) -> Session { Session { id: SessionId(id), + session_uuid: generate_session_uuid(), name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), @@ -479,6 +483,7 @@ fn create_session_with_task(id: u64, name: &str, status: SessionStatus, task: &s use codirigent_core::TaskId; Session { id: SessionId(id), + session_uuid: generate_session_uuid(), name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), diff --git a/crates/codirigent-ui/src/terminal_header.rs b/crates/codirigent-ui/src/terminal_header.rs index 3a83f253..3252c365 100644 --- a/crates/codirigent-ui/src/terminal_header.rs +++ b/crates/codirigent-ui/src/terminal_header.rs @@ -330,8 +330,6 @@ pub struct TerminalHeaderRenderHints { pub name: String, /// Color indicator color. pub color_indicator: Color, - /// Status indicator. - pub status: StatusIndicator, /// Task badge (if any). pub task: Option, /// Context display (if any). @@ -371,7 +369,6 @@ impl TerminalHeader { TerminalHeaderRenderHints { name: self.session_name.clone(), color_indicator: self.session_color, - status: self.status_indicator(), task: self.task_badge(), context: self.context_display(), is_focused: self.is_focused, diff --git a/crates/codirigent-ui/src/terminal_view.rs b/crates/codirigent-ui/src/terminal_view.rs index e2f60880..eb25df20 100644 --- a/crates/codirigent-ui/src/terminal_view.rs +++ b/crates/codirigent-ui/src/terminal_view.rs @@ -53,6 +53,7 @@ use crate::terminal_runtime::{TerminalRenderSnapshot, TerminalRuntimeHandle}; use crate::theme::{CodirigentTheme, Rgba}; use alacritty_terminal::term::TermMode; use codirigent_core::SessionId; +use unicode_width::UnicodeWidthChar; /// A run of text with uniform style for efficient canvas painting. #[derive(Debug, Clone)] @@ -100,6 +101,7 @@ pub(crate) struct CachedTerminalRow { } type ShapedTerminalRow = Arc>; +type SelectionRange = ((i32, usize), (i32, usize)); /// Cursor shape for rendering. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -236,6 +238,8 @@ pub struct TerminalView { cached_shaped_font_size: Option, /// Per-row shaped text derived from `cached_rows`. cached_shaped_rows: Option>, + /// Selection range used to build `cached_shaped_rows`. + cached_shaped_selection: Option, /// Dirty viewport rows after a partial content rebuild. dirty_rows: Option>, /// Whether cell dimensions have been initialized from font metrics. @@ -290,6 +294,7 @@ impl TerminalView { cached_shaped_font_family: None, cached_shaped_font_size: None, cached_shaped_rows: None, + cached_shaped_selection: None, dirty_rows: None, dimensions_initialized: false, cached_terminal_bg, @@ -517,6 +522,13 @@ impl TerminalView { .is_some_and(|snapshot| self.apply_snapshot(snapshot)) } + /// Get the current viewport scroll offset (lines above the live view). + /// + /// Returns 0 when the user is at the bottom (live terminal output). + pub fn display_offset(&self) -> usize { + self.display_offset + } + /// Check whether the viewport is showing scrollback instead of the live prompt. pub fn is_scrolled_back(&self) -> bool { self.display_offset != 0 @@ -653,6 +665,7 @@ impl TerminalView { self.cached_shaped_font_family = None; self.cached_shaped_font_size = None; self.cached_shaped_rows = None; + self.cached_shaped_selection = None; self.dirty_rows = None; } @@ -680,12 +693,16 @@ impl TerminalView { ) -> Vec { let font_family = self.font_family.clone(); let font_size = self.font_size; + let selection_range = self.selection.normalized(); + let selection_fg: gpui::Hsla = self.theme.terminal_selection_fg.into(); let font_changed = self.cached_shaped_font_family.as_ref() != Some(&font_family) || self .cached_shaped_font_size .map_or(true, |size| (size - font_size).abs() > 0.01); + let selection_changed = self.cached_shaped_selection != selection_range; let row_shapes_need_full_rebuild = font_changed + || selection_changed || self.cached_shaped_rows.is_none() || self.cached_rows.len() != self @@ -697,22 +714,30 @@ impl TerminalView { let row_shapes = self .cached_rows .iter() - .map(|row| { + .enumerate() + .map(|(row_index, row)| { shape_text_runs( text_system, row.text_runs_hsla.as_ref(), &font_family, font_size, + self.selection_range_for_viewport_row(row_index), + selection_fg, ) }) .collect::>(); self.cached_shaped_font_family = Some(font_family); self.cached_shaped_font_size = Some(font_size); + self.cached_shaped_selection = selection_range; self.cached_shaped_rows = Some(row_shapes); self.dirty_rows = None; } else if let Some(dirty_rows) = self.dirty_rows.take() { + let dirty_selection_ranges = dirty_rows + .iter() + .map(|row| (*row, self.selection_range_for_viewport_row(*row))) + .collect::>(); if let Some(row_shapes) = self.cached_shaped_rows.as_mut() { - for row in dirty_rows { + for (row, selection_range) in dirty_selection_ranges { if row >= self.cached_rows.len() || row >= row_shapes.len() { continue; } @@ -721,6 +746,8 @@ impl TerminalView { self.cached_rows[row].text_runs_hsla.as_ref(), &font_family, font_size, + selection_range, + selection_fg, ); } } @@ -851,6 +878,32 @@ impl TerminalView { rects } + fn selection_range_for_viewport_row(&self, row: usize) -> Option<(usize, usize)> { + let ((start_line, start_col), (end_line, end_col)) = self.selection.normalized()?; + let grid_line = self.viewport_row_to_grid_line(row); + if grid_line < start_line || grid_line > end_line { + return None; + } + + let max_col = self.cols as usize; + if max_col == 0 { + return None; + } + + let start = if grid_line == start_line { + start_col.min(max_col) + } else { + 0 + }; + let end = if grid_line == end_line { + end_col.saturating_add(1).min(max_col) + } else { + max_col + }; + + (start < end).then_some((start, end)) + } + /// Snapshot the cursor viewport position into `cached_cursor_viewport_pos`. fn refresh_cursor_cache(&mut self, cursor_viewport_cell: Option<(usize, usize)>) { if let Some((row, col)) = cursor_viewport_cell { @@ -874,6 +927,8 @@ fn shape_text_runs( text_runs: &[(TextRunSegment, gpui::Hsla)], font_family: &str, font_size: f32, + selection_columns: Option<(usize, usize)>, + selection_fg: gpui::Hsla, ) -> Arc> { use gpui::{px, Font, FontFeatures, FontStyle, FontWeight, TextRun}; @@ -882,61 +937,155 @@ fn shape_text_runs( let mut shaped_runs = Vec::with_capacity(text_runs.len()); for (run, fg_color) in text_runs.iter() { - let weight = if run.bold { - FontWeight::BOLD - } else { - FontWeight::NORMAL - }; - let style = if run.italic { - FontStyle::Italic - } else { - FontStyle::Normal - }; + for (split_run, split_fg) in + split_text_run_by_selection(run, *fg_color, selection_columns, selection_fg) + { + let weight = if split_run.bold { + FontWeight::BOLD + } else { + FontWeight::NORMAL + }; + let style = if split_run.italic { + FontStyle::Italic + } else { + FontStyle::Normal + }; - let font = Font { - family: font_family.clone(), - features: FontFeatures::default(), - fallbacks: None, - weight, - style, - }; + let font = Font { + family: font_family.clone(), + features: FontFeatures::default(), + fallbacks: None, + weight, + style, + }; - let underline = if run.underline { - Some(gpui::UnderlineStyle { - thickness: px(1.0), - color: Some(*fg_color), - wavy: false, - }) - } else { - None - }; + let underline = if split_run.underline { + Some(gpui::UnderlineStyle { + thickness: px(1.0), + color: Some(split_fg), + wavy: false, + }) + } else { + None + }; - let strikethrough = if run.strikethrough { - Some(gpui::StrikethroughStyle { - thickness: px(1.0), - color: Some(*fg_color), - }) - } else { - None - }; + let strikethrough = if split_run.strikethrough { + Some(gpui::StrikethroughStyle { + thickness: px(1.0), + color: Some(split_fg), + }) + } else { + None + }; - let text: gpui::SharedString = run.text.clone().into(); - let text_run = TextRun { - len: text.len(), - font, - color: *fg_color, - background_color: None, - underline, - strikethrough, - }; + let text: gpui::SharedString = split_run.text.clone().into(); + let text_run = TextRun { + len: text.len(), + font, + color: split_fg, + background_color: None, + underline, + strikethrough, + }; - let shaped = text_system.shape_line(text, font_size_px, &[text_run], None); - shaped_runs.push((run.row, run.start_col, shaped)); + let shaped = text_system.shape_line(text, font_size_px, &[text_run], None); + shaped_runs.push((split_run.row, split_run.start_col, shaped)); + } } Arc::new(shaped_runs) } +fn split_text_run_by_selection( + run: &TextRunSegment, + default_fg: gpui::Hsla, + selection_columns: Option<(usize, usize)>, + selection_fg: gpui::Hsla, +) -> Vec<(TextRunSegment, gpui::Hsla)> { + let Some((selection_start, selection_end)) = selection_columns else { + return vec![(run.clone(), default_fg)]; + }; + + let run_end = run.start_col + run.cell_count; + if selection_end <= run.start_col || selection_start >= run_end { + return vec![(run.clone(), default_fg)]; + } + + let mut segments = Vec::new(); + let mut segment_text = String::new(); + let mut segment_start_col = run.start_col; + let mut segment_cell_count = 0usize; + let mut segment_fg = default_fg; + let mut current_col = run.start_col; + let mut has_segment = false; + + for character in run.text.chars() { + let cell_width = terminal_char_width(character); + let character_fg = + if current_col < selection_end && current_col + cell_width > selection_start { + selection_fg + } else { + default_fg + }; + + if !has_segment { + segment_start_col = current_col; + segment_fg = character_fg; + has_segment = true; + } else if character_fg != segment_fg { + segments.push(( + clone_text_run_segment( + run, + segment_text.clone(), + segment_start_col, + segment_cell_count, + ), + segment_fg, + )); + segment_text.clear(); + segment_start_col = current_col; + segment_cell_count = 0; + segment_fg = character_fg; + } + + segment_text.push(character); + segment_cell_count += cell_width; + current_col += cell_width; + } + + if has_segment { + segments.push(( + clone_text_run_segment(run, segment_text, segment_start_col, segment_cell_count), + segment_fg, + )); + } + + segments +} + +fn clone_text_run_segment( + run: &TextRunSegment, + text: String, + start_col: usize, + cell_count: usize, +) -> TextRunSegment { + TextRunSegment { + text, + foreground: run.foreground, + bold: run.bold, + italic: run.italic, + underline: run.underline, + strikethrough: run.strikethrough, + row: run.row, + start_col, + cell_count, + } +} + +fn terminal_char_width(character: char) -> usize { + UnicodeWidthChar::width(character).unwrap_or(1).max(1) +} + /// Compute cell dimensions from actual font metrics using the text system. /// /// Uses `text_system.advance('m')` to get the actual character width and @@ -1048,6 +1197,58 @@ mod tests { assert!(selection.contains(7, 40)); } + #[test] + fn test_split_text_run_by_selection_uses_selection_foreground() { + let theme = CodirigentTheme::dark(); + let default_fg: gpui::Hsla = Rgba::rgb(255, 255, 255).into(); + let selection_fg: gpui::Hsla = Rgba::rgb(255, 0, 0).into(); + let run = TextRunSegment { + text: "hello".to_string(), + foreground: theme.terminal_foreground, + bold: false, + italic: false, + underline: false, + strikethrough: false, + row: 0, + start_col: 0, + cell_count: 5, + }; + + let segments = split_text_run_by_selection(&run, default_fg, Some((1, 4)), selection_fg); + + assert_eq!(segments.len(), 3); + assert_eq!(segments[0].0.text, "h"); + assert_eq!(segments[0].1, default_fg); + assert_eq!(segments[1].0.text, "ell"); + assert_eq!(segments[1].1, selection_fg); + assert_eq!(segments[2].0.text, "o"); + assert_eq!(segments[2].1, default_fg); + } + + #[test] + fn test_split_text_run_by_selection_keeps_wide_chars_whole() { + let theme = CodirigentTheme::dark(); + let default_fg: gpui::Hsla = Rgba::rgb(255, 255, 255).into(); + let selection_fg: gpui::Hsla = Rgba::rgb(255, 0, 0).into(); + let run = TextRunSegment { + text: "中a".to_string(), + foreground: theme.terminal_foreground, + bold: false, + italic: false, + underline: false, + strikethrough: false, + row: 0, + start_col: 0, + cell_count: 3, + }; + + let segments = split_text_run_by_selection(&run, default_fg, Some((1, 3)), selection_fg); + + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].0.text, "中a"); + assert_eq!(segments[0].1, selection_fg); + } + /// Verifies lexicographic row-major ordering: the column at the end of /// the start-row IS included, but a column that is AFTER the end-col on /// the end-row is NOT included. diff --git a/crates/codirigent-ui/src/theme_config.rs b/crates/codirigent-ui/src/theme_config.rs deleted file mode 100644 index 53447895..00000000 --- a/crates/codirigent-ui/src/theme_config.rs +++ /dev/null @@ -1,718 +0,0 @@ -//! Advanced theme configuration system for Codirigent. -//! -//! This module provides serializable theme definitions that can be loaded -//! from JSON configuration files, enabling custom themes. -//! -//! # Overview -//! -//! The theme system provides: -//! - [`Theme`] - Complete theme definition with colors, typography, and spacing -//! - [`ThemeColors`] - Color palette for all UI elements -//! - [`TerminalColors`] - ANSI 16-color palette for terminals -//! - [`ThemeTypography`] - Font settings -//! - [`ThemeSpacing`] - Layout spacing values -//! -//! # Default Themes -//! -//! Two built-in themes are provided: -//! - Dark theme (default) - Based on the spec's dark palette -//! - Light theme - High contrast light mode -//! -//! # Custom Themes -//! -//! Custom themes can be loaded from JSON files: -//! -//! ``` -//! use codirigent_ui::theme_config::Theme; -//! -//! let json = r#"{"id": "custom", "name": "Custom", "is_dark": true, ...}"#; -//! // let theme = Theme::from_json(json).unwrap(); -//! ``` - -use serde::{Deserialize, Serialize}; -use serde_json; - -/// Color value in hex format (e.g., "#1a1a2e"). -pub type HexColor = String; - -/// Complete theme definition. -/// -/// A theme contains all visual settings for the Codirigent UI, including -/// colors, typography, and spacing values. -/// -/// # Example -/// -/// ``` -/// use codirigent_ui::theme_config::Theme; -/// -/// let dark = Theme::dark(); -/// assert!(dark.is_dark); -/// assert_eq!(dark.id, "dark"); -/// -/// let light = Theme::light(); -/// assert!(!light.is_dark); -/// assert_eq!(light.id, "light"); -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct Theme { - /// Theme identifier (unique). - pub id: String, - /// Human-readable name. - pub name: String, - /// Whether this is a dark theme. - pub is_dark: bool, - /// Color palette. - pub colors: ThemeColors, - /// Typography settings. - pub typography: ThemeTypography, - /// Spacing values. - pub spacing: ThemeSpacing, -} - -/// Theme color palette. -/// -/// Contains all colors used throughout the UI, organized by purpose. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ThemeColors { - // Background colors - /// Primary background color (main window). - pub background_primary: HexColor, - /// Secondary background color (panels). - pub background_secondary: HexColor, - /// Tertiary background color (nested elements). - pub background_tertiary: HexColor, - - // Foreground colors - /// Primary text color. - pub foreground_primary: HexColor, - /// Secondary text color. - pub foreground_secondary: HexColor, - /// Muted/disabled text color. - pub foreground_muted: HexColor, - - // Accent colors - /// Primary accent color (buttons, links). - pub accent_primary: HexColor, - /// Secondary accent color (hover states). - pub accent_secondary: HexColor, - - // Status colors - /// Color for idle sessions. - pub status_idle: HexColor, - /// Color for working/active sessions. - pub status_working: HexColor, - /// Color for sessions waiting for input. - pub status_waiting: HexColor, - /// Color for completed sessions. - pub status_done: HexColor, - /// Color for sessions with errors. - pub status_error: HexColor, - - // Session group colors (predefined palette) - /// Colors for session grouping. - pub group_colors: Vec, - - // Border colors - /// Primary border color. - pub border_primary: HexColor, - /// Focused element border color. - pub border_focused: HexColor, - - // Terminal colors (ANSI 16-color palette) - /// Terminal ANSI color palette. - pub terminal: TerminalColors, -} - -/// Terminal ANSI colors. -/// -/// The standard 16-color ANSI palette used for terminal rendering. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct TerminalColors { - /// Black (color 0). - pub black: HexColor, - /// Red (color 1). - pub red: HexColor, - /// Green (color 2). - pub green: HexColor, - /// Yellow (color 3). - pub yellow: HexColor, - /// Blue (color 4). - pub blue: HexColor, - /// Magenta (color 5). - pub magenta: HexColor, - /// Cyan (color 6). - pub cyan: HexColor, - /// White (color 7). - pub white: HexColor, - /// Bright black (color 8). - pub bright_black: HexColor, - /// Bright red (color 9). - pub bright_red: HexColor, - /// Bright green (color 10). - pub bright_green: HexColor, - /// Bright yellow (color 11). - pub bright_yellow: HexColor, - /// Bright blue (color 12). - pub bright_blue: HexColor, - /// Bright magenta (color 13). - pub bright_magenta: HexColor, - /// Bright cyan (color 14). - pub bright_cyan: HexColor, - /// Bright white (color 15). - pub bright_white: HexColor, -} - -/// Typography settings. -/// -/// Font family and size settings for UI and terminal rendering. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ThemeTypography { - /// Main UI font family. - pub ui_font_family: String, - /// Terminal font family. - pub terminal_font_family: String, - /// Base font size in pixels. - pub base_font_size: f32, - /// Terminal font size in pixels. - pub terminal_font_size: f32, - /// Line height multiplier. - pub line_height: f32, -} - -/// Spacing values. -/// -/// Standard spacing values used throughout the UI for consistent layout. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ThemeSpacing { - /// Extra small spacing (2px). - pub xs: f32, - /// Small spacing (4px). - pub sm: f32, - /// Medium spacing (8px). - pub md: f32, - /// Large spacing (16px). - pub lg: f32, - /// Extra large spacing (24px). - pub xl: f32, - /// Grid gap between sessions. - pub grid_gap: f32, - /// Border radius. - pub border_radius: f32, -} - -impl Theme { - /// Create the default dark theme. - /// - /// Uses colors from the Codirigent spec with a dark background. - /// - /// # Example - /// - /// ``` - /// use codirigent_ui::theme_config::Theme; - /// - /// let theme = Theme::dark(); - /// assert!(theme.is_dark); - /// assert_eq!(theme.colors.background_primary, "#1a1a2e"); - /// ``` - pub fn dark() -> Self { - Self { - id: "dark".to_string(), - name: "Dark".to_string(), - is_dark: true, - colors: ThemeColors { - background_primary: "#1a1a2e".to_string(), - background_secondary: "#16213e".to_string(), - background_tertiary: "#0f3460".to_string(), - foreground_primary: "#eaeaea".to_string(), - foreground_secondary: "#b8b8b8".to_string(), - foreground_muted: "#6b6b6b".to_string(), - accent_primary: "#e94560".to_string(), - accent_secondary: "#0f3460".to_string(), - status_idle: "#6b6b6b".to_string(), - status_working: "#f39c12".to_string(), - status_waiting: "#e74c3c".to_string(), - status_done: "#27ae60".to_string(), - status_error: "#e74c3c".to_string(), - group_colors: vec![ - "#27ae60".to_string(), // green - "#3498db".to_string(), // blue - "#f39c12".to_string(), // yellow - "#9b59b6".to_string(), // purple - "#e74c3c".to_string(), // red - "#1abc9c".to_string(), // teal - ], - border_primary: "#2a2a4a".to_string(), - border_focused: "#e94560".to_string(), - terminal: TerminalColors::default_dark(), - }, - typography: ThemeTypography { - ui_font_family: "Inter".to_string(), - terminal_font_family: "JetBrains Mono".to_string(), - base_font_size: 14.0, - terminal_font_size: 14.0, - line_height: 1.5, - }, - spacing: ThemeSpacing { - xs: 2.0, - sm: 4.0, - md: 8.0, - lg: 16.0, - xl: 24.0, - grid_gap: 4.0, - border_radius: 4.0, - }, - } - } - - /// Create the default light theme. - /// - /// High contrast light mode with dark text on light backgrounds. - /// - /// # Example - /// - /// ``` - /// use codirigent_ui::theme_config::Theme; - /// - /// let theme = Theme::light(); - /// assert!(!theme.is_dark); - /// assert_eq!(theme.colors.background_primary, "#ffffff"); - /// ``` - pub fn light() -> Self { - Self { - id: "light".to_string(), - name: "Light".to_string(), - is_dark: false, - colors: ThemeColors { - background_primary: "#ffffff".to_string(), - background_secondary: "#f5f5f5".to_string(), - background_tertiary: "#e0e0e0".to_string(), - foreground_primary: "#1a1a1a".to_string(), - foreground_secondary: "#4a4a4a".to_string(), - foreground_muted: "#9a9a9a".to_string(), - accent_primary: "#0066cc".to_string(), - accent_secondary: "#e6f0ff".to_string(), - status_idle: "#9a9a9a".to_string(), - status_working: "#f39c12".to_string(), - status_waiting: "#e74c3c".to_string(), - status_done: "#27ae60".to_string(), - status_error: "#e74c3c".to_string(), - group_colors: vec![ - "#27ae60".to_string(), - "#3498db".to_string(), - "#f39c12".to_string(), - "#9b59b6".to_string(), - "#e74c3c".to_string(), - "#1abc9c".to_string(), - ], - border_primary: "#d0d0d0".to_string(), - border_focused: "#0066cc".to_string(), - terminal: TerminalColors::default_light(), - }, - typography: ThemeTypography { - ui_font_family: "Inter".to_string(), - terminal_font_family: "JetBrains Mono".to_string(), - base_font_size: 14.0, - terminal_font_size: 14.0, - line_height: 1.5, - }, - spacing: ThemeSpacing { - xs: 2.0, - sm: 4.0, - md: 8.0, - lg: 16.0, - xl: 24.0, - grid_gap: 4.0, - border_radius: 4.0, - }, - } - } - - /// Load a custom theme from JSON. - /// - /// # Arguments - /// - /// * `json` - JSON string containing theme definition - /// - /// # Returns - /// - /// The parsed theme or a serde_json error. - /// - /// # Example - /// - /// ``` - /// use codirigent_ui::theme_config::Theme; - /// - /// let dark = Theme::dark(); - /// let json = serde_json::to_string_pretty(&dark).unwrap(); - /// let loaded = Theme::from_json(&json).unwrap(); - /// assert_eq!(loaded.id, "dark"); - /// ``` - pub fn from_json(json: &str) -> Result { - serde_json::from_str(json) - } - - /// Serialize theme to JSON. - /// - /// # Returns - /// - /// Pretty-printed JSON string or serialization error. - /// - /// # Example - /// - /// ``` - /// use codirigent_ui::theme_config::Theme; - /// - /// let theme = Theme::dark(); - /// let json = theme.to_json().unwrap(); - /// assert!(json.contains("\"id\": \"dark\"")); - /// ``` - pub fn to_json(&self) -> Result { - serde_json::to_string_pretty(self) - } -} - -impl Default for Theme { - fn default() -> Self { - Self::dark() - } -} - -impl TerminalColors { - /// Create the default dark terminal colors. - pub fn default_dark() -> Self { - Self { - black: "#000000".to_string(), - red: "#e74c3c".to_string(), - green: "#27ae60".to_string(), - yellow: "#f39c12".to_string(), - blue: "#3498db".to_string(), - magenta: "#9b59b6".to_string(), - cyan: "#1abc9c".to_string(), - white: "#ecf0f1".to_string(), - bright_black: "#7f8c8d".to_string(), - bright_red: "#ff6b6b".to_string(), - bright_green: "#2ecc71".to_string(), - bright_yellow: "#f1c40f".to_string(), - bright_blue: "#5dade2".to_string(), - bright_magenta: "#bb8fce".to_string(), - bright_cyan: "#48c9b0".to_string(), - bright_white: "#ffffff".to_string(), - } - } - - /// Create the default light terminal colors. - pub fn default_light() -> Self { - Self { - black: "#2c3e50".to_string(), - red: "#c0392b".to_string(), - green: "#27ae60".to_string(), - yellow: "#f39c12".to_string(), - blue: "#2980b9".to_string(), - magenta: "#8e44ad".to_string(), - cyan: "#16a085".to_string(), - white: "#bdc3c7".to_string(), - bright_black: "#7f8c8d".to_string(), - bright_red: "#e74c3c".to_string(), - bright_green: "#2ecc71".to_string(), - bright_yellow: "#f1c40f".to_string(), - bright_blue: "#3498db".to_string(), - bright_magenta: "#9b59b6".to_string(), - bright_cyan: "#1abc9c".to_string(), - bright_white: "#ecf0f1".to_string(), - } - } - - /// Get color by ANSI index (0-15). - /// - /// # Arguments - /// - /// * `index` - ANSI color index (0-15) - /// - /// # Returns - /// - /// The hex color string, or None if index is out of range. - pub fn get(&self, index: u8) -> Option<&str> { - match index { - 0 => Some(&self.black), - 1 => Some(&self.red), - 2 => Some(&self.green), - 3 => Some(&self.yellow), - 4 => Some(&self.blue), - 5 => Some(&self.magenta), - 6 => Some(&self.cyan), - 7 => Some(&self.white), - 8 => Some(&self.bright_black), - 9 => Some(&self.bright_red), - 10 => Some(&self.bright_green), - 11 => Some(&self.bright_yellow), - 12 => Some(&self.bright_blue), - 13 => Some(&self.bright_magenta), - 14 => Some(&self.bright_cyan), - 15 => Some(&self.bright_white), - _ => None, - } - } -} - -impl Default for TerminalColors { - fn default() -> Self { - Self::default_dark() - } -} - -impl Default for ThemeTypography { - fn default() -> Self { - Self { - ui_font_family: "Inter".to_string(), - terminal_font_family: "JetBrains Mono".to_string(), - base_font_size: 14.0, - terminal_font_size: 14.0, - line_height: 1.5, - } - } -} - -impl Default for ThemeSpacing { - fn default() -> Self { - Self { - xs: 2.0, - sm: 4.0, - md: 8.0, - lg: 16.0, - xl: 24.0, - grid_gap: 4.0, - border_radius: 4.0, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_dark_theme() { - let theme = Theme::dark(); - assert!(theme.is_dark); - assert_eq!(theme.id, "dark"); - assert_eq!(theme.name, "Dark"); - } - - #[test] - fn test_light_theme() { - let theme = Theme::light(); - assert!(!theme.is_dark); - assert_eq!(theme.id, "light"); - assert_eq!(theme.name, "Light"); - } - - #[test] - fn test_theme_default() { - let theme = Theme::default(); - assert!(theme.is_dark); - assert_eq!(theme.id, "dark"); - } - - #[test] - fn test_theme_serialization() { - let theme = Theme::dark(); - let json = serde_json::to_string_pretty(&theme).unwrap(); - let loaded = Theme::from_json(&json).unwrap(); - assert_eq!(loaded.id, theme.id); - assert_eq!(loaded.is_dark, theme.is_dark); - assert_eq!( - loaded.colors.background_primary, - theme.colors.background_primary - ); - } - - #[test] - fn test_theme_to_json() { - let theme = Theme::dark(); - let json = theme.to_json().unwrap(); - assert!(json.contains("\"id\": \"dark\"")); - assert!(json.contains("\"is_dark\": true")); - } - - #[test] - fn test_theme_colors_dark() { - let theme = Theme::dark(); - assert_eq!(theme.colors.background_primary, "#1a1a2e"); - assert_eq!(theme.colors.foreground_primary, "#eaeaea"); - assert_eq!(theme.colors.accent_primary, "#e94560"); - } - - #[test] - fn test_theme_colors_light() { - let theme = Theme::light(); - assert_eq!(theme.colors.background_primary, "#ffffff"); - assert_eq!(theme.colors.foreground_primary, "#1a1a1a"); - assert_eq!(theme.colors.accent_primary, "#0066cc"); - } - - #[test] - fn test_status_colors() { - let theme = Theme::dark(); - assert_eq!(theme.colors.status_idle, "#6b6b6b"); - assert_eq!(theme.colors.status_working, "#f39c12"); - assert_eq!(theme.colors.status_waiting, "#e74c3c"); - assert_eq!(theme.colors.status_done, "#27ae60"); - assert_eq!(theme.colors.status_error, "#e74c3c"); - } - - #[test] - fn test_group_colors() { - let theme = Theme::dark(); - assert_eq!(theme.colors.group_colors.len(), 6); - assert_eq!(theme.colors.group_colors[0], "#27ae60"); - } - - #[test] - fn test_terminal_colors_dark() { - let colors = TerminalColors::default_dark(); - assert_eq!(colors.black, "#000000"); - assert_eq!(colors.red, "#e74c3c"); - assert_eq!(colors.bright_white, "#ffffff"); - } - - #[test] - fn test_terminal_colors_light() { - let colors = TerminalColors::default_light(); - assert_eq!(colors.black, "#2c3e50"); - assert_eq!(colors.red, "#c0392b"); - } - - #[test] - fn test_terminal_colors_default() { - let colors = TerminalColors::default(); - assert_eq!(colors.black, "#000000"); // Same as dark - } - - #[test] - fn test_terminal_colors_get() { - let colors = TerminalColors::default_dark(); - assert_eq!(colors.get(0), Some("#000000")); - assert_eq!(colors.get(1), Some("#e74c3c")); - assert_eq!(colors.get(15), Some("#ffffff")); - assert_eq!(colors.get(16), None); - } - - #[test] - fn test_terminal_colors_get_all_indices() { - let colors = TerminalColors::default_dark(); - for i in 0..16 { - assert!(colors.get(i).is_some(), "Color {} should exist", i); - } - } - - #[test] - fn test_typography_default() { - let typo = ThemeTypography::default(); - assert_eq!(typo.ui_font_family, "Inter"); - assert_eq!(typo.terminal_font_family, "JetBrains Mono"); - assert_eq!(typo.base_font_size, 14.0); - assert_eq!(typo.terminal_font_size, 14.0); - assert_eq!(typo.line_height, 1.5); - } - - #[test] - fn test_typography_serialization() { - let typo = ThemeTypography::default(); - let json = serde_json::to_string(&typo).unwrap(); - let parsed: ThemeTypography = serde_json::from_str(&json).unwrap(); - assert_eq!(typo.ui_font_family, parsed.ui_font_family); - assert_eq!(typo.base_font_size, parsed.base_font_size); - } - - #[test] - fn test_spacing_default() { - let spacing = ThemeSpacing::default(); - assert_eq!(spacing.xs, 2.0); - assert_eq!(spacing.sm, 4.0); - assert_eq!(spacing.md, 8.0); - assert_eq!(spacing.lg, 16.0); - assert_eq!(spacing.xl, 24.0); - assert_eq!(spacing.grid_gap, 4.0); - assert_eq!(spacing.border_radius, 4.0); - } - - #[test] - fn test_spacing_serialization() { - let spacing = ThemeSpacing::default(); - let json = serde_json::to_string(&spacing).unwrap(); - let parsed: ThemeSpacing = serde_json::from_str(&json).unwrap(); - assert_eq!(spacing.xs, parsed.xs); - assert_eq!(spacing.grid_gap, parsed.grid_gap); - } - - #[test] - fn test_theme_colors_equality() { - let theme1 = Theme::dark(); - let theme2 = Theme::dark(); - assert_eq!(theme1.colors, theme2.colors); - } - - #[test] - fn test_theme_clone() { - let theme = Theme::dark(); - let cloned = theme.clone(); - assert_eq!(theme, cloned); - } - - #[test] - fn test_theme_debug() { - let theme = Theme::dark(); - let debug_str = format!("{:?}", theme); - assert!(debug_str.contains("Theme")); - assert!(debug_str.contains("dark")); - } - - #[test] - fn test_invalid_json() { - let result = Theme::from_json("invalid json"); - assert!(result.is_err()); - } - - #[test] - fn test_partial_json() { - // Missing required fields should fail - let result = Theme::from_json(r#"{"id": "test"}"#); - assert!(result.is_err()); - } - - #[test] - fn test_theme_roundtrip() { - let original = Theme::light(); - let json = original.to_json().unwrap(); - let loaded = Theme::from_json(&json).unwrap(); - assert_eq!(original, loaded); - } - - #[test] - fn test_terminal_colors_clone() { - let colors = TerminalColors::default_dark(); - let cloned = colors.clone(); - assert_eq!(colors, cloned); - } - - #[test] - fn test_theme_colors_clone() { - let theme = Theme::dark(); - let cloned = theme.colors.clone(); - assert_eq!(theme.colors.background_primary, cloned.background_primary); - } - - #[test] - fn test_typography_clone() { - let typo = ThemeTypography::default(); - let cloned = typo.clone(); - assert_eq!(typo, cloned); - } - - #[test] - fn test_spacing_clone() { - let spacing = ThemeSpacing::default(); - let cloned = spacing.clone(); - assert_eq!(spacing, cloned); - } -} diff --git a/crates/codirigent-ui/src/theme_config/builtins.rs b/crates/codirigent-ui/src/theme_config/builtins.rs new file mode 100644 index 00000000..cb1564f4 --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/builtins.rs @@ -0,0 +1,226 @@ +use super::schema::{ + HexColor, TerminalColors, TerminalPalette, Theme, ThemeAccentColors, ThemeBackgroundColors, + ThemeBorderColors, ThemeColors, ThemeForegroundColors, ThemeInteractionColors, + ThemePriorityColors, ThemeSpacing, ThemeStatusColors, ThemeTypography, +}; +use crate::theme::{AnsiColors, CodirigentTheme, Hsla, Rgba}; + +const DEFAULT_UI_FONT_FAMILY: &str = "Inter"; +const DEFAULT_BORDER_RADIUS: f32 = 4.0; +const DEFAULT_EXTRA_SMALL_SPACING: f32 = 2.0; +const EXTRA_LARGE_SPACING_MULTIPLIER: f32 = 1.5; + +impl Theme { + /// Create the built-in dark theme definition. + pub fn dark() -> Self { + Self::from_runtime("dark", "Dark", true, &CodirigentTheme::dark()) + } + + /// Create the built-in light theme definition. + pub fn light() -> Self { + Self::from_runtime("light", "Light", false, &CodirigentTheme::light()) + } + + /// Build a serializable theme from the runtime theme model. + pub fn from_runtime(id: &str, name: &str, is_dark: bool, theme: &CodirigentTheme) -> Self { + Self { + id: id.to_string(), + name: name.to_string(), + is_dark, + colors: ThemeColors { + background: ThemeBackgroundColors { + app: hsla_to_hex(theme.background), + panel: hsla_to_hex(theme.panel_background), + header: hsla_to_hex(theme.header_background), + sidebar: hsla_to_hex(theme.sidebar_background), + icon_rail: hsla_to_hex(theme.icon_rail_background), + drawer: hsla_to_hex(theme.drawer_background), + }, + foreground: ThemeForegroundColors { + primary: hsla_to_hex(theme.foreground), + secondary: hsla_to_hex(theme.text_secondary), + muted: hsla_to_hex(theme.muted), + }, + border: ThemeBorderColors { + default: hsla_to_hex(theme.border), + focused: hsla_to_hex(theme.selected_ring), + }, + interaction: ThemeInteractionColors { + hover: hsla_to_hex(theme.hover), + active: hsla_to_hex(theme.active), + selection: hsla_to_hex(theme.selection), + }, + accent: ThemeAccentColors { + primary: hsla_to_hex(theme.primary), + secondary: hsla_to_hex(theme.secondary), + purple: hsla_to_hex(theme.purple), + orange: hsla_to_hex(theme.orange), + selected_ring: hsla_to_hex(theme.selected_ring), + broadcast: hsla_to_hex(theme.broadcast_accent), + ai_summary_background: hsla_to_hex(theme.ai_summary_background), + ai_summary_text: hsla_to_hex(theme.ai_summary_text), + input_required_background: hsla_to_hex(theme.input_required_background), + input_required_accent: hsla_to_hex(theme.input_required_accent), + }, + status: ThemeStatusColors { + idle: hsla_to_hex(theme.session_idle), + working: hsla_to_hex(theme.session_working), + needs_attention: hsla_to_hex(theme.session_needs_attention), + response_ready: hsla_to_hex(theme.session_response_ready), + error: hsla_to_hex(theme.session_error), + }, + priority: ThemePriorityColors { + high: hsla_to_hex(theme.priority_high), + medium: hsla_to_hex(theme.priority_medium), + low: hsla_to_hex(theme.priority_low), + }, + session_groups: theme + .session_colors + .iter() + .copied() + .map(hsla_to_hex) + .collect(), + terminal: TerminalColors { + background: rgba_to_hex(theme.terminal_background), + foreground: rgba_to_hex(theme.terminal_foreground), + cursor: rgba_to_hex(theme.terminal_cursor), + selection_background: rgba_to_hex(theme.terminal_selection_bg), + selection_foreground: rgba_to_hex(theme.terminal_selection_fg), + palette: ansi_to_palette(theme.ansi), + }, + }, + typography: ThemeTypography { + ui_font_family: DEFAULT_UI_FONT_FAMILY.to_string(), + terminal_font_family: theme.terminal_font_family.clone(), + base_font_size: theme.font_size_base, + terminal_font_size: theme.terminal_font_size, + line_height: theme.terminal_line_height, + }, + spacing: ThemeSpacing { + xs: DEFAULT_EXTRA_SMALL_SPACING, + sm: theme.spacing_small, + md: theme.spacing_base, + lg: theme.spacing_large, + xl: theme.spacing_large * EXTRA_LARGE_SPACING_MULTIPLIER, + grid_gap: theme.grid_gap, + border_radius: DEFAULT_BORDER_RADIUS, + }, + } + } +} + +fn ansi_to_palette(ansi: AnsiColors) -> TerminalPalette { + TerminalPalette { + black: rgba_to_hex(ansi.colors[0]), + red: rgba_to_hex(ansi.colors[1]), + green: rgba_to_hex(ansi.colors[2]), + yellow: rgba_to_hex(ansi.colors[3]), + blue: rgba_to_hex(ansi.colors[4]), + magenta: rgba_to_hex(ansi.colors[5]), + cyan: rgba_to_hex(ansi.colors[6]), + white: rgba_to_hex(ansi.colors[7]), + bright_black: rgba_to_hex(ansi.colors[8]), + bright_red: rgba_to_hex(ansi.colors[9]), + bright_green: rgba_to_hex(ansi.colors[10]), + bright_yellow: rgba_to_hex(ansi.colors[11]), + bright_blue: rgba_to_hex(ansi.colors[12]), + bright_magenta: rgba_to_hex(ansi.colors[13]), + bright_cyan: rgba_to_hex(ansi.colors[14]), + bright_white: rgba_to_hex(ansi.colors[15]), + } +} + +fn hsla_to_hex(color: Hsla) -> HexColor { + rgba_to_hex(hsla_to_rgba(color)) +} + +fn hsla_to_rgba(color: Hsla) -> Rgba { + let (r, g, b) = hsl_to_rgb(color.h, color.s, color.l); + Rgba::new(r, g, b, float_alpha_to_u8(color.a)) +} + +fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { + if s.abs() < f32::EPSILON { + let gray = float_channel_to_u8(l); + return (gray, gray, gray); + } + + let q = if l < 0.5 { + l * (1.0 + s) + } else { + l + s - l * s + }; + let p = 2.0 * l - q; + + let r = hue_to_rgb(p, q, h + (1.0 / 3.0)); + let g = hue_to_rgb(p, q, h); + let b = hue_to_rgb(p, q, h - (1.0 / 3.0)); + ( + float_channel_to_u8(r), + float_channel_to_u8(g), + float_channel_to_u8(b), + ) +} + +fn hue_to_rgb(p: f32, q: f32, mut t: f32) -> f32 { + if t < 0.0 { + t += 1.0; + } + if t > 1.0 { + t -= 1.0; + } + if t < (1.0 / 6.0) { + return p + (q - p) * 6.0 * t; + } + if t < 0.5 { + return q; + } + if t < (2.0 / 3.0) { + return p + (q - p) * ((2.0 / 3.0) - t) * 6.0; + } + p +} + +fn float_channel_to_u8(value: f32) -> u8 { + (value.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +fn float_alpha_to_u8(value: f32) -> u8 { + float_channel_to_u8(value) +} + +fn rgba_to_hex(color: Rgba) -> HexColor { + if color.a == u8::MAX { + format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b) + } else { + format!( + "#{:02x}{:02x}{:02x}{:02x}", + color.r, color.g, color.b, color.a + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtins_round_trip_from_runtime_shape() { + let dark = Theme::dark(); + assert_eq!(dark.id, "dark"); + assert_eq!(dark.colors.background.app, "#050505"); + assert_eq!(dark.colors.terminal.cursor, "#6366f1"); + assert_eq!(dark.colors.terminal.selection_background, "#6366f14d"); + + let light = Theme::light(); + assert_eq!(light.id, "light"); + assert_eq!(light.colors.background.app, "#f5f5f7"); + assert_eq!(light.colors.terminal.selection_background, "#4f46e533"); + } + + #[test] + fn hsla_to_rgba_handles_gray_without_saturation() { + let rgba = hsla_to_rgba(Hsla::new(0.0, 0.0, 0.5, 1.0)); + assert_eq!(rgba, Rgba::rgb(128, 128, 128)); + } +} diff --git a/crates/codirigent-ui/src/theme_config/conversion.rs b/crates/codirigent-ui/src/theme_config/conversion.rs new file mode 100644 index 00000000..5870b570 --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/conversion.rs @@ -0,0 +1,338 @@ +use super::schema::{TerminalPalette, Theme}; +use crate::theme::{AnsiColors, CodirigentTheme, Hsla, Rgba}; +use std::convert::TryFrom; + +const OPAQUE_ALPHA: u8 = u8::MAX; +const RGB_SHORT_LENGTH: usize = 3; +const RGBA_SHORT_LENGTH: usize = 4; +const RGB_LONG_LENGTH: usize = 6; +const RGBA_LONG_LENGTH: usize = 8; +const MIN_SESSION_GROUP_COLORS: usize = 6; +const UI_FONT_SIZE_DELTA: f32 = 2.0; +const MIN_SMALL_FONT_SIZE: f32 = 8.0; + +/// Error returned when a serialized theme cannot be converted into a runtime +/// [`CodirigentTheme`]. +#[allow(missing_docs)] +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ThemeConversionError { + /// A color string used an unsupported hex format. + #[error("invalid hex color for {field}: {value}")] + InvalidHex { field: &'static str, value: String }, + /// The serialized session group palette was shorter than runtime expects. + #[error("theme requires at least {required} session group colors, found {actual}")] + NotEnoughSessionGroupColors { required: usize, actual: usize }, +} + +impl TryFrom<&Theme> for CodirigentTheme { + type Error = ThemeConversionError; + + fn try_from(theme: &Theme) -> Result { + Ok(Self { + background: parse_hsla(&theme.colors.background.app, "colors.background.app")?, + panel_background: parse_hsla( + &theme.colors.background.panel, + "colors.background.panel", + )?, + header_background: parse_hsla( + &theme.colors.background.header, + "colors.background.header", + )?, + sidebar_background: parse_hsla( + &theme.colors.background.sidebar, + "colors.background.sidebar", + )?, + border: parse_hsla(&theme.colors.border.default, "colors.border.default")?, + hover: parse_hsla(&theme.colors.interaction.hover, "colors.interaction.hover")?, + active: parse_hsla( + &theme.colors.interaction.active, + "colors.interaction.active", + )?, + selection: parse_hsla( + &theme.colors.interaction.selection, + "colors.interaction.selection", + )?, + foreground: parse_hsla( + &theme.colors.foreground.primary, + "colors.foreground.primary", + )?, + text_secondary: parse_hsla( + &theme.colors.foreground.secondary, + "colors.foreground.secondary", + )?, + muted: parse_hsla(&theme.colors.foreground.muted, "colors.foreground.muted")?, + primary: parse_hsla(&theme.colors.accent.primary, "colors.accent.primary")?, + secondary: parse_hsla(&theme.colors.accent.secondary, "colors.accent.secondary")?, + purple: parse_hsla(&theme.colors.accent.purple, "colors.accent.purple")?, + orange: parse_hsla(&theme.colors.accent.orange, "colors.accent.orange")?, + icon_rail_background: parse_hsla( + &theme.colors.background.icon_rail, + "colors.background.icon_rail", + )?, + drawer_background: parse_hsla( + &theme.colors.background.drawer, + "colors.background.drawer", + )?, + selected_ring: parse_hsla( + &theme.colors.accent.selected_ring, + "colors.accent.selected_ring", + )?, + broadcast_accent: parse_hsla( + &theme.colors.accent.broadcast, + "colors.accent.broadcast", + )?, + ai_summary_background: parse_hsla( + &theme.colors.accent.ai_summary_background, + "colors.accent.ai_summary_background", + )?, + ai_summary_text: parse_hsla( + &theme.colors.accent.ai_summary_text, + "colors.accent.ai_summary_text", + )?, + input_required_background: parse_hsla( + &theme.colors.accent.input_required_background, + "colors.accent.input_required_background", + )?, + input_required_accent: parse_hsla( + &theme.colors.accent.input_required_accent, + "colors.accent.input_required_accent", + )?, + session_idle: parse_hsla(&theme.colors.status.idle, "colors.status.idle")?, + session_working: parse_hsla(&theme.colors.status.working, "colors.status.working")?, + session_needs_attention: parse_hsla( + &theme.colors.status.needs_attention, + "colors.status.needs_attention", + )?, + session_response_ready: parse_hsla( + &theme.colors.status.response_ready, + "colors.status.response_ready", + )?, + session_error: parse_hsla(&theme.colors.status.error, "colors.status.error")?, + priority_high: parse_hsla(&theme.colors.priority.high, "colors.priority.high")?, + priority_medium: parse_hsla(&theme.colors.priority.medium, "colors.priority.medium")?, + priority_low: parse_hsla(&theme.colors.priority.low, "colors.priority.low")?, + session_colors: parse_session_colors(theme)?, + cursor: parse_rgba(&theme.colors.terminal.cursor, "colors.terminal.cursor")?.to_hsla(), + ansi: parse_ansi_colors(&theme.colors.terminal.palette)?, + terminal_background: parse_rgba( + &theme.colors.terminal.background, + "colors.terminal.background", + )?, + terminal_foreground: parse_rgba( + &theme.colors.terminal.foreground, + "colors.terminal.foreground", + )?, + terminal_cursor: parse_rgba(&theme.colors.terminal.cursor, "colors.terminal.cursor")?, + terminal_selection_bg: parse_rgba( + &theme.colors.terminal.selection_background, + "colors.terminal.selection_background", + )?, + terminal_selection_fg: parse_rgba( + &theme.colors.terminal.selection_foreground, + "colors.terminal.selection_foreground", + )?, + grid_gap: theme.spacing.grid_gap, + font_size_base: theme.typography.base_font_size, + font_size_small: (theme.typography.base_font_size - UI_FONT_SIZE_DELTA) + .max(MIN_SMALL_FONT_SIZE), + font_size_large: theme.typography.base_font_size + UI_FONT_SIZE_DELTA, + terminal_font_size: theme.typography.terminal_font_size, + terminal_line_height: theme.typography.line_height, + terminal_font_family: theme.typography.terminal_font_family.clone(), + spacing_base: theme.spacing.md, + spacing_small: theme.spacing.sm, + spacing_large: theme.spacing.lg, + }) + } +} + +fn parse_session_colors(theme: &Theme) -> Result<[Hsla; 6], ThemeConversionError> { + let actual = theme.colors.session_groups.len(); + if actual < MIN_SESSION_GROUP_COLORS { + return Err(ThemeConversionError::NotEnoughSessionGroupColors { + required: MIN_SESSION_GROUP_COLORS, + actual, + }); + } + + let mut colors = [Hsla::new(0.0, 0.0, 0.0, 1.0); MIN_SESSION_GROUP_COLORS]; + for (target, source) in colors.iter_mut().zip( + theme + .colors + .session_groups + .iter() + .take(MIN_SESSION_GROUP_COLORS), + ) { + *target = parse_hsla(source, "colors.session_groups")?; + } + Ok(colors) +} + +fn parse_ansi_colors(palette: &TerminalPalette) -> Result { + Ok(AnsiColors { + colors: [ + parse_rgba(&palette.black, "colors.terminal.palette.black")?, + parse_rgba(&palette.red, "colors.terminal.palette.red")?, + parse_rgba(&palette.green, "colors.terminal.palette.green")?, + parse_rgba(&palette.yellow, "colors.terminal.palette.yellow")?, + parse_rgba(&palette.blue, "colors.terminal.palette.blue")?, + parse_rgba(&palette.magenta, "colors.terminal.palette.magenta")?, + parse_rgba(&palette.cyan, "colors.terminal.palette.cyan")?, + parse_rgba(&palette.white, "colors.terminal.palette.white")?, + parse_rgba( + &palette.bright_black, + "colors.terminal.palette.bright_black", + )?, + parse_rgba(&palette.bright_red, "colors.terminal.palette.bright_red")?, + parse_rgba( + &palette.bright_green, + "colors.terminal.palette.bright_green", + )?, + parse_rgba( + &palette.bright_yellow, + "colors.terminal.palette.bright_yellow", + )?, + parse_rgba(&palette.bright_blue, "colors.terminal.palette.bright_blue")?, + parse_rgba( + &palette.bright_magenta, + "colors.terminal.palette.bright_magenta", + )?, + parse_rgba(&palette.bright_cyan, "colors.terminal.palette.bright_cyan")?, + parse_rgba( + &palette.bright_white, + "colors.terminal.palette.bright_white", + )?, + ], + }) +} + +fn parse_hsla(value: &str, field: &'static str) -> Result { + Ok(parse_rgba(value, field)?.to_hsla()) +} + +fn parse_rgba(value: &str, field: &'static str) -> Result { + let hex = value.trim_start_matches('#'); + let (r, g, b, a) = match hex.len() { + RGB_SHORT_LENGTH => ( + expand_nibble(&hex[0..1], value, field)?, + expand_nibble(&hex[1..2], value, field)?, + expand_nibble(&hex[2..3], value, field)?, + OPAQUE_ALPHA, + ), + RGBA_SHORT_LENGTH => ( + expand_nibble(&hex[0..1], value, field)?, + expand_nibble(&hex[1..2], value, field)?, + expand_nibble(&hex[2..3], value, field)?, + expand_nibble(&hex[3..4], value, field)?, + ), + RGB_LONG_LENGTH => ( + parse_byte(&hex[0..2], value, field)?, + parse_byte(&hex[2..4], value, field)?, + parse_byte(&hex[4..6], value, field)?, + OPAQUE_ALPHA, + ), + RGBA_LONG_LENGTH => ( + parse_byte(&hex[0..2], value, field)?, + parse_byte(&hex[2..4], value, field)?, + parse_byte(&hex[4..6], value, field)?, + parse_byte(&hex[6..8], value, field)?, + ), + _ => return Err(invalid_hex(field, value)), + }; + + Ok(Rgba::new(r, g, b, a)) +} + +fn expand_nibble( + nibble: &str, + original: &str, + field: &'static str, +) -> Result { + parse_byte(&format!("{nibble}{nibble}"), original, field) +} + +fn parse_byte(byte: &str, original: &str, field: &'static str) -> Result { + u8::from_str_radix(byte, 16).map_err(|_| invalid_hex(field, original)) +} + +fn invalid_hex(field: &'static str, value: &str) -> ThemeConversionError { + ThemeConversionError::InvalidHex { + field, + value: value.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme_config::TerminalColors; + + #[test] + fn convert_builtin_dark_theme_into_runtime_theme() { + let config = Theme::dark(); + let runtime = CodirigentTheme::try_from(&config).expect("convert dark theme"); + + assert_eq!(runtime.background, CodirigentTheme::dark().background); + assert_eq!( + runtime.terminal_selection_bg, + CodirigentTheme::dark().terminal_selection_bg + ); + } + + #[test] + fn parse_rgba_supports_alpha_hex() { + let rgba = parse_rgba("#11223344", "test").expect("rgba"); + assert_eq!(rgba, Rgba::new(0x11, 0x22, 0x33, 0x44)); + } + + #[test] + fn reject_short_session_group_palette() { + let mut theme = Theme::dark(); + theme.colors.session_groups.truncate(2); + + let err = CodirigentTheme::try_from(&theme).expect_err("must fail"); + assert_eq!( + err, + ThemeConversionError::NotEnoughSessionGroupColors { + required: MIN_SESSION_GROUP_COLORS, + actual: 2, + } + ); + } + + #[test] + fn reject_invalid_hex_values() { + let mut theme = Theme::dark(); + theme.colors.terminal.cursor = "#12zz00".to_string(); + + let err = CodirigentTheme::try_from(&theme).expect_err("must fail"); + assert_eq!( + err, + ThemeConversionError::InvalidHex { + field: "colors.terminal.cursor", + value: "#12zz00".to_string(), + } + ); + } + + #[test] + fn parse_ansi_palette_uses_terminal_palette_entries() { + let theme = Theme::dark(); + let ansi = parse_ansi_colors(&theme.colors.terminal.palette).expect("ansi"); + assert_eq!(ansi.colors[1], Rgba::rgb(204, 0, 0)); + } + + #[test] + fn terminal_colors_include_full_surface_definition() { + let colors = TerminalColors { + background: "#000000".to_string(), + foreground: "#ffffff".to_string(), + cursor: "#abcdef".to_string(), + selection_background: "#11223344".to_string(), + selection_foreground: "#eeeeee".to_string(), + palette: Theme::dark().colors.terminal.palette, + }; + let cursor = parse_rgba(&colors.cursor, "cursor").expect("cursor"); + assert_eq!(cursor, Rgba::rgb(0xab, 0xcd, 0xef)); + } +} diff --git a/crates/codirigent-ui/src/theme_config/mod.rs b/crates/codirigent-ui/src/theme_config/mod.rs new file mode 100644 index 00000000..70e25a1d --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/mod.rs @@ -0,0 +1,99 @@ +//! Serializable theme configuration. +//! +//! This module provides a file-friendly schema for themes and a conversion path +//! into the runtime [`crate::theme::CodirigentTheme`] model used by the UI and +//! terminal renderer. + +mod builtins; +mod conversion; +mod schema; + +const DEFAULT_UI_FONT_FAMILY: &str = "Inter"; +const DEFAULT_BASE_FONT_SIZE: f32 = 13.0; +const DEFAULT_TERMINAL_FONT_SIZE: f32 = 13.0; +const DEFAULT_TERMINAL_LINE_HEIGHT: f32 = 1.0; +const DEFAULT_EXTRA_SMALL_SPACING: f32 = 2.0; +const DEFAULT_SMALL_SPACING: f32 = 4.0; +const DEFAULT_MEDIUM_SPACING: f32 = 8.0; +const DEFAULT_LARGE_SPACING: f32 = 16.0; +const DEFAULT_EXTRA_LARGE_SPACING: f32 = 24.0; +const DEFAULT_GRID_GAP: f32 = 4.0; +const DEFAULT_BORDER_RADIUS: f32 = 4.0; + +pub use conversion::ThemeConversionError; +pub use schema::{ + HexColor, TerminalColors, TerminalPalette, Theme, ThemeAccentColors, ThemeBackgroundColors, + ThemeBorderColors, ThemeColors, ThemeForegroundColors, ThemeInteractionColors, + ThemePriorityColors, ThemeSpacing, ThemeStatusColors, ThemeTypography, +}; + +impl Theme { + /// Parse a theme definition from JSON. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + /// Serialize a theme definition to pretty JSON. + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } +} + +impl Default for Theme { + fn default() -> Self { + Self::dark() + } +} + +impl Default for ThemeTypography { + fn default() -> Self { + Self { + ui_font_family: DEFAULT_UI_FONT_FAMILY.to_string(), + terminal_font_family: crate::theme::default_terminal_font_family().to_string(), + base_font_size: DEFAULT_BASE_FONT_SIZE, + terminal_font_size: DEFAULT_TERMINAL_FONT_SIZE, + line_height: DEFAULT_TERMINAL_LINE_HEIGHT, + } + } +} + +impl Default for ThemeSpacing { + fn default() -> Self { + Self { + xs: DEFAULT_EXTRA_SMALL_SPACING, + sm: DEFAULT_SMALL_SPACING, + md: DEFAULT_MEDIUM_SPACING, + lg: DEFAULT_LARGE_SPACING, + xl: DEFAULT_EXTRA_LARGE_SPACING, + grid_gap: DEFAULT_GRID_GAP, + border_radius: DEFAULT_BORDER_RADIUS, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dark_theme_serializes_and_round_trips() { + let theme = Theme::dark(); + let json = theme.to_json().expect("serialize"); + let loaded = Theme::from_json(&json).expect("deserialize"); + assert_eq!(theme, loaded); + } + + #[test] + fn light_theme_uses_runtime_terminal_font_family_default() { + let theme = Theme::light(); + assert_eq!( + theme.typography.terminal_font_family, + crate::theme::default_terminal_font_family() + ); + } + + #[test] + fn invalid_json_fails() { + assert!(Theme::from_json("not json").is_err()); + } +} diff --git a/crates/codirigent-ui/src/theme_config/schema.rs b/crates/codirigent-ui/src/theme_config/schema.rs new file mode 100644 index 00000000..7b6b9542 --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/schema.rs @@ -0,0 +1,186 @@ +use serde::{Deserialize, Serialize}; + +/// Color value in hex format. +/// +/// Supported forms for conversion into runtime colors are: +/// - `#RGB` +/// - `#RGBA` +/// - `#RRGGBB` +/// - `#RRGGBBAA` +pub type HexColor = String; + +/// Complete serializable theme definition. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Theme { + /// Theme identifier (unique). + pub id: String, + /// Human-readable name. + pub name: String, + /// Whether this is a dark theme. + pub is_dark: bool, + /// Theme color palette. + pub colors: ThemeColors, + /// Typography settings. + pub typography: ThemeTypography, + /// Spacing settings. + pub spacing: ThemeSpacing, +} + +/// Runtime-oriented theme color schema. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ThemeColors { + /// Background surfaces used across the application shell. + pub background: ThemeBackgroundColors, + /// Foreground/text colors. + pub foreground: ThemeForegroundColors, + /// Border colors. + pub border: ThemeBorderColors, + /// Hover, active, and selection state colors. + pub interaction: ThemeInteractionColors, + /// Accent colors and special-purpose highlights. + pub accent: ThemeAccentColors, + /// Session status colors. + pub status: ThemeStatusColors, + /// Task priority colors. + pub priority: ThemePriorityColors, + /// Session group palette. + pub session_groups: Vec, + /// Terminal appearance and ANSI palette. + pub terminal: TerminalColors, +} + +/// Background surfaces used across the workspace shell. +#[allow(missing_docs)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ThemeBackgroundColors { + pub app: HexColor, + pub panel: HexColor, + pub header: HexColor, + pub sidebar: HexColor, + pub icon_rail: HexColor, + pub drawer: HexColor, +} + +/// Foreground/text colors. +#[allow(missing_docs)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ThemeForegroundColors { + pub primary: HexColor, + pub secondary: HexColor, + pub muted: HexColor, +} + +/// Border colors. +#[allow(missing_docs)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ThemeBorderColors { + pub default: HexColor, + pub focused: HexColor, +} + +/// Interaction colors. +#[allow(missing_docs)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ThemeInteractionColors { + pub hover: HexColor, + pub active: HexColor, + pub selection: HexColor, +} + +/// Accent colors and highlight colors. +#[allow(missing_docs)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ThemeAccentColors { + pub primary: HexColor, + pub secondary: HexColor, + pub purple: HexColor, + pub orange: HexColor, + pub selected_ring: HexColor, + pub broadcast: HexColor, + pub ai_summary_background: HexColor, + pub ai_summary_text: HexColor, + pub input_required_background: HexColor, + pub input_required_accent: HexColor, +} + +/// Session status colors. +#[allow(missing_docs)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ThemeStatusColors { + pub idle: HexColor, + pub working: HexColor, + pub needs_attention: HexColor, + pub response_ready: HexColor, + pub error: HexColor, +} + +/// Task priority colors. +#[allow(missing_docs)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ThemePriorityColors { + pub high: HexColor, + pub medium: HexColor, + pub low: HexColor, +} + +/// Terminal surfaces and ANSI palette. +#[allow(missing_docs)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TerminalColors { + pub background: HexColor, + pub foreground: HexColor, + pub cursor: HexColor, + pub selection_background: HexColor, + pub selection_foreground: HexColor, + pub palette: TerminalPalette, +} + +/// ANSI 16-color palette. +#[allow(missing_docs)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TerminalPalette { + pub black: HexColor, + pub red: HexColor, + pub green: HexColor, + pub yellow: HexColor, + pub blue: HexColor, + pub magenta: HexColor, + pub cyan: HexColor, + pub white: HexColor, + pub bright_black: HexColor, + pub bright_red: HexColor, + pub bright_green: HexColor, + pub bright_yellow: HexColor, + pub bright_blue: HexColor, + pub bright_magenta: HexColor, + pub bright_cyan: HexColor, + pub bright_white: HexColor, +} + +/// Typography settings for serialized themes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ThemeTypography { + /// Main UI font family. + pub ui_font_family: String, + /// Terminal font family. + pub terminal_font_family: String, + /// Base UI font size in pixels. + pub base_font_size: f32, + /// Terminal font size in pixels. + pub terminal_font_size: f32, + /// Terminal line height multiplier. + pub line_height: f32, +} + +/// Spacing settings for serialized themes. +#[allow(missing_docs)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ThemeSpacing { + pub xs: f32, + pub sm: f32, + pub md: f32, + pub lg: f32, + pub xl: f32, + pub grid_gap: f32, + pub border_radius: f32, +} diff --git a/crates/codirigent-ui/src/theme_manager.rs b/crates/codirigent-ui/src/theme_manager.rs index d4622dbc..5c874ea2 100644 --- a/crates/codirigent-ui/src/theme_manager.rs +++ b/crates/codirigent-ui/src/theme_manager.rs @@ -17,10 +17,30 @@ //! assert_eq!(manager.active().id, "light"); //! ``` +use crate::theme::CodirigentTheme; use crate::theme_config::Theme; use anyhow::Result; use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; +use tracing::warn; + +/// Built-in theme ID used as the final fallback. +pub const DEFAULT_THEME_ID: &str = "dark"; +/// Directory name under the user config root that stores custom theme files. +pub const CUSTOM_THEME_DIRECTORY_NAME: &str = "themes"; + +/// Result of resolving a requested theme into a runtime theme. +#[derive(Debug, Clone)] +pub struct RuntimeThemeResolution { + /// Theme ID originally requested by the caller. + pub requested_id: String, + /// Theme ID that was actually resolved. + pub resolved_id: String, + /// Runtime theme used by the UI. + pub theme: CodirigentTheme, + /// Whether the resolution required a fallback. + pub used_fallback: bool, +} /// Manages available themes. /// @@ -64,13 +84,36 @@ impl ThemeManager { /// ``` pub fn with_defaults() -> Self { let mut themes = HashMap::new(); - themes.insert("dark".to_string(), Theme::dark()); + themes.insert(DEFAULT_THEME_ID.to_string(), Theme::dark()); themes.insert("light".to_string(), Theme::light()); Self { themes, - active_theme: "dark".to_string(), + active_theme: DEFAULT_THEME_ID.to_string(), + } + } + + /// Return the custom theme directory for a given user config root. + pub fn custom_theme_dir(user_config_dir: &Path) -> PathBuf { + user_config_dir.join(CUSTOM_THEME_DIRECTORY_NAME) + } + + /// Create with built-in themes plus any user-installed custom themes. + /// + /// Invalid theme files are logged and ignored. An unreadable themes + /// directory is also logged, but does not prevent the manager from + /// returning built-in themes. + pub fn with_user_themes(user_config_dir: &Path) -> Self { + let mut manager = Self::with_defaults(); + let theme_dir = Self::custom_theme_dir(user_config_dir); + if let Err(error) = manager.load_custom_themes(&theme_dir) { + warn!( + path = ?theme_dir, + error = %error, + "Failed to scan custom theme directory" + ); } + manager } /// Load custom themes from a directory. @@ -171,7 +214,7 @@ impl ThemeManager { pub fn active(&self) -> &Theme { self.themes.get(&self.active_theme).unwrap_or_else(|| { self.themes - .get("dark") + .get(DEFAULT_THEME_ID) .expect("Dark theme must always exist") }) } @@ -282,14 +325,14 @@ impl ThemeManager { /// ``` pub fn remove_theme(&mut self, id: &str) -> bool { // Cannot remove built-in themes - if id == "dark" || id == "light" { + if id == DEFAULT_THEME_ID || id == "light" { return false; } let removed = self.themes.remove(id).is_some(); // If we removed the active theme, fall back to dark if removed && self.active_theme == id { - self.active_theme = "dark".to_string(); + self.active_theme = DEFAULT_THEME_ID.to_string(); } removed @@ -302,7 +345,7 @@ impl ThemeManager { if self.active().is_dark { self.set_active("light"); } else { - self.set_active("dark"); + self.set_active(DEFAULT_THEME_ID); } } @@ -320,6 +363,38 @@ impl ThemeManager { pub fn light_themes(&self) -> Vec<&Theme> { self.themes.values().filter(|t| !t.is_dark).collect() } + + /// Convert a registered theme into the runtime theme model. + pub fn runtime_theme(&self, id: &str) -> Result { + let theme = self + .get(id) + .ok_or_else(|| anyhow::anyhow!("Theme '{id}' not found"))?; + CodirigentTheme::try_from(theme).map_err(anyhow::Error::from) + } + + /// Resolve a requested theme ID into a runtime theme, falling back to the + /// built-in dark theme when the requested theme is missing or invalid. + pub fn resolve_runtime_theme(&self, requested_id: &str) -> RuntimeThemeResolution { + if let Ok(theme) = self.runtime_theme(requested_id) { + return RuntimeThemeResolution { + requested_id: requested_id.to_string(), + resolved_id: requested_id.to_string(), + theme, + used_fallback: false, + }; + } + + let fallback_theme = self + .runtime_theme(DEFAULT_THEME_ID) + .unwrap_or_else(|_| CodirigentTheme::dark()); + + RuntimeThemeResolution { + requested_id: requested_id.to_string(), + resolved_id: DEFAULT_THEME_ID.to_string(), + theme: fallback_theme, + used_fallback: requested_id != DEFAULT_THEME_ID, + } + } } impl Default for ThemeManager { @@ -331,6 +406,7 @@ impl Default for ThemeManager { #[cfg(test)] mod tests { use super::*; + use crate::theme_config::TerminalPalette; use std::io::Write; use tempfile::tempdir; @@ -355,6 +431,16 @@ mod tests { assert_eq!(manager.len(), 2); } + #[test] + fn test_custom_theme_dir_appends_themes_directory() { + let config_dir = Path::new("/tmp/codirigent"); + + assert_eq!( + ThemeManager::custom_theme_dir(config_dir), + config_dir.join(CUSTOM_THEME_DIRECTORY_NAME) + ); + } + #[test] fn test_active() { let manager = ThemeManager::with_defaults(); @@ -574,6 +660,24 @@ mod tests { assert_eq!(manager.len(), 2); // Only built-in themes } + #[test] + fn test_with_user_themes_loads_from_themes_subdirectory() { + let dir = tempdir().unwrap(); + let themes_dir = ThemeManager::custom_theme_dir(dir.path()); + std::fs::create_dir_all(&themes_dir).unwrap(); + + let custom = Theme::from_runtime("aurora", "Aurora", false, &CodirigentTheme::light()); + let json = serde_json::to_string(&custom).unwrap(); + let theme_path = themes_dir.join("aurora.json"); + let mut file = std::fs::File::create(&theme_path).unwrap(); + file.write_all(json.as_bytes()).unwrap(); + + let manager = ThemeManager::with_user_themes(dir.path()); + + assert_eq!(manager.len(), 3); + assert!(manager.get("aurora").is_some()); + } + #[test] fn test_load_theme_file() { let dir = tempdir().unwrap(); @@ -621,4 +725,48 @@ mod tests { // Should fall back to dark theme assert_eq!(manager.active().id, "dark"); } + + #[test] + fn test_runtime_theme_converts_registered_theme() { + let manager = ThemeManager::with_defaults(); + let runtime = manager + .runtime_theme(DEFAULT_THEME_ID) + .expect("runtime theme"); + + assert_eq!(runtime.background, CodirigentTheme::dark().background); + assert_eq!( + runtime.terminal_cursor, + CodirigentTheme::dark().terminal_cursor + ); + } + + #[test] + fn test_resolve_runtime_theme_falls_back_for_missing_id() { + let manager = ThemeManager::with_defaults(); + let resolved = manager.resolve_runtime_theme("missing-theme"); + + assert_eq!(resolved.requested_id, "missing-theme"); + assert_eq!(resolved.resolved_id, DEFAULT_THEME_ID); + assert!(resolved.used_fallback); + assert_eq!( + resolved.theme.background, + CodirigentTheme::dark().background + ); + } + + #[test] + fn test_resolve_runtime_theme_falls_back_for_invalid_theme_payload() { + let mut manager = ThemeManager::with_defaults(); + let mut broken = Theme::dark(); + broken.id = "broken".to_string(); + broken.colors.terminal.palette = TerminalPalette { + red: "#zz0000".to_string(), + ..broken.colors.terminal.palette.clone() + }; + manager.add_theme(broken); + + let resolved = manager.resolve_runtime_theme("broken"); + assert_eq!(resolved.resolved_id, DEFAULT_THEME_ID); + assert!(resolved.used_fallback); + } } diff --git a/crates/codirigent-ui/src/workspace/core.rs b/crates/codirigent-ui/src/workspace/core.rs index f7330788..a77af9de 100644 --- a/crates/codirigent-ui/src/workspace/core.rs +++ b/crates/codirigent-ui/src/workspace/core.rs @@ -514,6 +514,39 @@ impl Workspace { } } + fn replace_hidden_session_into_grouped_pane( + &mut self, + pane_id: PaneId, + session_id: SessionId, + ) -> bool { + let Some(previous_active_session_id) = self.active_session_for_pane(pane_id.clone()) else { + return false; + }; + + if !self.set_pane_active_session(pane_id.clone(), session_id) { + return false; + } + + let Some(group) = self.pane_tab_groups.get_mut(&pane_id) else { + return true; + }; + + if let Some(index) = group + .session_ids + .iter() + .position(|current| *current == previous_active_session_id) + { + group.session_ids[index] = session_id; + } else if !group.session_ids.contains(&session_id) { + group.session_ids.push(session_id); + } + + let mut seen = HashSet::new(); + group.session_ids.retain(|current| seen.insert(*current)); + group.active_session_id = session_id; + true + } + fn remove_session_from_pane_group(&mut self, pane_id: PaneId, session_id: SessionId) { let Some(mut group) = self.pane_tab_groups.remove(&pane_id) else { return; @@ -993,79 +1026,143 @@ impl Workspace { } } - let focused = match &mut self.layout_state { - WorkspaceLayoutState::Grid(s) => { - if s.profile() == LayoutProfile::Single { - if let Some(visible_index) = - s.assignments().iter().position(|cell| *cell == Some(id)) + let focused = match &self.layout_state { + WorkspaceLayoutState::Grid(_) => { + enum GridFocusAction { + Complete(bool), + ReplaceGrouped(PaneId), + SwapHidden(usize), + } + + let action = { + let Some(state) = self.layout_state.as_grid_mut() else { + return false; + }; + + if state.profile() == LayoutProfile::Single { + if let Some(visible_index) = state + .assignments() + .iter() + .position(|cell| *cell == Some(id)) + { + state.focus_index(visible_index); + GridFocusAction::Complete(true) + } else { + let replacement_index = state.focused_index().or(Some(0)); + let Some(replacement_index) = replacement_index else { + return false; + }; + GridFocusAction::SwapHidden(replacement_index) + } + } else if let Some(target_index) = state + .assignments() + .iter() + .position(|cell| *cell == Some(id)) { - s.focus_index(visible_index); - true + state.focus_index(target_index); + GridFocusAction::Complete(true) } else { - let replacement_index = s.focused_index().or(Some(0)); + let replacement_index = state + .first_empty_index() + .or_else(|| state.focused_index()) + .or_else(|| state.occupied_indices().into_iter().next()) + .or_else(|| state.first_empty_index()); + let Some(replacement_index) = replacement_index else { return false; }; - s.swap_hidden_into_index(id, replacement_index).is_some() - } - } else if let Some(target_index) = - s.assignments().iter().position(|cell| *cell == Some(id)) - { - s.focus_index(target_index); - true - } else { - let replacement_index = s - .first_empty_index() - .or_else(|| s.focused_index()) - .or_else(|| s.occupied_indices().into_iter().next()) - .or_else(|| s.first_empty_index()); - let Some(replacement_index) = replacement_index else { - return false; - }; - - if s.session_at(replacement_index).is_none() { - if s.assign_session_to_index(id, replacement_index) { - s.focus_index(replacement_index); - true + if state.session_at(replacement_index).is_none() { + if state.assign_session_to_index(id, replacement_index) { + state.focus_index(replacement_index); + GridFocusAction::Complete(true) + } else { + GridFocusAction::Complete(false) + } + } else if self.pane_tab_groups.contains_key(&PaneId::GridCell { + index: replacement_index, + }) { + GridFocusAction::ReplaceGrouped(PaneId::GridCell { + index: replacement_index, + }) } else { - false + GridFocusAction::SwapHidden(replacement_index) } - } else { - s.swap_hidden_into_index(id, replacement_index).is_some() } + }; + + match action { + GridFocusAction::Complete(result) => result, + GridFocusAction::ReplaceGrouped(pane_id) => { + self.replace_hidden_session_into_grouped_pane(pane_id, id) + } + GridFocusAction::SwapHidden(index) => self + .layout_state + .as_grid_mut() + .and_then(|state| state.swap_hidden_into_index(id, index)) + .is_some(), } } - WorkspaceLayoutState::SplitTree(s) => { - if s.focus_session(id) { - true - } else { - let target_slot = s - .focused_slot() - .or_else(|| { - s.assignments().iter().find_map(|(slot, session)| { - if session.is_some() - || self - .pane_tab_groups - .contains_key(&PaneId::SplitSlot { slot: *slot }) - { - Some(*slot) - } else { - None - } - }) - }) - .or_else(|| { - s.assignments() - .iter() - .find_map(|(slot, session)| session.is_none().then_some(*slot)) - }); - - let Some(target_slot) = target_slot else { + WorkspaceLayoutState::SplitTree(_) => { + enum SplitFocusAction { + Complete(bool), + ReplaceGrouped(PaneId), + ReplaceSlot(SlotId), + } + + let action = { + let Some(state) = self.layout_state.as_split_tree_mut() else { return false; }; - s.replace_session_in_slot(target_slot, id).is_some() + if state.focus_session(id) { + SplitFocusAction::Complete(true) + } else { + let target_slot = state + .focused_slot() + .or_else(|| { + state.assignments().iter().find_map(|(slot, session)| { + if session.is_some() + || self + .pane_tab_groups + .contains_key(&PaneId::SplitSlot { slot: *slot }) + { + Some(*slot) + } else { + None + } + }) + }) + .or_else(|| { + state + .assignments() + .iter() + .find_map(|(slot, session)| session.is_none().then_some(*slot)) + }); + + let Some(target_slot) = target_slot else { + return false; + }; + + let target_pane = PaneId::SplitSlot { slot: target_slot }; + if self.pane_tab_groups.contains_key(&target_pane) { + SplitFocusAction::ReplaceGrouped(target_pane) + } else { + SplitFocusAction::ReplaceSlot(target_slot) + } + } + }; + + match action { + SplitFocusAction::Complete(result) => result, + SplitFocusAction::ReplaceGrouped(pane_id) => { + self.replace_hidden_session_into_grouped_pane(pane_id, id) + } + SplitFocusAction::ReplaceSlot(slot) => self + .layout_state + .as_split_tree_mut() + .and_then(|state| state.replace_session_in_slot(slot, id)) + .is_some(), } } }; diff --git a/crates/codirigent-ui/src/workspace/drawer_render.rs b/crates/codirigent-ui/src/workspace/drawer_render.rs index 9d718275..17e1e43d 100644 --- a/crates/codirigent-ui/src/workspace/drawer_render.rs +++ b/crates/codirigent-ui/src/workspace/drawer_render.rs @@ -1450,12 +1450,14 @@ impl WorkspaceView { #[cfg(test)] mod tests { use super::*; + use codirigent_core::types::session::generate_session_uuid; use codirigent_core::{GitRepoInfo, SessionId, SessionStatus}; use std::path::PathBuf; fn test_session(id: u64, working_directory: &str) -> Session { Session { id: SessionId(id), + session_uuid: generate_session_uuid(), name: format!("Session {id}"), status: SessionStatus::Idle, working_directory: PathBuf::from(working_directory), diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index 5928059a..97849ade 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -54,10 +54,10 @@ use crate::theme::CodirigentTheme; use crate::toolbar::CustomLayoutPicker; use codirigent_core::compaction::{CompactionConfig, CompactionService}; use codirigent_core::{ - CodexExecutionMode, DefaultEventBus, FileStorageService, ProcessMonitor, SessionId, + CodexExecutionMode, DefaultEventBus, EventBus, FileStorageService, ProcessMonitor, SessionId, SessionManager, SessionStatus, TaskManager, TaskManagerConfig, }; -use codirigent_detector::{InputDetector, NotificationManager}; +use codirigent_detector::{InputDetector, NotificationHandle}; use codirigent_filetree::FileTree; use codirigent_session::clipboard_service::{ClipboardService, DefaultClipboardService}; use codirigent_session::DefaultSessionManager; @@ -144,9 +144,27 @@ pub struct WorkspaceView { pub(super) cli_readers: Arc>, /// Cached detection results and memoized state. pub(super) cache: CacheState, - /// Notification manager — enforces master toggle, per-type toggles, and cooldown. + /// Notification handle — sends commands to a background actor for desktop notifications. /// All desktop notifications must go through this instead of calling send_notification directly. - pub(super) notification_manager: NotificationManager, + pub(super) notification_handle: NotificationHandle, + /// Counter incremented each maintenance poll cycle for tab pulse animation. + /// Render code derives pulse phase from `pulse_counter % 6` (3 ticks on, 3 off = ~750ms each). + pub(super) pulse_counter: u8, + + // --- Auto-update state --- + /// Update service for auto-update checking and downloading. + pub(super) update_service: Option>, + /// Current update info from the checker. + pub(super) update_info: Option, + /// Whether the user dismissed the update toast this session. + pub(super) update_dismissed: bool, + /// Download progress percentage (0-100) during download. + pub(super) update_download_progress: Option, + /// Staged update ready to apply. + pub(super) staged_update: Option, + /// Receiver for update events from the EventBus. + pub(super) update_event_rx: + Option>, } /// Returns `true` if the editor command refers to a terminal-based editor @@ -166,6 +184,10 @@ impl WorkspaceView { const MAINTENANCE_POLL_INTERVAL_MS: u64 = 250; /// Debounce window for persisted app-state saves. const STATE_SAVE_DEBOUNCE: Duration = Duration::from_millis(200); + /// Delta used to derive the small and large UI font variants from the base size. + pub(super) const UI_FONT_VARIANT_DELTA: f32 = 2.0; + /// Lower bound for derived small UI text. + pub(super) const MIN_UI_SMALL_FONT_SIZE: f32 = 8.0; fn session_is_shell_idle(&self, session_id: SessionId) -> bool { self.with_detector(|detector| { @@ -458,6 +480,23 @@ impl WorkspaceView { ); } + let update_service = match codirigent_updater::UpdateService::new( + env!("CARGO_PKG_VERSION"), + event_bus.clone(), + ) { + Ok(svc) => { + svc.start_background_check(); + Some(Arc::new(svc)) + } + Err(e) => { + tracing::warn!("Failed to initialize update service: {}", e); + None + } + }; + + // Subscribe to EventBus for update events + let update_event_rx = Some(event_bus.subscribe()); + let (storage, task_manager) = Self::init_task_manager(event_bus.clone()); let (file_tree, file_tree_model, project_root) = Self::init_file_tree(); @@ -521,7 +560,14 @@ impl WorkspaceView { update_tx, cli_readers: Arc::new(Mutex::new(CliReaders::new())), cache: CacheState::new(), - notification_manager: NotificationManager::new(Default::default()), + notification_handle: NotificationHandle::new(Default::default()), + pulse_counter: 0, + update_service, + update_info: None, + update_dismissed: false, + update_download_progress: None, + staged_update: None, + update_event_rx, }; // Pre-detect editors and shells in the background so settings open instantly @@ -816,8 +862,9 @@ impl WorkspaceView { pub(super) fn apply_ui_font_size(&mut self, size: f32) { let theme = self.workspace.theme_mut(); theme.font_size_base = size; - theme.font_size_small = (size - 2.0).max(8.0); - theme.font_size_large = size + 2.0; + theme.font_size_small = + (size - Self::UI_FONT_VARIANT_DELTA).max(Self::MIN_UI_SMALL_FONT_SIZE); + theme.font_size_large = size + Self::UI_FONT_VARIANT_DELTA; } /// Apply terminal font size update to theme and all terminal views. @@ -893,20 +940,131 @@ impl WorkspaceView { &mut self.terminals } + /// Handle keyboard input when the settings panel is open. + /// + /// **Precondition:** must only be called when `self.settings.open` is true. + /// Returns `true` if the key was consumed and should not be forwarded to the PTY. + fn handle_settings_key(&mut self, event: &KeyDownEvent, cx: &mut Context) -> bool { + debug_assert!( + self.settings.open, + "handle_settings_key called with settings closed" + ); + // Navigate Keyboard Shortcuts panel with keyboard when not recording. + if self + .settings + .page + .as_ref() + .map(|p| { + p.active_category() == crate::settings::SettingsCategory::KeyboardShortcuts + && p.recording_shortcut.is_none() + }) + .unwrap_or(false) + { + let key = event.keystroke.key.as_str(); + let shift = event.keystroke.modifiers.shift; + let handled = match key { + "tab" | "down" | "up" => { + let sorted_keys = self + .settings + .page + .as_ref() + .map(|p| p.sorted_shortcut_keys.as_slice()) + .unwrap_or_default(); + let move_down = (key == "tab" && !shift) || key == "down"; + let new_focus = self.settings.page.as_ref().and_then(|p| { + super::impl_shortcuts_nav::navigate_shortcuts_focus( + p, + sorted_keys, + move_down, + ) + }); + if let Some(page) = self.settings.page.as_mut() { + page.focused_shortcut_row = new_focus; + } + cx.notify(); + true + } + "enter" | " " => { + if let Some(focused) = self + .settings + .page + .as_ref() + .and_then(|p| p.focused_shortcut_row.clone()) + { + if let Some(page) = self.settings.page.as_mut() { + page.recording_shortcut = Some(focused); + } + cx.notify(); + } + // When no row is focused, Enter/Space is still consumed (not forwarded to PTY) + // because the settings panel is open and these keys have no terminal meaning here. + true + } + _ => false, + }; + if handled { + cx.stop_propagation(); + return true; + } + } + + // When a shortcut is being recorded in the Keyboard Shortcuts settings panel, + // capture the next meaningful keystroke and save it. + if let Some(action_name) = self + .settings + .page + .as_ref() + .and_then(|p| p.recording_shortcut.clone()) + { + if event.keystroke.key == "escape" { + // Escape cancels recording without saving and without closing settings. + if let Some(page) = self.settings.page.as_mut() { + page.recording_shortcut = None; + page.focused_shortcut_row = None; + } + cx.notify(); + cx.stop_propagation(); + return true; + } + if let Some(binding_str) = + super::impl_shortcuts_recording::format_keystroke_as_binding(&event.keystroke) + { + if let Some(page) = self.settings.page.as_mut() { + page.user_settings + .keybindings + .insert(action_name, binding_str); + page.refresh_sorted_shortcut_keys(); + page.recording_shortcut = None; + page.focused_shortcut_row = None; + page.user_save_pending = true; + } + self.maybe_schedule_settings_save(cx); + cx.notify(); + } + cx.stop_propagation(); + return true; + } + + false + } + /// Handle keyboard input for the focused session. fn handle_key_down( &mut self, event: &KeyDownEvent, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { - // Suppress unused-variable warning on macOS where the cfg-gated - // Ctrl-shortcut block (which uses `window`) is compiled out. - #[cfg(target_os = "macos")] - let _ = &window; - - // Escape closes settings page if open - if self.settings.open && event.keystroke.key == "escape" { + // Escape closes settings page if open — but only when NOT recording a shortcut. + // When recording, Escape cancels the recording instead (handled below). + if self.settings.open + && event.keystroke.key == "escape" + && self + .settings + .page + .as_ref() + .map_or(true, |p| p.recording_shortcut.is_none()) + { self.close_settings(cx); cx.notify(); return; @@ -918,77 +1076,23 @@ impl WorkspaceView { return; } + if self.settings.open && self.handle_settings_key(event, cx) { + return; + } + // Don't send platform-modifier shortcuts to PTY (handled as GPUI actions). - // On macOS, `platform` maps to Command key and GPUI's `cmd-v` bindings work - // natively. On Windows/Linux, `platform` is false for Ctrl+key, so we handle - // Ctrl shortcuts directly below. + // GPUI's `secondary-` bindings map to Cmd on macOS and Ctrl on + // Windows/Linux, so the action system handles all modifier shortcuts correctly. if event.keystroke.modifiers.platform { return; } - // On Windows/Linux, GPUI's `cmd-` keybindings expect `modifiers.platform`, - // but Ctrl+key only sets `modifiers.control`. The action system never matches, - // so we must handle Ctrl shortcuts directly here. + // On Windows/Linux, Ctrl sets modifiers.control (not modifiers.platform). + // Guard here so Ctrl+ never reaches the PTY even if the GPUI action + // system fails to match a secondary-* binding. #[cfg(not(target_os = "macos"))] if event.keystroke.modifiers.control { - let key = event.keystroke.key.as_ref(); - match key { - "v" => { - self.handle_paste(&crate::app::Paste, window, cx); - return; - } - "c" => { - self.handle_copy(&crate::app::Copy, window, cx); - return; - } - "n" => { - self.create_session(cx); - return; - } - "w" => { - self.close_focused_session(cx); - return; - } - "q" => { - cx.quit(); - return; - } - "b" => { - self.toggle_task_board(cx); - return; - } - "e" => { - self.toggle_sidebar(cx); - return; - } - "k" => { - self.toggle_sidebar(cx); - return; - } - "p" if event.keystroke.modifiers.shift => { - self.open_task_creation_modal(); - cx.notify(); - return; - } - "\\" => { - self.next_layout(cx); - return; - } - "," => { - self.open_settings(); - cx.notify(); - return; - } - "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" => { - let num: usize = match key.parse::() { - Ok(n) => n, - Err(_) => return, // unreachable given match arm, but safe - }; - self.focus_session_number(num, cx); - return; - } - _ => {} // Other Ctrl combos go to PTY (Ctrl+D, Ctrl+L, etc.) - } + return; } // Text input (including IME commits) is delivered through the @@ -1417,6 +1521,9 @@ impl EntityInputHandler for WorkspaceView { // Modal text fields are handled via key events; do not leak input to PTY. return; } + if self.settings.open { + return; + } let had_ime_overlay = self.ime_marked_range.is_some() || self.ime_preedit_text.is_some(); self.ime_marked_range = None; @@ -1449,7 +1556,7 @@ impl EntityInputHandler for WorkspaceView { _window: &mut Window, cx: &mut Context, ) { - if self.has_blocking_modal() { + if self.has_blocking_modal() || self.settings.open { let had_ime_overlay = self.ime_marked_range.is_some() || self.ime_preedit_text.is_some(); self.ime_marked_range = None; @@ -1577,7 +1684,7 @@ impl Render for WorkspaceView { sidebar_width: actual_sidebar_width, right_panel_width: right_panel_w, grid_gap: self.workspace.theme().grid_gap, - focused_session_id: self.render_focus_signature(), + rendered_sessions_signature: self.rendered_session_signature(), }; if self.cache.render_cell_info_dirty || self.cache.render_layout_signature != Some(layout_signature) @@ -1607,6 +1714,8 @@ impl Render for WorkspaceView { .on_action(cx.listener(Self::handle_close_session)) .on_action(cx.listener(Self::handle_next_layout)) .on_action(cx.listener(Self::handle_toggle_sidebar)) + .on_action(cx.listener(Self::handle_toggle_task_board)) + .on_action(cx.listener(Self::handle_quick_switch)) .on_action(cx.listener(Self::handle_focus_session1)) .on_action(cx.listener(Self::handle_focus_session2)) .on_action(cx.listener(Self::handle_focus_session3)) @@ -1644,6 +1753,12 @@ impl Render for WorkspaceView { container = self.render_main_workspace(container, window, cx, grid_gap); container = self.render_active_modals(container, cx); container = self.render_overlays(container, cx); + + // Update toast (rendered last, on top of everything) + if let Some(toast) = self.render_update_toast(cx) { + container = container.child(toast); + } + container } } diff --git a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs index a3fc7225..519533ec 100644 --- a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs +++ b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs @@ -7,6 +7,7 @@ use crate::workspace::types::{ }; use codirigent_core::{CodirigentEvent, EventBus, SessionId, SessionManager}; use gpui::{Context, Window}; +use std::hash::{Hash, Hasher}; use std::time::{Duration, Instant}; use tracing::warn; @@ -45,25 +46,14 @@ impl WorkspaceView { }) } - pub(super) fn render_focus_signature(&self) -> Option { - Self::render_focus_signature_for_layout( - self.workspace.layout_profile(), - self.workspace.focused_session_id(), - ) + pub(super) fn rendered_session_signature(&self) -> u64 { + Self::rendered_session_signature_for_ids(&self.workspace.visible_session_ids()) } - fn render_focus_signature_for_layout( - layout_profile: crate::layout::LayoutProfile, - focused_session_id: Option, - ) -> Option { - // Only single-pane mode swaps which session is visibly rendered when focus - // changes. Multi-pane layouts already render every visible session, so - // focus changes alone should not invalidate the cell-layout cache. - if layout_profile == crate::layout::LayoutProfile::Single { - focused_session_id - } else { - None - } + fn rendered_session_signature_for_ids(session_ids: &[SessionId]) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + session_ids.hash(&mut hasher); + hasher.finish() } /// Cycle to next layout. @@ -312,24 +302,30 @@ mod tests { } #[test] - fn test_render_focus_signature_tracks_focus_in_single_layout() { - assert_eq!( - super::WorkspaceView::render_focus_signature_for_layout( - crate::layout::LayoutProfile::Single, - Some(codirigent_core::SessionId(2)), - ), - Some(codirigent_core::SessionId(2)) - ); + fn test_rendered_session_signature_changes_when_visible_sessions_change() { + let a = super::WorkspaceView::rendered_session_signature_for_ids(&[ + codirigent_core::SessionId(1), + codirigent_core::SessionId(2), + ]); + let b = super::WorkspaceView::rendered_session_signature_for_ids(&[ + codirigent_core::SessionId(1), + codirigent_core::SessionId(3), + ]); + + assert_ne!(a, b); } #[test] - fn test_render_focus_signature_ignores_focus_outside_single_layout() { - assert_eq!( - super::WorkspaceView::render_focus_signature_for_layout( - crate::layout::LayoutProfile::Grid2x2, - Some(codirigent_core::SessionId(2)), - ), - None - ); + fn test_rendered_session_signature_is_stable_for_same_visible_sessions() { + let a = super::WorkspaceView::rendered_session_signature_for_ids(&[ + codirigent_core::SessionId(2), + codirigent_core::SessionId(5), + ]); + let b = super::WorkspaceView::rendered_session_signature_for_ids(&[ + codirigent_core::SessionId(2), + codirigent_core::SessionId(5), + ]); + + assert_eq!(a, b); } } diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs index a154ed5a..ddfe80e5 100644 --- a/crates/codirigent-ui/src/workspace/grid_render.rs +++ b/crates/codirigent-ui/src/workspace/grid_render.rs @@ -301,7 +301,16 @@ impl WorkspaceView { tv.scroll_up(lines); } else if delta_y < 0.0 { let lines = (-delta_y / cell_h).ceil().max(1.0) as usize; - tv.scroll_down(lines); + // Snap to bottom when scrolling down within one viewport + // of the live view. Without this, accidentally scrolling + // up by even 1 line causes every new output line to push + // the viewport further from the bottom, making it feel + // like there is no bottom wall. + if tv.display_offset() <= tv.rows() as usize + lines { + tv.scroll_to_bottom(); + } else { + tv.scroll_down(lines); + } } cx.notify(); } diff --git a/crates/codirigent-ui/src/workspace/impl_action_handlers.rs b/crates/codirigent-ui/src/workspace/impl_action_handlers.rs index ddf41075..1f933b08 100644 --- a/crates/codirigent-ui/src/workspace/impl_action_handlers.rs +++ b/crates/codirigent-ui/src/workspace/impl_action_handlers.rs @@ -110,6 +110,31 @@ impl WorkspaceView { info!("ToggleSidebar action triggered"); self.toggle_sidebar(cx); } + + /// Handle ToggleTaskBoard action (Ctrl+T / Cmd+T). + pub(super) fn handle_toggle_task_board( + &mut self, + _action: &ToggleTaskBoard, + _window: &mut Window, + cx: &mut Context, + ) { + info!("ToggleTaskBoard action triggered"); + self.toggle_task_board(cx); + } + + /// Handle QuickSwitch action (Ctrl+K / Cmd+K). + /// + /// This is a placeholder — no dedicated session-picker UI exists yet. + /// Toggles the sidebar as a stand-in until a real session picker is built. + pub(super) fn handle_quick_switch( + &mut self, + _action: &QuickSwitch, + _window: &mut Window, + cx: &mut Context, + ) { + info!("QuickSwitch action triggered (toggling sidebar as placeholder)"); + self.toggle_sidebar(cx); + } } /// Generate FocusSession handler methods for WorkspaceView. diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling.rs b/crates/codirigent-ui/src/workspace/impl_output_polling.rs index e45e9e72..c82c3520 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling.rs @@ -177,6 +177,76 @@ impl WorkspaceView { if self.update_clipboard_preview(cx) { cx.notify(); } + + self.pulse_counter = self.pulse_counter.wrapping_add(1); + + self.poll_update_events(cx); + } + + /// Drain update-related events from the EventBus broadcast receiver. + fn poll_update_events(&mut self, cx: &mut gpui::Context) { + let Some(rx) = self.update_event_rx.as_mut() else { + return; + }; + + // Drain all pending update events from the broadcast channel. + loop { + match rx.try_recv() { + Ok(event) => match event { + codirigent_core::CodirigentEvent::UpdateAvailable { + version: _, + release_url: _, + } => { + if !self.update_dismissed { + if let Some(svc) = &self.update_service { + if let codirigent_updater::UpdateState::UpdateAvailable(info) = + svc.state() + { + self.update_info = Some(info); + } + } + cx.notify(); + } + } + codirigent_core::CodirigentEvent::UpdateDownloadProgress { percent } => { + self.update_download_progress = Some(percent); + cx.notify(); + } + codirigent_core::CodirigentEvent::UpdateReadyToApply => { + if let Some(svc) = &self.update_service { + if let codirigent_updater::UpdateState::Staged(staged) = svc.state() { + self.staged_update = Some(staged); + self.update_download_progress = None; + } + } + cx.notify(); + } + codirigent_core::CodirigentEvent::UpdateApplyingOnStartup => { + // A staged update is being applied — quit so the helper + // script can swap the app and relaunch. + cx.quit(); + } + codirigent_core::CodirigentEvent::UpdateFailed { error } => { + warn!("Update failed: {}", error); + self.update_download_progress = None; + cx.notify(); + } + _ => { + // Ignore non-update events. + } + }, + Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break, + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(n)) => { + warn!("Update event receiver lagged by {} messages", n); + // Continue draining. + } + Err(tokio::sync::broadcast::error::TryRecvError::Closed) => { + // Channel closed — stop polling. + self.update_event_rx = None; + break; + } + } + } } pub(super) fn spawn_background_detector_maintenance(&mut self, cx: &mut Context) { diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs index 83e51213..419169fb 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs @@ -423,7 +423,7 @@ impl WorkspaceView { } Some(tool) => (NotificationType::PermissionPrompt, Some(tool)), }; - this.notification_manager.notify( + this.notification_handle.send( notif_type, result.session_id, &session_name, diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs index 1d4576a4..a6eca093 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs @@ -66,21 +66,33 @@ struct HookSignal { sandbox_policy_type: Option, /// Codirigent session ID, present only when Claude Code was spawned by Codirigent /// (via the `CODIRIGENT_SESSION_ID` environment variable). + #[serde(default)] codirigent_session_id: Option, + #[serde(default)] + codirigent_session_uuid: Option, ts: u64, } #[derive(Debug)] struct HookSignalUpdate { - session_id: SessionId, + session_id: Option, signal_file_id: String, cli_session_id: Option, + codirigent_session_id: Option, + codirigent_session_uuid: Option, codex_execution_mode: Option, status: String, cli_type: Option, ts: u64, } +#[derive(Debug, Clone)] +struct ClaudeRoutingSession { + id: SessionId, + claude_session_id: Option, + session_uuid: String, +} + fn codex_execution_mode_fingerprint(mode: Option) -> Option<&'static str> { match mode { Some(CodexExecutionMode::FullAuto) => Some("full-auto"), @@ -123,6 +135,7 @@ fn resolve_hook_cli_session_id( signal_file_id: &str, explicit_cli_session_id: Option<&str>, session_id: SessionId, + allow_filename_backfill: bool, ) -> Option { if let Some(explicit_id) = explicit_cli_session_id .map(str::trim) @@ -140,6 +153,10 @@ fn resolve_hook_cli_session_id( return None; } + if !allow_filename_backfill { + return None; + } + let fallback = signal_file_id.trim(); if fallback.is_empty() || fallback == session_id.0.to_string() { return None; @@ -156,6 +173,63 @@ fn resolve_hook_cli_session_id( Some(fallback.to_owned()) } +fn resolve_claude_target_session( + sessions: &[ClaudeRoutingSession], + cli_session_id: Option<&str>, + codirigent_session_uuid: Option<&str>, +) -> Option { + if let Some(cli_id) = cli_session_id { + let matches: Vec<_> = sessions + .iter() + .filter(|session| session.claude_session_id.as_deref() == Some(cli_id)) + .collect(); + match matches.len() { + 1 => return Some(matches[0].id), + n if n > 1 => { + warn!( + cli_session_id = cli_id, + count = n, + "Ambiguous Claude cli_session_id match" + ); + return None; + } + _ => {} + } + } + + if let Some(session_uuid) = codirigent_session_uuid { + let matches: Vec<_> = sessions + .iter() + .filter(|session| session.session_uuid == session_uuid) + .collect(); + match matches.len() { + 1 => return Some(matches[0].id), + n if n > 1 => { + warn!( + session_uuid, + count = n, + "Ambiguous Claude session_uuid match" + ); + return None; + } + _ => {} + } + } + + warn!( + ?cli_session_id, + ?codirigent_session_uuid, + "No Claude session matched for hook signal; discarding" + ); + None +} + +fn parse_legacy_hook_session_id(codirigent_session_id: Option<&str>) -> Option { + codirigent_session_id + .and_then(|id| id.parse::().ok()) + .map(SessionId) +} + fn codex_execution_mode_from_approval_and_sandbox( approval_policy: Option<&str>, sandbox_policy_type: Option<&str>, @@ -228,19 +302,12 @@ fn read_recent_hook_signal_updates() -> Vec { continue; } - let session_id = match signal - .codirigent_session_id - .as_deref() - .and_then(|id| id.parse::().ok()) - { - Some(id) => SessionId(id), - None => continue, - }; - updates.push(HookSignalUpdate { - session_id, + session_id: None, signal_file_id, cli_session_id: signal.cli_session_id, + codirigent_session_id: signal.codirigent_session_id, + codirigent_session_uuid: signal.codirigent_session_uuid, codex_execution_mode: codex_execution_mode_from_approval_and_sandbox( signal.approval_policy.as_deref(), signal.sandbox_policy_type.as_deref(), @@ -288,6 +355,8 @@ impl WorkspaceView { session_id, signal_file_id, cli_session_id, + codirigent_session_id, + codirigent_session_uuid, codex_execution_mode, status, cli_type, @@ -319,18 +388,49 @@ impl WorkspaceView { let mut id_changed = false; let mut cli_type_changed = false; let cli_type_name = cli_type.as_deref().unwrap_or(CLI_TYPE_CLAUDE); + let resolved_session_id = if cli_type_name == CLI_TYPE_CLAUDE { + let routing_sessions = self + .workspace + .sessions() + .iter() + .map(|session| ClaudeRoutingSession { + id: session.id, + claude_session_id: session.claude_session_id.clone(), + session_uuid: session.session_uuid.clone(), + }) + .collect::>(); + match resolve_claude_target_session( + &routing_sessions, + cli_session_id.as_deref(), + codirigent_session_uuid.as_deref(), + ) { + Some(session_id) => session_id, + None => return, + } + } else { + match session_id + .or_else(|| parse_legacy_hook_session_id(codirigent_session_id.as_deref())) + { + Some(session_id) => session_id, + None => return, + } + }; if let Some(cli_type) = cli_type_from_hook_signal_name(cli_type_name) { let current_cli_type = self .clipboard .clipboard_service - .get_session_cli_type(session_id); + .get_session_cli_type(resolved_session_id); self.clipboard .clipboard_service - .set_session_cli_type(session_id, cli_type); + .set_session_cli_type(resolved_session_id, cli_type); cli_type_changed = current_cli_type != cli_type; } - let resolved_cli_session_id = - resolve_hook_cli_session_id(&signal_file_id, cli_session_id.as_deref(), session_id); + let resolved_cli_session_id = resolve_hook_cli_session_id( + &signal_file_id, + cli_session_id.as_deref(), + resolved_session_id, + cli_type_name != CLI_TYPE_CLAUDE, + ); if let Some(cli_session_id) = resolved_cli_session_id.as_deref() { match cli_type_name { CLI_TYPE_CLAUDE => { @@ -339,7 +439,7 @@ impl WorkspaceView { .lock() .ok() .and_then(|mgr| { - mgr.with_session_state_mut(session_id, |state| { + mgr.with_session_state_mut(resolved_session_id, |state| { let changed = state.session.claude_session_id.as_deref() != Some(cli_session_id); state.session.claude_session_id = Some(cli_session_id.to_owned()); @@ -354,7 +454,7 @@ impl WorkspaceView { .lock() .ok() .and_then(|mgr| { - mgr.with_session_state_mut(session_id, |state| { + mgr.with_session_state_mut(resolved_session_id, |state| { let changed = state.session.gemini_session_id.as_deref() != Some(cli_session_id); state.session.gemini_session_id = Some(cli_session_id.to_owned()); @@ -369,7 +469,7 @@ impl WorkspaceView { .lock() .ok() .and_then(|mgr| { - mgr.with_session_state_mut(session_id, |state| { + mgr.with_session_state_mut(resolved_session_id, |state| { let changed = state.session.codex_session_id.as_deref() != Some(cli_session_id); state.session.codex_session_id = Some(cli_session_id.to_owned()); @@ -377,7 +477,7 @@ impl WorkspaceView { }) }) .unwrap_or(false); - if let Some(session) = self.workspace.session_mut(session_id) { + if let Some(session) = self.workspace.session_mut(resolved_session_id) { if session.codex_session_id.as_deref() != Some(cli_session_id) { session.codex_session_id = Some(cli_session_id.to_owned()); id_changed = true; @@ -394,7 +494,7 @@ impl WorkspaceView { if cli_type_name == CLI_TYPE_CODEX { if let Some(mode) = codex_execution_mode { - self.set_session_codex_execution_mode(session_id, Some(mode), cx); + self.set_session_codex_execution_mode(resolved_session_id, Some(mode), cx); } let started_at = chrono::Utc::now(); let manager_changed = self @@ -402,7 +502,7 @@ impl WorkspaceView { .lock() .ok() .and_then(|mgr| { - mgr.with_session_state_mut(session_id, |state| { + mgr.with_session_state_mut(resolved_session_id, |state| { if state.session.codex_started_at.is_none() { state.session.codex_started_at = Some(started_at); true @@ -414,7 +514,7 @@ impl WorkspaceView { .unwrap_or(false); let workspace_changed = self .workspace - .session_mut(session_id) + .session_mut(resolved_session_id) .map(|session| { if session.codex_started_at.is_none() { session.codex_started_at = Some(started_at); @@ -432,8 +532,11 @@ impl WorkspaceView { } let focused_id = self.workspace.focused_session_id(); - let is_focused = Some(session_id) == focused_id; - let prev_status = self.workspace.session(session_id).map(|s| s.status); + let is_focused = Some(resolved_session_id) == focused_id; + let prev_status = self + .workspace + .session(resolved_session_id) + .map(|s| s.status); let new_status = match status.as_str() { "working" => SessionStatus::Working, "needs_attention" => SessionStatus::NeedsAttention, @@ -459,12 +562,12 @@ impl WorkspaceView { if let Ok(mut readers) = self.cli_readers.lock() { let status_since = readers .cached_status - .get(&session_id) + .get(&resolved_session_id) .filter(|c| c.status == new_status) .map(|c| c.status_since) .unwrap_or_else(Instant::now); readers.cached_status.insert( - session_id, + resolved_session_id, CachedCliStatus { status: new_status, seen_at: Instant::now(), @@ -481,17 +584,17 @@ impl WorkspaceView { && prev_status_for_notif != SessionStatus::NeedsAttention { self.event_bus.publish(CodirigentEvent::AttentionRequired { - session_id, + session_id: resolved_session_id, detail: None, }); let name = self .workspace - .session(session_id) + .session(resolved_session_id) .map(|s| s.name.clone()) - .unwrap_or_else(|| format!("Session {}", session_id.0)); - self.notification_manager.notify( + .unwrap_or_else(|| format!("Session {}", resolved_session_id.0)); + self.notification_handle.send( NotificationType::InputRequired, - session_id, + resolved_session_id, &name, None, ); @@ -502,19 +605,19 @@ impl WorkspaceView { { let name = self .workspace - .session(session_id) + .session(resolved_session_id) .map(|s| s.name.clone()) - .unwrap_or_else(|| format!("Session {}", session_id.0)); - self.notification_manager.notify( + .unwrap_or_else(|| format!("Session {}", resolved_session_id.0)); + self.notification_handle.send( NotificationType::ResponseReady, - session_id, + resolved_session_id, &name, None, ); } - if self.sync_session_status(session_id) || cli_type_changed { - self.sync_session_header(session_id); + if self.sync_session_status(resolved_session_id) || cli_type_changed { + self.sync_session_header(resolved_session_id); cx.notify(); } } @@ -532,10 +635,23 @@ mod tests { approval_policy: None, sandbox_policy_type: None, codirigent_session_id: codirigent_session_id.map(str::to_owned), + codirigent_session_uuid: None, ts, } } + fn claude_session( + id: u64, + claude_session_id: Option<&str>, + session_uuid: &str, + ) -> ClaudeRoutingSession { + ClaudeRoutingSession { + id: SessionId(id), + claude_session_id: claude_session_id.map(str::to_owned), + session_uuid: session_uuid.to_owned(), + } + } + #[test] fn hook_signal_without_codirigent_id_is_ignored() { // Signals without codirigent_session_id come from Claude Code started @@ -659,13 +775,16 @@ mod tests { #[test] fn numeric_signal_file_id_is_not_treated_as_codex_session_id() { - assert_eq!(resolve_hook_cli_session_id("3", None, SessionId(3)), None); + assert_eq!( + resolve_hook_cli_session_id("3", None, SessionId(3), true), + None + ); } #[test] fn non_numeric_signal_file_id_can_backfill_cli_session_id() { assert_eq!( - resolve_hook_cli_session_id("codex-uuid", None, SessionId(3)), + resolve_hook_cli_session_id("codex-uuid", None, SessionId(3), true), Some("codex-uuid".to_string()) ); } @@ -673,7 +792,7 @@ mod tests { #[test] fn explicit_cli_session_id_wins_over_signal_file_id() { assert_eq!( - resolve_hook_cli_session_id("3", Some("real-codex-id"), SessionId(3)), + resolve_hook_cli_session_id("3", Some("real-codex-id"), SessionId(3), true), Some("real-codex-id".to_string()) ); } @@ -681,15 +800,70 @@ mod tests { #[test] fn unsafe_hook_cli_session_id_is_rejected() { assert_eq!( - resolve_hook_cli_session_id("3", Some("bad;id"), SessionId(3)), + resolve_hook_cli_session_id("3", Some("bad;id"), SessionId(3), true), + None + ); + assert_eq!( + resolve_hook_cli_session_id("bad;id", None, SessionId(3), true), None ); + } + + #[test] + fn claude_signal_does_not_backfill_cli_session_id_from_filename() { + assert_eq!( + resolve_hook_cli_session_id("claude-session-id", None, SessionId(3), false), + None + ); + } + + #[test] + fn claude_signal_routes_by_cli_session_id_before_session_uuid() { + let sessions = vec![ + claude_session(1, Some("claude-parent"), "uuid-parent"), + claude_session(2, Some("claude-other"), "uuid-other"), + ]; + + assert_eq!( + resolve_claude_target_session(&sessions, Some("claude-parent"), Some("uuid-other")), + Some(SessionId(1)) + ); + } + + #[test] + fn claude_signal_falls_back_to_codirigent_session_uuid() { + let sessions = vec![ + claude_session(1, Some("claude-parent"), "uuid-parent"), + claude_session(2, Some("claude-other"), "uuid-other"), + ]; + assert_eq!( - resolve_hook_cli_session_id("bad;id", None, SessionId(3)), + resolve_claude_target_session(&sessions, Some("unknown-subagent"), Some("uuid-parent")), + Some(SessionId(1)) + ); + } + + #[test] + fn ambiguous_claude_uuid_match_is_rejected() { + let sessions = vec![ + claude_session(1, Some("claude-parent"), "shared-uuid"), + claude_session(2, Some("claude-other"), "shared-uuid"), + ]; + + assert_eq!( + resolve_claude_target_session(&sessions, None, Some("shared-uuid")), None ); } + #[test] + fn claude_signal_with_no_uuid_fields_is_discarded() { + // A legacy signal that has only a numeric codirigent_session_id and no + // UUID fields must not route to any Claude session. + let sessions = vec![claude_session(1, Some("claude-abc"), "uuid-abc")]; + assert_eq!(resolve_claude_target_session(&sessions, None, None), None); + } + #[test] fn hook_signal_cli_type_maps_to_codex() { assert_eq!( diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs index 2eb5c558..9d44df50 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs @@ -416,6 +416,7 @@ impl WorkspaceView { } self.spawn_session_git_refresh(session_id, mgr_session.working_directory.clone(), cx); + self.save_state_to_disk(cx); any_dirty = true; } diff --git a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs index 654c393e..83c177fa 100644 --- a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs +++ b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs @@ -34,6 +34,7 @@ use tracing::{info, warn}; #[derive(Debug, Clone)] struct RestoreSessionPlan { original_session_id: SessionId, + session_uuid: String, session_name: String, working_dir: PathBuf, shell: Option, @@ -655,6 +656,7 @@ mod tests { fn restore_resume_commands_preserve_cli_order() { let plan = RestoreSessionPlan { original_session_id: SessionId(1), + session_uuid: "session-uuid-1".to_string(), session_name: "Session 1".to_string(), working_dir: sample_working_dir(), shell: None, @@ -963,6 +965,7 @@ mod tests { fn restore_plan_cli_type_prefers_known_resume_metadata() { let base = RestoreSessionPlan { original_session_id: SessionId(1), + session_uuid: "session-uuid-1".to_string(), session_name: "Session 1".to_string(), working_dir: PathBuf::from("/tmp"), shell: None, @@ -989,6 +992,25 @@ mod tests { assert_eq!(restore_plan_cli_type(&base), CliType::GenericShell); } + + #[test] + fn restore_resume_commands_empty_for_plan_with_no_cli_fields() { + let plan = RestoreSessionPlan { + original_session_id: SessionId(1), + session_uuid: "uuid".to_string(), + session_name: "Session 1".to_string(), + working_dir: sample_working_dir(), + shell: None, + group: None, + color: None, + claude_resume: None, + codex_resume: None, + codex_execution_mode: None, + codex_started_at: None, + gemini_resume: None, + }; + assert!(restore_resume_commands(&plan).is_empty()); + } } impl WorkspaceView { @@ -1403,15 +1425,27 @@ impl WorkspaceView { bootstrapped.request.launch_shell.as_deref(), ), ); + let restore_cli = self + .effective_user_settings() + .general + .restore_cli_on_startup; + let cli_type = if restore_cli { + restore_plan_cli_type(&plan) + } else { + CliType::GenericShell + }; self.clipboard .clipboard_service - .set_session_cli_type(bootstrapped.session_id, restore_plan_cli_type(&plan)); + .set_session_cli_type(bootstrapped.session_id, cli_type); - if plan.codex_execution_mode.is_some() || plan.codex_started_at.is_some() { + if restore_cli && (plan.codex_execution_mode.is_some() || plan.codex_started_at.is_some()) { let codex_execution_mode = plan.codex_execution_mode; let codex_started_at = plan.codex_started_at; if let Ok(manager) = self.session_manager.lock() { manager.with_session_state_mut(bootstrapped.session_id, |state| { + // session_uuid is set only inside this restore_cli-gated block; + // the local session struct receives the same guard at the assignment below. + state.session.session_uuid = plan.session_uuid.clone(); state.session.codex_execution_mode = codex_execution_mode; state.session.codex_started_at = codex_started_at; }); @@ -1419,11 +1453,22 @@ impl WorkspaceView { } let mut session = bootstrapped.session; + if restore_cli { + session.session_uuid = plan.session_uuid.clone(); + } session.shell = bootstrapped.request.requested_shell.clone(); session.group = plan.group.clone(); session.color = plan.color.clone(); - session.codex_execution_mode = plan.codex_execution_mode; - session.codex_started_at = plan.codex_started_at; + session.codex_execution_mode = if restore_cli { + plan.codex_execution_mode + } else { + None + }; + session.codex_started_at = if restore_cli { + plan.codex_started_at + } else { + None + }; if !self.attach_bootstrapped_session( session, @@ -1445,15 +1490,18 @@ impl WorkspaceView { }); } - for command in restore_resume_commands(&plan) { - if let Ok(manager) = self.session_manager.lock() { - if let Err(error) = manager.send_input(bootstrapped.session_id, command.as_bytes()) - { - warn!( - ?bootstrapped.session_id, - %error, - "Failed to send resume command" - ); + if restore_cli { + for command in restore_resume_commands(&plan) { + if let Ok(manager) = self.session_manager.lock() { + if let Err(error) = + manager.send_input(bootstrapped.session_id, command.as_bytes()) + { + warn!( + ?bootstrapped.session_id, + %error, + "Failed to send resume command" + ); + } } } } @@ -1600,6 +1648,7 @@ impl WorkspaceView { sessions.push(RestoreSessionPlan { original_session_id: saved.id, + session_uuid: saved.session_uuid.clone(), session_name, working_dir, shell: saved.shell, @@ -1721,6 +1770,10 @@ impl WorkspaceView { } this.polling.restore_in_flight = false; info!("Session restoration complete"); + // Persist immediately so any session_uuids generated for + // legacy state (via serde default) are stable on the next + // restart and do not change between restarts. + this.save_state_to_disk(cx); } this.refresh_derived_ui_state(); diff --git a/crates/codirigent-ui/src/workspace/impl_settings.rs b/crates/codirigent-ui/src/workspace/impl_settings.rs index be07ce21..50d43bb7 100644 --- a/crates/codirigent-ui/src/workspace/impl_settings.rs +++ b/crates/codirigent-ui/src/workspace/impl_settings.rs @@ -4,12 +4,22 @@ use super::gpui::WorkspaceView; use super::types::{ShellPickerOption, ShellPickerSection, SHELL_PICKER_AUTO_DETECT_LABEL}; use crate::app::OpenSettings; use crate::settings::SettingsPage; +use crate::theme::CodirigentTheme; +use crate::theme_manager::ThemeManager; use codirigent_core::config_service::ConfigService; use gpui::{Context, Window}; use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use std::time::Duration; use tracing::warn; +struct LoadedSettingsSnapshot { + user_settings: codirigent_core::config::UserSettings, + project_config: codirigent_core::config::ProjectConfig, + project_dir: Option, + theme_manager: ThemeManager, +} + fn shell_picker_display_label(shell: &str) -> String { if shell.is_empty() { SHELL_PICKER_AUTO_DETECT_LABEL.to_string() @@ -102,6 +112,137 @@ fn shell_picker_option_order(shell_options: &[String]) -> Vec { .collect() } +/// Convert our `KeyBinding` struct to the GPUI keystroke string format. +/// +/// GPUI format: lowercase, dash-separated, e.g. `"secondary-shift-n"`. +/// Uses `"secondary-"` for the platform modifier (Cmd on macOS, Ctrl on +/// Windows/Linux), which is how GPUI's `KeyBinding::new` accepts it. +fn binding_to_gpui_string(binding: &crate::keybindings::KeyBinding) -> String { + let mut owned: Vec = Vec::new(); + if binding.modifiers.cmd { + owned.push("secondary".to_string()); + } + if binding.modifiers.ctrl { + owned.push("ctrl".to_string()); + } + if binding.modifiers.alt { + owned.push("alt".to_string()); + } + if binding.modifiers.shift { + owned.push("shift".to_string()); + } + owned.push(binding.key.to_lowercase()); + owned.join("-") +} + +/// Build a GPUI `KeyBinding` list from the user settings keybindings map. +/// +/// Each entry maps an action name (e.g. `"new_session"`) to a display +/// string (e.g. `"Ctrl+N"`). We parse the display string, convert it to +/// GPUI keystroke format, and produce a `gpui::KeyBinding`. +/// +/// Entries with unknown action names or unparseable binding strings are +/// silently skipped. +fn keybindings_to_gpui_list( + keybindings: &std::collections::HashMap, +) -> Vec { + use crate::app::{ + ClosePane, CloseSession, Copy, FocusSession1, FocusSession2, FocusSession3, FocusSession4, + FocusSession5, FocusSession6, FocusSession7, FocusSession8, FocusSession9, NewSession, + NextLayout, OpenSettings, Paste, QuickSwitch, Quit, SplitHorizontal, SplitVertical, + ToggleSidebar, ToggleTaskBoard, + }; + use crate::keybindings::KeybindingManager; + + keybindings + .iter() + .filter_map(|(action_name, binding_str)| { + let km_binding = KeybindingManager::parse_binding(binding_str).ok()?; + if km_binding.key.is_empty() { + return None; + } + let gpui_str = binding_to_gpui_string(&km_binding); + // Build the gpui::KeyBinding for each known action name. + // switch_session_N and focus_session_N share the same numeric index. + let kb: gpui::KeyBinding = match action_name.as_str() { + "new_session" => gpui::KeyBinding::new(&gpui_str, NewSession, None), + "close_session" => gpui::KeyBinding::new(&gpui_str, CloseSession, None), + "toggle_layout" => gpui::KeyBinding::new(&gpui_str, NextLayout, None), + "toggle_sidebar" => gpui::KeyBinding::new(&gpui_str, ToggleSidebar, None), + "toggle_task_board" => gpui::KeyBinding::new(&gpui_str, ToggleTaskBoard, None), + "open_settings" => gpui::KeyBinding::new(&gpui_str, OpenSettings, None), + "quit" => gpui::KeyBinding::new(&gpui_str, Quit, None), + "paste" => gpui::KeyBinding::new(&gpui_str, Paste, None), + "copy" => gpui::KeyBinding::new(&gpui_str, Copy, None), + "split_horizontal" => gpui::KeyBinding::new(&gpui_str, SplitHorizontal, None), + "split_vertical" => gpui::KeyBinding::new(&gpui_str, SplitVertical, None), + "close_pane" => gpui::KeyBinding::new(&gpui_str, ClosePane, None), + "quick_switch" => gpui::KeyBinding::new(&gpui_str, QuickSwitch, None), + "focus_session_1" | "switch_session_1" => { + gpui::KeyBinding::new(&gpui_str, FocusSession1, None) + } + "focus_session_2" | "switch_session_2" => { + gpui::KeyBinding::new(&gpui_str, FocusSession2, None) + } + "focus_session_3" | "switch_session_3" => { + gpui::KeyBinding::new(&gpui_str, FocusSession3, None) + } + "focus_session_4" | "switch_session_4" => { + gpui::KeyBinding::new(&gpui_str, FocusSession4, None) + } + "focus_session_5" | "switch_session_5" => { + gpui::KeyBinding::new(&gpui_str, FocusSession5, None) + } + "focus_session_6" | "switch_session_6" => { + gpui::KeyBinding::new(&gpui_str, FocusSession6, None) + } + "focus_session_7" | "switch_session_7" => { + gpui::KeyBinding::new(&gpui_str, FocusSession7, None) + } + "focus_session_8" | "switch_session_8" => { + gpui::KeyBinding::new(&gpui_str, FocusSession8, None) + } + "focus_session_9" | "switch_session_9" => { + gpui::KeyBinding::new(&gpui_str, FocusSession9, None) + } + _ => return None, + }; + Some(kb) + }) + .collect() +} + +/// Normalize a keybinding string to the platform-correct display form. +/// +/// Parses the string and re-formats it through `format_binding`, which +/// outputs "Ctrl" on Windows/Linux and "Cmd" on macOS for the platform +/// modifier. Returns the original string unchanged on parse failure. +fn normalize_keybinding_display(binding: &str) -> String { + use crate::keybindings::KeybindingManager; + KeybindingManager::parse_binding(binding) + .map(|b| KeybindingManager::format_binding(&b)) + .unwrap_or_else(|_| binding.to_string()) +} + +fn load_settings_snapshot( + config_service: &codirigent_core::config_service::DefaultConfigService, + project_dir: Option, +) -> LoadedSettingsSnapshot { + let user_settings = config_service.load_user_settings().unwrap_or_default(); + let project_config = project_dir + .as_ref() + .and_then(|dir| config_service.load_project_config(dir).ok()) + .unwrap_or_default(); + let theme_manager = ThemeManager::with_user_themes(config_service.user_config_dir()); + + LoadedSettingsSnapshot { + user_settings, + project_config, + project_dir, + theme_manager, + } +} + impl WorkspaceView { pub(super) fn shell_picker_sections( &self, @@ -134,6 +275,59 @@ impl WorkspaceView { .or_else(|| std::env::current_dir().ok()) } + fn apply_theme_runtime_overrides( + theme: &mut CodirigentTheme, + user_settings: &codirigent_core::config::UserSettings, + ) { + theme.grid_gap = user_settings.appearance.grid_gap as f32; + theme.font_size_base = user_settings.appearance.font_size; + theme.font_size_small = (user_settings.appearance.font_size - Self::UI_FONT_VARIANT_DELTA) + .max(Self::MIN_UI_SMALL_FONT_SIZE); + theme.font_size_large = user_settings.appearance.font_size + Self::UI_FONT_VARIANT_DELTA; + theme.terminal_font_size = user_settings.terminal.font_size; + theme.terminal_line_height = user_settings.terminal.line_height; + if !user_settings.terminal.font_family.is_empty() { + theme.terminal_font_family = user_settings.terminal.font_family.clone(); + } + } + + fn apply_runtime_theme(&mut self, theme: CodirigentTheme) { + self.workspace.set_theme(theme.clone()); + self.clipboard.clipboard_preview.set_theme(theme.clone()); + for terminal_view in self.terminals_mut().values_mut() { + terminal_view.set_theme(theme.clone()); + } + } + + pub(super) fn resolve_and_apply_theme_id( + &mut self, + requested_id: &str, + user_settings: &codirigent_core::config::UserSettings, + ) -> String { + let resolution = self + .settings + .theme_manager + .resolve_runtime_theme(requested_id); + if resolution.used_fallback { + warn!( + requested_theme_id = %resolution.requested_id, + resolved_theme_id = %resolution.resolved_id, + "Failed to resolve requested theme ID, using fallback theme" + ); + } + + let mut theme = resolution.theme; + Self::apply_theme_runtime_overrides(&mut theme, user_settings); + + self.settings.active_theme_id = resolution.resolved_id.clone(); + let _ = self + .settings + .theme_manager + .set_active(&self.settings.active_theme_id); + self.apply_runtime_theme(theme); + self.settings.active_theme_id.clone() + } + fn build_settings_page(&self) -> SettingsPage { let mut user_settings = self.settings.cached_user_settings.clone(); let project_config = self.settings.cached_project_config.clone(); @@ -148,13 +342,13 @@ impl WorkspaceView { .entry(k.clone()) .or_insert_with(|| v.clone()); } + // Normalize all displayed binding values to platform-correct labels. + // This handles configs migrated from another platform (e.g. "Cmd+N" on Windows). + for v in user_settings.keybindings.values_mut() { + *v = normalize_keybinding_display(v); + } - let bg: gpui::Hsla = self.workspace.theme().background.into(); - user_settings.appearance.theme = if bg.l > 0.5 { - "light".to_string() - } else { - "dark".to_string() - }; + user_settings.appearance.theme = self.settings.active_theme_id.clone(); let theme = self.workspace.theme(); user_settings.appearance.font_size = theme.font_size_base; @@ -228,7 +422,7 @@ impl WorkspaceView { match result { Ok(()) => { this.settings.cached_user_settings = user_settings.clone(); - this.notification_manager + this.notification_handle .update_settings(user_settings.notifications.clone()); } Err(e) => warn!("Failed to save user settings: {}", e), @@ -293,7 +487,7 @@ impl WorkspaceView { }) .await; - let _ = this.update(cx, |this, _cx| { + let _ = this.update(cx, |this, cx| { this.settings.save_task = None; let ( user_settings, @@ -310,8 +504,17 @@ impl WorkspaceView { warn!("Failed to save user settings: {}", err); } else { this.settings.cached_user_settings = user_settings.clone(); - this.notification_manager + this.notification_handle .update_settings(user_settings.notifications.clone()); + // Re-register keybindings with GPUI so user changes take + // effect immediately without requiring a restart. + // Clear the existing keymap first (GPUI bind_keys appends — without + // clearing, the list grows by 44 entries on every save and dispatch + // becomes O(saves)). + cx.clear_key_bindings(); + let mut merged = crate::app::default_gpui_keybindings(); + merged.extend(keybindings_to_gpui_list(&user_settings.keybindings)); + cx.bind_keys(merged); if let Some(page) = this.settings.page.as_mut() { if page.user_settings == user_settings { page.mark_user_saved(); @@ -337,7 +540,7 @@ impl WorkspaceView { } } } - this.maybe_schedule_settings_save(_cx); + this.maybe_schedule_settings_save(cx); }); })); } @@ -389,27 +592,25 @@ impl WorkspaceView { self.settings.load_task = Some(cx.spawn(async move |this: gpui::WeakEntity, cx| { let loaded = cx .background_executor() - .spawn(async move { - let user_settings = config_service.load_user_settings().unwrap_or_default(); - let project_config = project_dir - .as_ref() - .and_then(|dir| config_service.load_project_config(dir).ok()) - .unwrap_or_default(); - (user_settings, project_config, project_dir) - }) + .spawn(async move { load_settings_snapshot(&config_service, project_dir) }) .await; let _ = this.update(cx, |this, cx| { let restore_after_load = std::mem::take(&mut this.settings.restore_after_load); this.settings.load_task = None; this.settings.loaded_once = true; - this.settings.cached_user_settings = loaded.0.clone(); - this.settings.cached_project_config = loaded.1.clone(); - this.settings.current_working_dir = loaded.2; - this.notification_manager - .update_settings(loaded.0.notifications.clone()); + this.settings.theme_manager = loaded.theme_manager; + let mut user_settings = loaded.user_settings.clone(); + let resolved_theme_id = this + .resolve_and_apply_theme_id(&user_settings.appearance.theme, &user_settings); + user_settings.appearance.theme = resolved_theme_id; + this.settings.cached_user_settings = user_settings.clone(); + this.settings.cached_project_config = loaded.project_config.clone(); + this.settings.current_working_dir = loaded.project_dir; + this.notification_handle + .update_settings(user_settings.notifications.clone()); this.top_bar - .load_saved_profiles(loaded.0.saved_layouts.clone()); + .load_saved_profiles(user_settings.saved_layouts.clone()); if let Some(existing_page) = this.settings.page.as_ref() { if !existing_page.user_save_pending && !existing_page.project_save_pending { @@ -417,12 +618,23 @@ impl WorkspaceView { let open_dropdown = existing_page.open_dropdown.clone(); let dropdown_click_pos = existing_page.dropdown_click_pos; let recording_shortcut = existing_page.recording_shortcut.clone(); + let focused_shortcut_row = existing_page.focused_shortcut_row.clone(); let mut page = this.build_settings_page(); page.set_category(active_category); page.open_dropdown = open_dropdown; page.dropdown_click_pos = dropdown_click_pos; page.recording_shortcut = recording_shortcut; + page.focused_shortcut_row = focused_shortcut_row; + // Validate the preserved recording state is still pointing to a known + // action. If the action was removed (e.g. by a downgrade), drop the + // stale state. + if let Some(ref name) = page.recording_shortcut { + if !page.user_settings.keybindings.contains_key(name) { + page.recording_shortcut = None; + page.focused_shortcut_row = None; + } + } this.settings.page = Some(page); } } else if this.settings.open { @@ -487,6 +699,130 @@ impl WorkspaceView { #[cfg(test)] mod tests { use super::*; + use crate::theme::CodirigentTheme; + use crate::theme_config::Theme; + use crate::theme_manager::CUSTOM_THEME_DIRECTORY_NAME; + use codirigent_core::config_service::DefaultConfigService; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_binding_to_gpui_string_ctrl_n() { + use crate::keybindings::{KeyBinding, Modifiers}; + let binding = KeyBinding::new( + "N", + Modifiers { + cmd: true, + ..Default::default() + }, + ); + let result = binding_to_gpui_string(&binding); + assert_eq!(result, "secondary-n"); + } + + #[test] + fn test_binding_to_gpui_string_shift() { + use crate::keybindings::{KeyBinding, Modifiers}; + let binding = KeyBinding::new( + "D", + Modifiers { + cmd: true, + shift: true, + ..Default::default() + }, + ); + let result = binding_to_gpui_string(&binding); + assert_eq!(result, "secondary-shift-d"); + } + + #[test] + fn test_keybindings_to_gpui_list_new_session() { + let mut map = std::collections::HashMap::new(); + map.insert("new_session".to_string(), "Ctrl+N".to_string()); + let list = keybindings_to_gpui_list(&map); + assert_eq!(list.len(), 1); + } + + #[test] + fn test_keybindings_to_gpui_list_skips_unknown_action() { + let mut map = std::collections::HashMap::new(); + map.insert("unknown_action_xyz".to_string(), "Ctrl+N".to_string()); + let list = keybindings_to_gpui_list(&map); + assert_eq!(list.len(), 0); + } + + #[test] + fn test_keybindings_to_gpui_list_includes_toggle_task_board() { + let mut map = std::collections::HashMap::new(); + map.insert("toggle_task_board".to_string(), "Ctrl+B".to_string()); + let list = keybindings_to_gpui_list(&map); + assert_eq!(list.len(), 1); + } + + #[test] + fn test_keybindings_to_gpui_list_includes_quick_switch() { + // quick_switch is a live-reload binding that must appear in the list. + let mut map = std::collections::HashMap::new(); + map.insert("quick_switch".to_string(), "Ctrl+K".to_string()); + let list = keybindings_to_gpui_list(&map); + assert_eq!(list.len(), 1); + } + + #[test] + fn test_keybindings_to_gpui_list_skips_empty_key() { + // A binding that parses but has an empty key should be silently dropped + // rather than causing KeyBinding::new to panic on an invalid keystroke. + let mut map = std::collections::HashMap::new(); + // "Ctrl+" parses as a modifier-only binding with empty key — verify safe skip. + // Since parse_binding may reject this, also test via a direct call. + // If parse_binding rejects it, the entry is already dropped before our guard; + // the test verifies the overall list is still empty / doesn't panic. + map.insert("new_session".to_string(), "Ctrl+".to_string()); + let list = keybindings_to_gpui_list(&map); + // Either parse_binding rejects "Ctrl+" (returning 0) or our guard catches + // the empty key (also returning 0). Either way no panic. + assert_eq!(list.len(), 0); + } + + #[test] + fn test_keybindings_to_gpui_list_includes_new_actions() { + let mut map = std::collections::HashMap::new(); + map.insert("open_settings".to_string(), "Ctrl+,".to_string()); + map.insert("quit".to_string(), "Ctrl+Q".to_string()); + map.insert("paste".to_string(), "Ctrl+V".to_string()); + map.insert("copy".to_string(), "Ctrl+C".to_string()); + map.insert("split_horizontal".to_string(), "Ctrl+D".to_string()); + map.insert("split_vertical".to_string(), "Ctrl+Shift+D".to_string()); + map.insert("close_pane".to_string(), "Ctrl+Shift+W".to_string()); + let list = keybindings_to_gpui_list(&map); + assert_eq!(list.len(), 7); + } + + #[test] + fn test_normalize_keybinding_display_cmd_to_ctrl_on_non_macos() { + // "Cmd+N" → platform modifier display: "Cmd+N" on macOS, "Ctrl+N" elsewhere. + #[cfg(not(target_os = "macos"))] + assert_eq!(normalize_keybinding_display("Cmd+N"), "Ctrl+N"); + #[cfg(target_os = "macos")] + assert_eq!(normalize_keybinding_display("Cmd+N"), "Cmd+N"); + } + + #[test] + fn test_normalize_keybinding_display_preserves_valid_platform_string() { + #[cfg(not(target_os = "macos"))] + assert_eq!(normalize_keybinding_display("Ctrl+N"), "Ctrl+N"); + #[cfg(target_os = "macos")] + assert_eq!(normalize_keybinding_display("Cmd+N"), "Cmd+N"); + } + + #[test] + fn test_normalize_keybinding_display_falls_back_on_invalid() { + // Unparseable strings should be returned unchanged. + assert_eq!( + normalize_keybinding_display("not-a-binding"), + "not-a-binding" + ); + } #[test] fn shell_picker_sections_group_common_shells_before_more() { @@ -573,4 +909,85 @@ mod tests { assert_eq!(order, vec![0, 2, 3, 1]); } + + #[test] + fn test_default_binding_tables_are_in_sync() { + use crate::app::default_gpui_keybindings; + use codirigent_core::config::UserSettings; + + let gpui_count = default_gpui_keybindings().len(); + let settings_bindings = UserSettings::default_keybindings(); + let live_reload_count = keybindings_to_gpui_list(&settings_bindings).len(); + + // Every action registered at startup must also be rebindable via settings. + // UserSettings::default_keybindings uses "switch_session_N" action names while + // default_gpui_keybindings uses "focus_session_N" action names — keybindings_to_gpui_list + // recognises both aliases, so counts should be equal. + // If this fails, a new action was added to one table but not the other. + assert_eq!( + live_reload_count, gpui_count, + "default_gpui_keybindings ({gpui_count} entries) and \ + UserSettings::default_keybindings live-reload list ({live_reload_count} entries) \ + are out of sync — add the missing action to both tables" + ); + } + + #[test] + fn apply_theme_runtime_overrides_preserves_user_preferences() { + let mut theme = CodirigentTheme::dark(); + let mut user_settings = codirigent_core::config::UserSettings::default(); + user_settings.appearance.font_size = 17.0; + user_settings.appearance.grid_gap = 7; + user_settings.terminal.font_size = 15.0; + user_settings.terminal.line_height = 1.3; + user_settings.terminal.font_family = "FiraCode Nerd Font".to_string(); + + WorkspaceView::apply_theme_runtime_overrides(&mut theme, &user_settings); + + assert_eq!(theme.font_size_base, 17.0); + assert_eq!(theme.font_size_small, 15.0); + assert_eq!(theme.font_size_large, 19.0); + assert_eq!(theme.grid_gap, 7.0); + assert_eq!(theme.terminal_font_size, 15.0); + assert_eq!(theme.terminal_line_height, 1.3); + assert_eq!(theme.terminal_font_family, "FiraCode Nerd Font"); + } + + #[test] + fn apply_theme_runtime_overrides_keeps_theme_terminal_font_when_unset() { + let mut theme = CodirigentTheme::dark(); + let original_font_family = theme.terminal_font_family.clone(); + let mut user_settings = codirigent_core::config::UserSettings::default(); + user_settings.terminal.font_family.clear(); + + WorkspaceView::apply_theme_runtime_overrides(&mut theme, &user_settings); + + assert_eq!(theme.terminal_font_family, original_font_family); + } + + #[test] + fn load_settings_snapshot_loads_custom_themes_from_user_config_dir() { + let dir = tempdir().unwrap(); + let config_service = DefaultConfigService::with_config_dir(dir.path().to_path_buf()); + let themes_dir = dir.path().join(CUSTOM_THEME_DIRECTORY_NAME); + fs::create_dir_all(&themes_dir).unwrap(); + + let mut settings = codirigent_core::config::UserSettings::default(); + settings.appearance.theme = "aurora".to_string(); + config_service.save_user_settings(&settings).unwrap(); + + let custom_theme = + Theme::from_runtime("aurora", "Aurora", false, &CodirigentTheme::light()); + fs::write( + themes_dir.join("aurora.json"), + custom_theme.to_json().unwrap(), + ) + .unwrap(); + + let snapshot = load_settings_snapshot(&config_service, None); + + assert_eq!(snapshot.user_settings.appearance.theme, "aurora"); + assert!(snapshot.theme_manager.get("aurora").is_some()); + assert_eq!(snapshot.theme_manager.len(), 3); + } } diff --git a/crates/codirigent-ui/src/workspace/impl_shortcuts_nav.rs b/crates/codirigent-ui/src/workspace/impl_shortcuts_nav.rs new file mode 100644 index 00000000..d10c38c8 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/impl_shortcuts_nav.rs @@ -0,0 +1,105 @@ +//! Keyboard navigation for the Keyboard Shortcuts settings panel. + +use crate::settings::SettingsPage; + +/// Compute the next focused row after a navigation key press. +/// +/// `sorted_keys` must match the order used to render the table (alphabetical). +/// `down` = true for ArrowDown / Tab; false for ArrowUp / Shift+Tab. +pub(super) fn navigate_shortcuts_focus( + page: &SettingsPage, + sorted_keys: &[String], + down: bool, +) -> Option { + if sorted_keys.is_empty() { + return None; + } + let current = page.focused_shortcut_row.as_deref(); + let pos = current.and_then(|c| sorted_keys.iter().position(|k| k == c)); + let next = match pos { + None => 0, + Some(i) if down => (i + 1).min(sorted_keys.len() - 1), + Some(0) => 0, + Some(i) => i - 1, + }; + Some(sorted_keys[next].clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::SettingsPage; + use codirigent_core::config::ProjectConfig; + use codirigent_core::config::UserSettings; + + fn make_page() -> SettingsPage { + SettingsPage::new( + UserSettings::default(), + ProjectConfig::default(), + vec![], + vec![], + vec![], + ) + } + + fn sorted_keys() -> Vec { + let mut v: Vec = UserSettings::default_keybindings() + .keys() + .cloned() + .collect(); + v.sort(); + v + } + + #[test] + fn test_navigate_down_from_none_selects_first() { + let page = make_page(); + let keys = sorted_keys(); + let result = navigate_shortcuts_focus(&page, &keys, true); + assert_eq!(result, Some(keys[0].clone())); + } + + #[test] + fn test_navigate_down_from_first_selects_second() { + let mut page = make_page(); + let keys = sorted_keys(); + page.focused_shortcut_row = Some(keys[0].clone()); + let result = navigate_shortcuts_focus(&page, &keys, true); + assert_eq!(result, Some(keys[1].clone())); + } + + #[test] + fn test_navigate_up_from_second_selects_first() { + let mut page = make_page(); + let keys = sorted_keys(); + page.focused_shortcut_row = Some(keys[1].clone()); + let result = navigate_shortcuts_focus(&page, &keys, false); + assert_eq!(result, Some(keys[0].clone())); + } + + #[test] + fn test_navigate_down_at_end_stays_at_last() { + let mut page = make_page(); + let keys = sorted_keys(); + let last = keys.last().unwrap().clone(); + page.focused_shortcut_row = Some(last.clone()); + let result = navigate_shortcuts_focus(&page, &keys, true); + assert_eq!(result, Some(last)); + } + + #[test] + fn test_navigate_up_at_start_stays_at_first() { + let mut page = make_page(); + let keys = sorted_keys(); + page.focused_shortcut_row = Some(keys[0].clone()); + let result = navigate_shortcuts_focus(&page, &keys, false); + assert_eq!(result, Some(keys[0].clone())); + } + + #[test] + fn test_navigate_empty_list_returns_none() { + let page = make_page(); + let result = navigate_shortcuts_focus(&page, &[], true); + assert_eq!(result, None); + } +} diff --git a/crates/codirigent-ui/src/workspace/impl_shortcuts_recording.rs b/crates/codirigent-ui/src/workspace/impl_shortcuts_recording.rs new file mode 100644 index 00000000..9c083a4c --- /dev/null +++ b/crates/codirigent-ui/src/workspace/impl_shortcuts_recording.rs @@ -0,0 +1,184 @@ +//! Keyboard shortcut recording logic for the settings panel. + +use crate::keybindings::{KeyBinding, KeybindingManager, Modifiers}; +use gpui::Keystroke; + +/// Convert a GPUI keystroke event into a displayable binding string. +/// +/// Returns `None` for bare keys (no primary modifier) because those cannot +/// be safely bound as app shortcuts without conflicting with terminal text input. +/// Shift-only is also rejected. +/// +/// On macOS: `platform` (Cmd) → "Cmd", `control` → "Ctrl". +/// On Windows/Linux: `platform` (Win/Super) or `control` (Ctrl) both → "Ctrl". +pub(super) fn format_keystroke_as_binding(keystroke: &Keystroke) -> Option { + let mods = &keystroke.modifiers; + + // Require at least one non-Shift primary modifier. + let has_primary = mods.platform || mods.control || mods.alt; + if !has_primary { + return None; + } + + let modifiers = build_modifiers(mods); + let key = normalise_key_name(&keystroke.key); + let binding = KeyBinding::new(key, modifiers); + Some(KeybindingManager::format_binding(&binding)) +} + +#[cfg(target_os = "macos")] +fn build_modifiers(mods: &gpui::Modifiers) -> Modifiers { + Modifiers { + cmd: mods.platform, + ctrl: mods.control, + alt: mods.alt, + shift: mods.shift, + } +} + +#[cfg(not(target_os = "macos"))] +fn build_modifiers(mods: &gpui::Modifiers) -> Modifiers { + // On Windows/Linux, both the Super/Win key (platform) and Ctrl key (control) + // are treated as the platform modifier and displayed as "Ctrl". + Modifiers { + cmd: mods.platform || mods.control, + ctrl: false, // folded into cmd above + alt: mods.alt, + shift: mods.shift, + } +} + +/// Normalise GPUI key names to Title case for round-tripping through parse_binding. +pub(super) fn normalise_key_name(key: &str) -> String { + match key { + "backspace" => "Backspace".to_string(), + "enter" | "return" => "Enter".to_string(), + "tab" => "Tab".to_string(), + "escape" => "Escape".to_string(), + "space" => "Space".to_string(), + "up" => "Up".to_string(), + "down" => "Down".to_string(), + "left" => "Left".to_string(), + "right" => "Right".to_string(), + "delete" => "Delete".to_string(), + "pageup" => "PageUp".to_string(), + "pagedown" => "PageDown".to_string(), + "home" => "Home".to_string(), + "end" => "End".to_string(), + "insert" => "Insert".to_string(), + "f1" => "F1".to_string(), + "f2" => "F2".to_string(), + "f3" => "F3".to_string(), + "f4" => "F4".to_string(), + "f5" => "F5".to_string(), + "f6" => "F6".to_string(), + "f7" => "F7".to_string(), + "f8" => "F8".to_string(), + "f9" => "F9".to_string(), + "f10" => "F10".to_string(), + "f11" => "F11".to_string(), + "f12" => "F12".to_string(), + _ => { + let mut chars = key.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Keystroke, Modifiers}; + + fn make_keystroke( + key: &str, + control: bool, + alt: bool, + shift: bool, + platform: bool, + ) -> Keystroke { + Keystroke { + modifiers: Modifiers { + control, + alt, + shift, + platform, + ..Default::default() + }, + key: key.to_string(), + key_char: None, + } + } + + #[test] + fn test_format_keystroke_ctrl_key() { + // control=true: on macOS maps to Ctrl modifier; on Windows/Linux folds + // into the cmd/platform slot which still displays as "Ctrl". + // Both platforms produce "Ctrl+N" for this keystroke. + let ks = make_keystroke("n", true, false, false, false); + let result = format_keystroke_as_binding(&ks); + assert_eq!(result, Some("Ctrl+N".to_string())); + } + + #[test] + fn test_format_keystroke_platform_n() { + let ks = make_keystroke("n", false, false, false, true); + let result = format_keystroke_as_binding(&ks); + #[cfg(target_os = "macos")] + assert_eq!(result, Some("Cmd+N".to_string())); + #[cfg(not(target_os = "macos"))] + assert_eq!(result, Some("Ctrl+N".to_string())); // platform maps to Ctrl display on Windows + } + + #[test] + fn test_format_keystroke_bare_key_returns_none() { + let ks = make_keystroke("a", false, false, false, false); + let result = format_keystroke_as_binding(&ks); + assert_eq!(result, None); + } + + #[test] + fn test_format_keystroke_shift_only_returns_none() { + let ks = make_keystroke("a", false, false, true, false); + let result = format_keystroke_as_binding(&ks); + assert_eq!(result, None); + } + + #[test] + fn test_format_keystroke_ctrl_shift_n() { + let ks = make_keystroke("n", true, false, true, false); + let result = format_keystroke_as_binding(&ks); + #[cfg(not(target_os = "macos"))] + assert_eq!(result, Some("Ctrl+Shift+N".to_string())); + #[cfg(target_os = "macos")] + assert_eq!(result, Some("Ctrl+Shift+N".to_string())); + } + + #[test] + fn test_normalise_key_name_special_keys() { + assert_eq!(normalise_key_name("backspace"), "Backspace"); + assert_eq!(normalise_key_name("enter"), "Enter"); + assert_eq!(normalise_key_name("tab"), "Tab"); + assert_eq!(normalise_key_name("escape"), "Escape"); + } + + #[test] + fn test_normalise_key_name_plain_letter() { + assert_eq!(normalise_key_name("n"), "N"); + assert_eq!(normalise_key_name("a"), "A"); + } + + #[test] + fn test_normalise_key_name_extended_keys() { + assert_eq!(normalise_key_name("pageup"), "PageUp"); + assert_eq!(normalise_key_name("pagedown"), "PageDown"); + assert_eq!(normalise_key_name("home"), "Home"); + assert_eq!(normalise_key_name("end"), "End"); + assert_eq!(normalise_key_name("insert"), "Insert"); + assert_eq!(normalise_key_name("f1"), "F1"); + assert_eq!(normalise_key_name("f12"), "F12"); + } +} diff --git a/crates/codirigent-ui/src/workspace/mod.rs b/crates/codirigent-ui/src/workspace/mod.rs index e136dc4a..727ced37 100644 --- a/crates/codirigent-ui/src/workspace/mod.rs +++ b/crates/codirigent-ui/src/workspace/mod.rs @@ -74,6 +74,12 @@ mod impl_action_handlers; #[cfg(feature = "gpui-full")] mod impl_settings; +#[cfg(feature = "gpui-full")] +mod impl_shortcuts_recording; + +#[cfg(feature = "gpui-full")] +mod impl_shortcuts_nav; + #[cfg(feature = "gpui-full")] mod impl_ui_operations; @@ -104,6 +110,9 @@ mod top_bar_render; #[cfg(feature = "gpui-full")] mod modal_render; +#[cfg(feature = "gpui-full")] +mod toast_render; + #[cfg(feature = "gpui-full")] mod grid_render; @@ -113,9 +122,15 @@ mod split_render; #[cfg(feature = "gpui-full")] mod pane_header_render; +#[cfg(feature = "gpui-full")] +mod tab_status_render; + #[cfg(feature = "gpui-full")] mod settings_panels; +#[cfg(feature = "gpui-full")] +mod settings_theme_picker; + #[cfg(feature = "gpui-full")] mod clipboard_state; diff --git a/crates/codirigent-ui/src/workspace/pane_header_render.rs b/crates/codirigent-ui/src/workspace/pane_header_render.rs index 7ab8172d..2b9de338 100644 --- a/crates/codirigent-ui/src/workspace/pane_header_render.rs +++ b/crates/codirigent-ui/src/workspace/pane_header_render.rs @@ -7,7 +7,7 @@ use crate::icons; use crate::terminal_header::TerminalHeaderRenderHints; use crate::theme::CodirigentTheme; use crate::workspace::gpui::WorkspaceView; -use codirigent_core::{PaneId, SessionId}; +use codirigent_core::{PaneId, SessionId, SessionStatus}; use gpui::{ div, px, ClickEvent, Context, FontWeight, InteractiveElement, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, SharedString, StatefulInteractiveElement, Styled, @@ -32,7 +32,6 @@ impl WorkspaceView { cx: &mut Context, ) -> gpui::Stateful { let color_indicator: gpui::Hsla = hints.color_indicator.into(); - let status_color: gpui::Hsla = hints.status.color.into(); let show_plus_button = self .workspace() .pane_active_session_id(pane_id.clone()) @@ -59,7 +58,6 @@ impl WorkspaceView { .rounded_sm() .bg(color_indicator), ) - .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(status_color)) .child(self.render_pane_tab_strip( pane_id.clone(), session_id, @@ -169,6 +167,12 @@ impl WorkspaceView { let pane_tab_ids = self.workspace().pane_tab_session_ids(pane_id.clone()); let mut tab_strip = div().flex().items_center().gap_1().overflow_hidden(); + let tab_status_style = self + .effective_user_settings() + .appearance + .tab_status_style + .clone(); + for tab_session_id in pane_tab_ids { let tab_is_active = tab_session_id == session_id; let tab_name = self @@ -176,7 +180,27 @@ impl WorkspaceView { .session(tab_session_id) .map(|session| session.name.clone()) .unwrap_or_else(|| hints.name.clone()); - let tab_bg = if tab_is_active { + let tab_status = self + .workspace() + .session(tab_session_id) + .map(|s| s.status) + .unwrap_or(SessionStatus::Idle); + let decoration = super::tab_status_render::render_tab_status( + &tab_status_style, + tab_status, + tab_is_active, + ); + + let tab_bg = if let Some(glow_bg) = decoration.tab_bg { + if tab_is_active { + let mut base: gpui::Hsla = theme.active.into(); + base.h = glow_bg.h; + base.s = glow_bg.s.max(base.s); + base + } else { + glow_bg + } + } else if tab_is_active { theme.active.into() } else { border_color.opacity(0.35) @@ -215,20 +239,53 @@ impl WorkspaceView { cx.notify(); } } - })) - .child( - div() - .text_xs() - .font_weight(if tab_is_active { - FontWeight::SEMIBOLD - } else { - FontWeight::MEDIUM - }) - .text_color(tab_fg) - .overflow_hidden() - .text_ellipsis() - .child(tab_name), - ); + })); + + // Apply glow border if present + if let Some(glow_border) = decoration.tab_border { + tab = tab.border_1().border_color(glow_border); + } + + // Apply pulse opacity for animated states. + // 6-step sine-like curve for smooth pulsing (250ms per step = 1.5s cycle). + if decoration.should_pulse { + const PULSE_CURVE: [f32; 6] = [1.0, 0.85, 0.55, 0.4, 0.55, 0.85]; + let phase = (self.pulse_counter % 6) as usize; + tab = tab.opacity(PULSE_CURVE[phase]); + } + + let is_badge = tab_status_style == "badge"; + let is_glow = tab_status_style == "glow"; + let mut status_child = decoration.child; + + // Prepend dot (before name) for "dot" style or unknown styles + if !is_badge && !is_glow { + if let Some(child) = status_child.take() { + tab = tab.child(child); + } + } + + // Add the name label + tab = tab.child( + div() + .text_xs() + .font_weight(if tab_is_active { + FontWeight::SEMIBOLD + } else { + FontWeight::MEDIUM + }) + .text_color(tab_fg) + .overflow_hidden() + .text_ellipsis() + .child(tab_name), + ); + + // Append badge (after name) for "badge" style + if is_badge { + if let Some(child) = status_child.take() { + tab = tab.child(child); + } + } if tab_is_active { let drag_source_pane_id = pane_id.clone(); diff --git a/crates/codirigent-ui/src/workspace/settings_panels.rs b/crates/codirigent-ui/src/workspace/settings_panels.rs index 97aef673..de4e0026 100644 --- a/crates/codirigent-ui/src/workspace/settings_panels.rs +++ b/crates/codirigent-ui/src/workspace/settings_panels.rs @@ -11,6 +11,7 @@ use crate::settings::controls::{setting_row, setting_toggle, settings_section_he use crate::settings::SettingsCategory; use crate::terminal_view::CursorShape; +use super::settings_theme_picker::{build_theme_picker_sections, theme_picker_display_label}; use super::types::DROPDOWN_TRIGGER_HEIGHT; const SETTINGS_DROPDOWN_MAX_HEIGHT: f32 = 280.0; @@ -22,6 +23,35 @@ enum DropdownEntry { Separator, } +fn build_theme_dropdown_entries( + theme_manager: &crate::theme_manager::ThemeManager, +) -> Vec { + let sections = build_theme_picker_sections(theme_manager); + let mut entries = Vec::new(); + + for (section_index, section) in sections.into_iter().enumerate() { + if section_index > 0 { + entries.push(DropdownEntry::Separator); + } + + entries.push(DropdownEntry::Section { + label: section.title.to_string(), + }); + + entries.extend( + section + .options + .into_iter() + .map(|option| DropdownEntry::Option { + value: option.id, + label: option.label, + }), + ); + } + + entries +} + impl super::gpui::WorkspaceView { /// Render the full settings overlay (sidebar + content area). pub(super) fn render_settings_overlay(&mut self, cx: &mut Context) -> impl IntoElement { @@ -109,6 +139,7 @@ impl super::gpui::WorkspaceView { if let Some(ref mut page) = this.settings.page { page.set_category(cat); page.open_dropdown = None; + page.focused_shortcut_row = None; } cx.notify(); })) @@ -402,6 +433,7 @@ impl super::gpui::WorkspaceView { let shell = page.user_settings.general.default_shell.clone(); let working_dir = page.user_settings.general.default_working_dir.clone(); let show_splash = page.user_settings.general.show_splash; + let restore_cli_on_startup = page.user_settings.general.restore_cli_on_startup; let notif = page.user_settings.notifications.clone(); let theme = self.workspace.theme(); @@ -499,6 +531,24 @@ impl super::gpui::WorkspaceView { cx.notify(); }), )) + .child(setting_row( + "Restore AI sessions", + "Resume previous Claude/Codex/Gemini sessions on startup", + theme, + self.render_toggle_control( + "toggle-restore-cli", + restore_cli_on_startup, + cx, + |this, _, cx| { + if let Some(page) = this.settings.page.as_mut() { + page.user_settings.general.restore_cli_on_startup = + !page.user_settings.general.restore_cli_on_startup; + page.user_save_pending = true; + } + cx.notify(); + }, + ), + )) .child(settings_section_header("Notifications", theme, false)) .child(setting_row( "Desktop notifications", @@ -676,10 +726,14 @@ impl super::gpui::WorkspaceView { .page .as_ref() .expect("BUG: settings page should exist when rendering settings"); - let theme_name = page.user_settings.appearance.theme.clone(); + let theme_id = page.user_settings.appearance.theme.clone(); let font_size = page.user_settings.appearance.font_size; let grid_gap = page.user_settings.appearance.grid_gap; + let tab_status_style = page.user_settings.appearance.tab_status_style.clone(); let theme = self.workspace.theme(); + let theme_entries = build_theme_dropdown_entries(&self.settings.theme_manager); + let selected_theme_label = + theme_picker_display_label(&self.settings.theme_manager, &theme_id); div() .flex() @@ -688,46 +742,27 @@ impl super::gpui::WorkspaceView { .child(settings_section_header("Theme", theme, true)) .child(setting_row( "Color theme", - "Switch between dark and light themes", + "Select the active UI and terminal theme", theme, - self.render_dropdown_control( + self.render_dropdown_control_with_entries( "dd-theme", - &["dark", "light"], - &theme_name, + &theme_entries, + &theme_id, + &selected_theme_label, cx, |this, val, _, _| { + let mut user_settings = None; if let Some(page) = this.settings.page.as_mut() { page.user_settings.appearance.theme = val.clone(); page.user_save_pending = true; + user_settings = Some(page.user_settings.clone()); } - let new_theme = if val == "light" { - crate::theme::CodirigentTheme::light() - } else { - crate::theme::CodirigentTheme::dark() - }; - // Preserve user settings across theme switch - let (gap, ui_size, term_size) = this - .settings - .page - .as_ref() - .map(|p| { - ( - p.user_settings.appearance.grid_gap, - p.user_settings.appearance.font_size, - p.user_settings.terminal.font_size, - ) - }) - .unwrap_or((4, 13.0, 13.0)); - this.workspace.set_theme(new_theme); - let t = this.workspace.theme_mut(); - t.grid_gap = gap as f32; - t.font_size_base = ui_size; - t.font_size_small = (ui_size - 2.0).max(8.0); - t.font_size_large = ui_size + 2.0; - t.terminal_font_size = term_size; - let terminal_theme = t.clone(); - for tv in this.terminals_mut().values_mut() { - tv.set_theme(terminal_theme.clone()); + if let Some(user_settings) = user_settings { + let resolved_theme_id = + this.resolve_and_apply_theme_id(&val, &user_settings); + if let Some(page) = this.settings.page.as_mut() { + page.user_settings.appearance.theme = resolved_theme_id; + } } }, ), @@ -803,6 +838,25 @@ impl super::gpui::WorkspaceView { }, ), )) + .child(settings_section_header("Status", theme, false)) + .child(setting_row( + "Tab status style", + "How session status is shown on tabs (dot, badge, or glow)", + theme, + self.render_dropdown_control( + "dd-tab-status-style", + &["dot", "badge", "glow"], + &tab_status_style, + cx, + |this, val, _, cx| { + if let Some(page) = this.settings.page.as_mut() { + page.user_settings.appearance.tab_status_style = val; + page.user_save_pending = true; + } + cx.notify(); + }, + ), + )) .into_any_element() } @@ -964,10 +1018,14 @@ impl super::gpui::WorkspaceView { let panel_bg: Hsla = theme.panel_background.into(); let border: Hsla = theme.border.into(); - let mut sorted: Vec<_> = page.user_settings.keybindings.iter().collect(); - sorted.sort_by_key(|(k, _)| (*k).clone()); + let sorted: Vec<_> = page + .sorted_shortcut_keys + .iter() + .filter_map(|k| page.user_settings.keybindings.get(k).map(|v| (k, v))) + .collect(); let recording = page.recording_shortcut.clone(); + let focused_row = page.focused_shortcut_row.clone(); let mut container = div() .flex() @@ -1002,6 +1060,7 @@ impl super::gpui::WorkspaceView { for (action, binding) in sorted { let action_name = action.clone(); let is_recording = recording.as_deref() == Some(action.as_str()); + let is_focused = focused_row.as_deref() == Some(action.as_str()); let display = if is_recording { "Press a key...".to_string() } else { @@ -1020,6 +1079,14 @@ impl super::gpui::WorkspaceView { .py(px(6.0)) .rounded_md() .hover(|s| s.bg(Hsla { a: 0.05, ..fg })) + .bg(if is_focused { + Hsla { a: 0.08, ..fg } + } else { + Hsla { + a: 0.0, + ..Default::default() + } + }) .cursor_pointer() .on_mouse_down( MouseButton::Left, @@ -1027,8 +1094,10 @@ impl super::gpui::WorkspaceView { if let Some(page) = this.settings.page.as_mut() { if page.recording_shortcut.as_deref() == Some(&action_name) { page.recording_shortcut = None; + page.focused_shortcut_row = None; } else { page.recording_shortcut = Some(action_name.clone()); + page.focused_shortcut_row = Some(action_name.clone()); } } cx.notify(); diff --git a/crates/codirigent-ui/src/workspace/settings_state.rs b/crates/codirigent-ui/src/workspace/settings_state.rs index c678c2c6..767a24c0 100644 --- a/crates/codirigent-ui/src/workspace/settings_state.rs +++ b/crates/codirigent-ui/src/workspace/settings_state.rs @@ -1,6 +1,7 @@ //! Settings state management for WorkspaceView. use crate::settings::SettingsPage; +use crate::theme_manager::ThemeManager; use codirigent_core::config::{ProjectConfig, UserSettings}; use codirigent_core::config_service::DefaultConfigService; use std::path::PathBuf; @@ -21,6 +22,10 @@ pub(super) struct SettingsState { pub(super) cached_user_settings: UserSettings, /// Cached project config for the current working directory. pub(super) cached_project_config: ProjectConfig, + /// Theme registry used to resolve theme IDs into runtime themes. + pub(super) theme_manager: ThemeManager, + /// Theme ID currently applied to the workspace runtime. + pub(super) active_theme_id: String, /// Working directory used for project-scoped settings. pub(super) current_working_dir: Option, /// Whether settings have been loaded from disk at least once. @@ -31,6 +36,8 @@ pub(super) struct SettingsState { impl SettingsState { pub(super) fn new() -> Self { + let theme_manager = ThemeManager::with_defaults(); + let active_theme_id = theme_manager.active_id().to_string(); Self { page: None, open: false, @@ -39,9 +46,25 @@ impl SettingsState { save_task: None, cached_user_settings: UserSettings::default(), cached_project_config: ProjectConfig::default(), + theme_manager, + active_theme_id, current_working_dir: std::env::current_dir().ok(), loaded_once: false, restore_after_load: false, } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme_manager::DEFAULT_THEME_ID; + + #[test] + fn settings_state_starts_with_registry_default_theme_id() { + let state = SettingsState::new(); + + assert_eq!(state.active_theme_id, DEFAULT_THEME_ID); + assert_eq!(state.theme_manager.active_id(), DEFAULT_THEME_ID); + } +} diff --git a/crates/codirigent-ui/src/workspace/settings_theme_picker.rs b/crates/codirigent-ui/src/workspace/settings_theme_picker.rs new file mode 100644 index 00000000..79f39d33 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/settings_theme_picker.rs @@ -0,0 +1,137 @@ +use crate::theme_manager::ThemeManager; + +const DARK_THEME_SECTION_TITLE: &str = "Dark"; +const LIGHT_THEME_SECTION_TITLE: &str = "Light"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ThemePickerOption { + pub(super) id: String, + pub(super) label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ThemePickerSection { + pub(super) title: &'static str, + pub(super) options: Vec, +} + +pub(super) fn build_theme_picker_sections(theme_manager: &ThemeManager) -> Vec { + let mut dark_options = Vec::new(); + let mut light_options = Vec::new(); + + for theme in theme_manager.list() { + let option = ThemePickerOption { + id: theme.id.clone(), + label: theme.name.clone(), + }; + + if theme.is_dark { + dark_options.push(option); + } else { + light_options.push(option); + } + } + + sort_theme_picker_options(&mut dark_options); + sort_theme_picker_options(&mut light_options); + + let mut sections = Vec::new(); + if !dark_options.is_empty() { + sections.push(ThemePickerSection { + title: DARK_THEME_SECTION_TITLE, + options: dark_options, + }); + } + if !light_options.is_empty() { + sections.push(ThemePickerSection { + title: LIGHT_THEME_SECTION_TITLE, + options: light_options, + }); + } + + sections +} + +pub(super) fn theme_picker_display_label( + theme_manager: &ThemeManager, + selected_id: &str, +) -> String { + theme_manager + .get(selected_id) + .map(|theme| theme.name.clone()) + .unwrap_or_else(|| selected_id.to_string()) +} + +fn sort_theme_picker_options(options: &mut [ThemePickerOption]) { + options.sort_by_key(theme_picker_sort_key); +} + +fn theme_picker_sort_key(option: &ThemePickerOption) -> (String, String) { + ( + option.label.to_ascii_lowercase(), + option.id.to_ascii_lowercase(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::CodirigentTheme; + use crate::theme_config::Theme; + + #[test] + fn build_theme_picker_sections_groups_dark_before_light_and_sorts_by_name() { + let mut manager = ThemeManager::with_defaults(); + manager.add_theme(Theme::from_runtime( + "night-owl", + "Night Owl", + true, + &CodirigentTheme::dark(), + )); + manager.add_theme(Theme::from_runtime( + "tokyo-night", + "Tokyo Night", + true, + &CodirigentTheme::dark(), + )); + manager.add_theme(Theme::from_runtime( + "aurora", + "Aurora", + false, + &CodirigentTheme::light(), + )); + + let sections = build_theme_picker_sections(&manager); + + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].title, DARK_THEME_SECTION_TITLE); + assert_eq!( + sections[0] + .options + .iter() + .map(|option| option.label.as_str()) + .collect::>(), + vec!["Dark", "Night Owl", "Tokyo Night"] + ); + assert_eq!(sections[1].title, LIGHT_THEME_SECTION_TITLE); + assert_eq!( + sections[1] + .options + .iter() + .map(|option| option.label.as_str()) + .collect::>(), + vec!["Aurora", "Light"] + ); + } + + #[test] + fn theme_picker_display_label_uses_theme_name_when_available() { + let manager = ThemeManager::with_defaults(); + + assert_eq!(theme_picker_display_label(&manager, "dark"), "Dark"); + assert_eq!( + theme_picker_display_label(&manager, "missing-theme"), + "missing-theme" + ); + } +} diff --git a/crates/codirigent-ui/src/workspace/tab_status_render.rs b/crates/codirigent-ui/src/workspace/tab_status_render.rs new file mode 100644 index 00000000..5bbc8571 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/tab_status_render.rs @@ -0,0 +1,227 @@ +//! Tab status indicator rendering for session tabs. +//! +//! Provides three configurable styles (dot, badge, glow) for showing +//! session status on tab pills. Animation policy: only NeedsAttention +//! and ResponseReady pulse, and only on background (non-active) tabs. +//! +//! Note: Tab animation rules differ from `StatusIndicator::animated` +//! (which flags Working and NeedsAttention for the pane header). This +//! module defines its own animation policy. + +use crate::terminal_header::StatusIndicator; +use codirigent_core::SessionStatus; +use gpui::Hsla; + +/// Decoration produced by the tab status renderer. +/// +/// The caller uses this to apply the status indicator to each tab: +/// - `child`: a dot/badge element to prepend or append to the tab name +/// - `tab_bg` / `tab_border`: background tint for glow style +/// - `should_pulse`: whether this tab should animate (pulse opacity) +pub(super) struct TabStatusDecoration { + /// Optional child element (dot/badge circle). None for glow style. + pub child: Option, + /// Optional background color for the tab container (glow style). + pub tab_bg: Option, + /// Optional border color for the tab container (glow style). + pub tab_border: Option, + /// Whether this tab should pulse (NeedsAttention/ResponseReady on background tabs). + pub should_pulse: bool, +} + +/// Map a `SessionStatus` to its indicator color as HSLA. +/// +/// Reuses the canonical color mapping from [`StatusIndicator::for_status`] +/// to avoid color duplication between the tab and header rendering paths. +fn status_color(status: SessionStatus) -> Hsla { + StatusIndicator::for_status(status).color.into() +} + +/// Whether the given status should pulse on a background tab. +fn should_pulse_status(status: SessionStatus, is_active: bool) -> bool { + if is_active { + return false; + } + matches!( + status, + SessionStatus::NeedsAttention | SessionStatus::ResponseReady + ) +} + +/// Render tab status decoration for the given style. +/// +/// `style` is one of "dot", "badge", or "glow". Unknown values fall back to "dot". +/// `is_active` is true for the currently visible tab in the pane. +pub(super) fn render_tab_status( + style: &str, + status: SessionStatus, + is_active: bool, +) -> TabStatusDecoration { + let color = status_color(status); + let pulse = should_pulse_status(status, is_active); + + match style { + "glow" => TabStatusDecoration { + child: None, + tab_bg: Some(color.opacity(0.15)), + tab_border: Some(color.opacity(0.25)), + should_pulse: pulse, + }, + // "dot", "badge", and any unknown value all produce a dot child. + // The caller decides placement (prepend for "dot", append for "badge"). + _ => { + use gpui::{div, px, IntoElement, Styled}; + let dot = div() + .w(px(8.0)) + .h(px(8.0)) + .rounded_full() + .bg(color) + .flex_shrink_0() + .into_any_element(); + TabStatusDecoration { + child: Some(dot), + tab_bg: None, + tab_border: None, + should_pulse: pulse, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── status_color tests ────────────────────────────────────────── + + #[test] + fn test_status_color_idle() { + let color = status_color(SessionStatus::Idle); + assert!(color.a > 0.0); + } + + #[test] + fn test_status_color_all_variants_are_distinct() { + let colors: Vec = [ + SessionStatus::Idle, + SessionStatus::Working, + SessionStatus::NeedsAttention, + SessionStatus::ResponseReady, + SessionStatus::Error, + ] + .iter() + .map(|s| status_color(*s)) + .collect(); + + for i in 0..colors.len() - 1 { + assert_ne!( + (colors[i].h, colors[i].s), + (colors[i + 1].h, colors[i + 1].s), + "colors for variants {} and {} should differ", + i, + i + 1 + ); + } + } + + // ── should_pulse_status tests ─────────────────────────────────── + + #[test] + fn test_pulse_needs_attention_background() { + assert!(should_pulse_status(SessionStatus::NeedsAttention, false)); + } + + #[test] + fn test_pulse_response_ready_background() { + assert!(should_pulse_status(SessionStatus::ResponseReady, false)); + } + + #[test] + fn test_no_pulse_needs_attention_active() { + assert!(!should_pulse_status(SessionStatus::NeedsAttention, true)); + } + + #[test] + fn test_no_pulse_response_ready_active() { + assert!(!should_pulse_status(SessionStatus::ResponseReady, true)); + } + + #[test] + fn test_no_pulse_idle() { + assert!(!should_pulse_status(SessionStatus::Idle, false)); + assert!(!should_pulse_status(SessionStatus::Idle, true)); + } + + #[test] + fn test_no_pulse_working() { + assert!(!should_pulse_status(SessionStatus::Working, false)); + assert!(!should_pulse_status(SessionStatus::Working, true)); + } + + #[test] + fn test_no_pulse_error() { + assert!(!should_pulse_status(SessionStatus::Error, false)); + assert!(!should_pulse_status(SessionStatus::Error, true)); + } + + // ── render_tab_status tests ───────────────────────────────────── + // + // Glow tests are safe (no GPUI element creation). Dot/badge tests + // create GPUI elements — if they fail without a context, the pure + // function tests above still cover the core logic. + + #[test] + fn test_glow_style_has_bg_no_child() { + let dec = render_tab_status("glow", SessionStatus::Working, false); + assert!(dec.child.is_none()); + assert!(dec.tab_bg.is_some()); + assert!(dec.tab_border.is_some()); + } + + #[test] + fn test_glow_pulse_on_needs_attention_background() { + let dec = render_tab_status("glow", SessionStatus::NeedsAttention, false); + assert!(dec.should_pulse); + } + + #[test] + fn test_glow_no_pulse_on_active_tab() { + let dec = render_tab_status("glow", SessionStatus::NeedsAttention, true); + assert!(!dec.should_pulse); + } + + #[test] + fn test_glow_no_pulse_idle() { + let dec = render_tab_status("glow", SessionStatus::Idle, false); + assert!(!dec.should_pulse); + } + + #[test] + fn test_dot_style_has_child_no_bg() { + let dec = render_tab_status("dot", SessionStatus::Working, false); + assert!(dec.child.is_some()); + assert!(dec.tab_bg.is_none()); + assert!(dec.tab_border.is_none()); + } + + #[test] + fn test_badge_style_has_child_no_bg() { + let dec = render_tab_status("badge", SessionStatus::Idle, true); + assert!(dec.child.is_some()); + assert!(dec.tab_bg.is_none()); + assert!(dec.tab_border.is_none()); + } + + #[test] + fn test_unknown_style_falls_back_to_dot() { + let dec = render_tab_status("unknown", SessionStatus::Idle, false); + assert!(dec.child.is_some()); + assert!(dec.tab_bg.is_none()); + } + + #[test] + fn test_dot_pulse_on_response_ready_background() { + let dec = render_tab_status("dot", SessionStatus::ResponseReady, false); + assert!(dec.should_pulse); + } +} diff --git a/crates/codirigent-ui/src/workspace/tests.rs b/crates/codirigent-ui/src/workspace/tests.rs index 6133633b..ab535e4a 100644 --- a/crates/codirigent-ui/src/workspace/tests.rs +++ b/crates/codirigent-ui/src/workspace/tests.rs @@ -1168,6 +1168,127 @@ fn test_workspace_focus_hidden_grid_session_prefers_empty_cell() { assert!(ws.is_session_visible(SessionId(1))); } +#[test] +fn test_workspace_focus_hidden_grid_session_replaces_active_tab_in_grouped_pane() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=6 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + assert!(ws.group_session_into_pane(SessionId(1), PaneId::GridCell { index: 1 })); + assert!(ws.focus_session(SessionId(5))); + assert!(ws.focus_session(SessionId(1))); + assert!(ws.focus_session(SessionId(6))); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(2), SessionId(6)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(6)) + ); + assert!(!ws.is_session_visible(SessionId(1))); + assert!(ws.focus_session(SessionId(1))); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(1)) + ); +} + +#[test] +fn test_workspace_focus_hidden_custom_grid_session_replaces_active_tab_in_grouped_pane() { + let mut ws = Workspace::with_profile(LayoutProfile::Custom { rows: 1, cols: 3 }); + for i in 1..=5 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + assert!(ws.group_session_into_pane(SessionId(1), PaneId::GridCell { index: 1 })); + assert!(ws.focus_session(SessionId(4))); + assert!(ws.focus_session(SessionId(1))); + assert!(ws.focus_session(SessionId(5))); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(2), SessionId(5)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(5)) + ); + assert!(!ws.is_session_visible(SessionId(1))); + assert!(ws.focus_session(SessionId(1))); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(1)) + ); +} + +#[test] +fn test_workspace_focus_hidden_split_session_replaces_active_tab_in_grouped_pane() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=3 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + ws.set_split_tree(LayoutNode::from_grid(1, 2)); + assert!(ws.group_session_into_pane(SessionId(1), PaneId::SplitSlot { slot: SlotId(1) })); + assert!(ws.focus_session(SessionId(3))); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::SplitSlot { slot: SlotId(1) }), + vec![SessionId(2), SessionId(3)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::SplitSlot { slot: SlotId(1) }), + Some(SessionId(3)) + ); + assert!(!ws.is_session_visible(SessionId(1))); + assert!(ws.focus_session(SessionId(1))); + assert_eq!( + ws.pane_active_session_id(PaneId::SplitSlot { slot: SlotId(1) }), + Some(SessionId(1)) + ); +} + +#[test] +fn test_workspace_focus_hidden_custom_split_session_replaces_active_tab_in_grouped_pane() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=4 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + let tree = LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.55, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Split { + direction: SplitDirection::Vertical, + ratio: 0.4, + first: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(2) }), + }), + }; + ws.set_split_tree(tree); + assert!(ws.group_session_into_pane(SessionId(1), PaneId::SplitSlot { slot: SlotId(1) })); + assert!(ws.focus_session(SessionId(4))); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::SplitSlot { slot: SlotId(1) }), + vec![SessionId(2), SessionId(4)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::SplitSlot { slot: SlotId(1) }), + Some(SessionId(4)) + ); + assert!(!ws.is_session_visible(SessionId(1))); + assert!(ws.focus_session(SessionId(1))); + assert_eq!( + ws.pane_active_session_id(PaneId::SplitSlot { slot: SlotId(1) }), + Some(SessionId(1)) + ); +} + #[test] fn test_workspace_restore_pane_tab_groups_rehydrates_active_tabs() { let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); diff --git a/crates/codirigent-ui/src/workspace/toast_render.rs b/crates/codirigent-ui/src/workspace/toast_render.rs new file mode 100644 index 00000000..eed26133 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/toast_render.rs @@ -0,0 +1,296 @@ +//! Toast notification rendering for auto-update UI. +//! +//! Renders a small overlay in the bottom-right corner of the workspace +//! showing update status: available, downloading, ready to apply, or +//! post-update confirmation. + +use super::gpui::WorkspaceView; +use gpui::{ + div, px, ClickEvent, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, + SharedString, StatefulInteractiveElement, Styled, +}; + +impl WorkspaceView { + /// Render the auto-update toast notification. + /// + /// Returns `None` when there is nothing to show (all update state is + /// `None` or the user has dismissed the toast). + pub(super) fn render_update_toast(&self, cx: &mut Context) -> Option { + // Determine which toast variant to show (priority order). + let variant = if let Some(ref staged) = self.staged_update { + ToastVariant::ReadyToApply { + version: staged.version.to_string(), + } + } else if let Some(percent) = self.update_download_progress { + ToastVariant::Downloading { percent } + } else if let Some(ref info) = self.update_info { + if self.update_dismissed { + return None; + } + ToastVariant::UpdateAvailable { + version: info.version.to_string(), + } + } else { + return None; + }; + + let theme = self.workspace().theme(); + let panel_bg: gpui::Hsla = theme.panel_background.into(); + let border_color: gpui::Hsla = theme.border.into(); + let fg: gpui::Hsla = theme.foreground.into(); + let muted: gpui::Hsla = theme.muted.into(); + let primary: gpui::Hsla = theme.primary.into(); + + let mut toast = div() + .id("update-toast") + .absolute() + .bottom(px(16.0)) + .right(px(16.0)) + .bg(panel_bg) + .border_1() + .border_color(border_color) + .rounded_lg() + .shadow_lg() + .p_3() + .flex() + .flex_col() + .gap_2() + .max_w(px(320.0)) + .min_w(px(240.0)); + + match variant { + ToastVariant::UpdateAvailable { version } => { + toast = toast + .child( + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child(SharedString::from(format!( + "Update available (v{})", + version + ))), + ) + .child(self.render_dismiss_button(muted, cx)), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child("A new version of Codirigent is available."), + ) + .child( + div() + .flex() + .gap_2() + .justify_end() + .child(self.render_toast_button( + "update-btn", + "Update", + primary, + gpui::Hsla::white(), + |this: &mut Self, + _: &ClickEvent, + _window, + cx: &mut gpui::Context| { + if let Some(svc) = &this.update_service { + svc.start_download(); + } + cx.notify(); + }, + cx, + )), + ); + } + ToastVariant::Downloading { percent } => { + toast = toast + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child(SharedString::from(format!("Downloading... {}%", percent))), + ) + .child(self.render_progress_bar(percent, primary, border_color)) + .child( + div() + .flex() + .gap_2() + .justify_end() + .child(self.render_toast_button( + "cancel-download-btn", + "Cancel", + border_color, + fg, + |this: &mut Self, + _: &ClickEvent, + _window, + cx: &mut gpui::Context| { + if let Some(svc) = &this.update_service { + svc.cancel_download(); + } + this.update_download_progress = None; + // Restore update_info from service state + if let Some(svc) = &this.update_service { + if let codirigent_updater::UpdateState::UpdateAvailable( + info, + ) = svc.state() + { + this.update_info = Some(info); + } + } + cx.notify(); + }, + cx, + )), + ); + } + ToastVariant::ReadyToApply { version } => { + toast = toast + .child( + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child(SharedString::from(format!( + "Update ready (v{})", + version + ))), + ), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child("Restart to apply the update."), + ) + .child( + div() + .flex() + .gap_2() + .justify_end() + .child(self.render_toast_button( + "later-btn", + "Later", + border_color, + fg, + |this: &mut Self, + _: &ClickEvent, + _window, + cx: &mut gpui::Context| { + this.update_dismissed = true; + cx.notify(); + }, + cx, + )) + .child(self.render_toast_button( + "restart-btn", + "Restart Now", + primary, + gpui::Hsla::white(), + |this: &mut Self, + _: &ClickEvent, + _window, + cx: &mut gpui::Context| { + if let Some(svc) = &this.update_service { + match svc.apply() { + Ok(()) => { + cx.quit(); + } + Err(e) => { + tracing::error!("Failed to apply update: {}", e); + } + } + } + }, + cx, + )), + ); + } + } + + Some(toast) + } + + /// Render a small dismiss (X) button for the toast. + fn render_dismiss_button(&self, muted: gpui::Hsla, cx: &mut Context) -> impl IntoElement { + div() + .id("dismiss-update-toast") + .text_xs() + .text_color(muted) + .cursor_pointer() + .hover(|style| style.text_color(muted.opacity(0.7))) + .px_1() + .rounded_sm() + .child("\u{2715}") // Unicode X mark + .on_click(cx.listener(|this, _: &ClickEvent, _window, cx| { + this.update_dismissed = true; + cx.notify(); + })) + } + + /// Render a styled button for the toast. + fn render_toast_button( + &self, + id: &str, + label: &str, + bg: gpui::Hsla, + text: gpui::Hsla, + on_click: impl Fn(&mut Self, &ClickEvent, &mut gpui::Window, &mut Context) + 'static, + cx: &mut Context, + ) -> impl IntoElement { + div() + .id(SharedString::from(id.to_string())) + .px_3() + .py(px(4.0)) + .rounded_md() + .bg(bg) + .text_xs() + .text_color(text) + .cursor_pointer() + .hover(|style| style.opacity(0.85)) + .on_click(cx.listener(on_click)) + .child(SharedString::from(label.to_string())) + } + + /// Render a simple progress bar. + fn render_progress_bar( + &self, + percent: u8, + fill_color: gpui::Hsla, + track_color: gpui::Hsla, + ) -> impl IntoElement { + let width_pct = (percent as f32).clamp(0.0, 100.0); + div() + .w_full() + .h(px(4.0)) + .rounded_sm() + .bg(track_color) + .child( + div() + .h_full() + .rounded_sm() + .bg(fill_color) + .w(gpui::relative(width_pct / 100.0)), + ) + } +} + +/// Toast content variants. +enum ToastVariant { + UpdateAvailable { version: String }, + Downloading { percent: u8 }, + ReadyToApply { version: String }, +} diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs index b2cdf608..96a534cd 100644 --- a/crates/codirigent-ui/src/workspace/types.rs +++ b/crates/codirigent-ui/src/workspace/types.rs @@ -682,8 +682,8 @@ pub(super) struct RenderLayoutSignature { pub sidebar_width: f32, pub right_panel_width: f32, pub grid_gap: f32, - /// Only populated in single-session layout where the visible cell depends on focus. - pub focused_session_id: Option, + /// Hash of the sessions actively rendered in visible panes, in pane order. + pub rendered_sessions_signature: u64, } #[derive(Debug, Clone, Copy, PartialEq)] diff --git a/crates/codirigent-updater/Cargo.toml b/crates/codirigent-updater/Cargo.toml new file mode 100644 index 00000000..7a85071d --- /dev/null +++ b/crates/codirigent-updater/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "codirigent-updater" +description = "Auto-update checking and installation for Codirigent" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +codirigent-core.workspace = true +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +reqwest.workspace = true +semver.workspace = true +sha2.workspace = true +hex.workspace = true +dirs.workspace = true +chrono.workspace = true +futures-util.workspace = true +tokio-util.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio-test.workspace = true diff --git a/crates/codirigent-updater/src/checker.rs b/crates/codirigent-updater/src/checker.rs new file mode 100644 index 00000000..ba872755 --- /dev/null +++ b/crates/codirigent-updater/src/checker.rs @@ -0,0 +1,375 @@ +//! GitHub Releases API polling and version comparison. +//! +//! The checker queries the GitHub Releases API for the latest stable release +//! of Codirigent, compares it against the currently running version using +//! semver, and returns an [`UpdateInfo`] when an upgrade is available. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, warn}; + +/// GitHub repository used for release checks. +const GITHUB_REPO: &str = "oso95/Codirigent"; + +/// Information about an available update. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UpdateInfo { + /// The new version. + pub version: semver::Version, + /// URL to the GitHub release page. + pub release_url: String, + /// Direct download URL for the platform artifact. + pub asset_url: String, + /// Direct download URL for checksums-sha256.txt. + pub checksum_url: String, +} + +// --------------------------------------------------------------------------- +// GitHub API response types (private) +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct GitHubRelease { + tag_name: String, + html_url: String, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +// --------------------------------------------------------------------------- +// Platform asset filter +// --------------------------------------------------------------------------- + +/// Returns `(target_triple_substring, artifact_suffix)` for the current +/// platform, or `None` if auto-update is not supported on this OS/arch. +pub fn platform_asset_filter() -> Option<(&'static str, &'static str)> { + match (std::env::consts::ARCH, std::env::consts::OS) { + ("aarch64", "macos") => Some(("aarch64-apple-darwin", ".dmg")), + ("x86_64", "macos") => Some(("x86_64-apple-darwin", ".dmg")), + ("x86_64", "windows") => Some(("x86_64-pc-windows-msvc", ".msi")), + ("aarch64", "windows") => Some(("aarch64-pc-windows-msvc", ".msi")), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Pure parsing / comparison logic +// --------------------------------------------------------------------------- + +/// Parse a GitHub release JSON response and determine if an update is available. +/// +/// This is a pure function (no I/O) that makes it easy to test version +/// comparison logic with synthetic payloads. +/// +/// Returns: +/// - `Ok(Some(UpdateInfo))` if the release is newer than `current_version` +/// - `Ok(None)` if up to date, no matching platform asset, or no checksum file +/// - `Err` on JSON parse failure +pub fn parse_release( + response_json: &str, + current_version: &semver::Version, +) -> Result> { + let release: GitHubRelease = + serde_json::from_str(response_json).context("Failed to parse GitHub release JSON")?; + + // Strip optional leading 'v' from tag. + let tag = release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name); + let remote_version: semver::Version = tag + .parse() + .with_context(|| format!("Invalid semver in tag: {}", release.tag_name))?; + + // A pre-release current version (e.g. 0.3.0-alpha) is "ahead" of a stable + // release (e.g. 0.2.0) if its major.minor.patch is greater. But a + // pre-release *of the same version* (0.2.0-alpha) should be notified about + // the stable release (0.2.0). + // + // semver crate ordering: 0.2.0-alpha < 0.2.0 < 0.3.0-alpha < 0.3.0 + // So `remote_version > *current_version` handles both cases correctly: + // - 0.2.0 > 0.2.0-alpha → true (notify) + // - 0.2.0 > 0.3.0-alpha → false (don't notify) + if remote_version <= *current_version { + debug!( + current = %current_version, + remote = %remote_version, + "Already up to date" + ); + return Ok(None); + } + + // Find the platform artifact. + let (triple, suffix) = match platform_asset_filter() { + Some(filter) => filter, + None => { + debug!("No platform asset filter for this OS/arch — skipping"); + return Ok(None); + } + }; + + let artifact = release + .assets + .iter() + .find(|a| a.name.contains(triple) && a.name.ends_with(suffix)); + + let artifact = match artifact { + Some(a) => a, + None => { + debug!( + triple, + suffix, "No matching platform artifact found in release assets" + ); + return Ok(None); + } + }; + + // Find the checksum file. + let checksum = release + .assets + .iter() + .find(|a| a.name == "checksums-sha256.txt"); + + let checksum = match checksum { + Some(c) => c, + None => { + debug!("No checksums-sha256.txt found in release assets"); + return Ok(None); + } + }; + + Ok(Some(UpdateInfo { + version: remote_version, + release_url: release.html_url, + asset_url: artifact.browser_download_url.clone(), + checksum_url: checksum.browser_download_url.clone(), + })) +} + +// --------------------------------------------------------------------------- +// Async network check +// --------------------------------------------------------------------------- + +/// Check the GitHub Releases API for the latest version. +/// +/// Returns `Ok(Some(UpdateInfo))` when a newer release is found, `Ok(None)` +/// when up to date (or rate-limited / no releases), and `Err` on network or +/// parse failure. +pub async fn check_for_update( + current_version: &semver::Version, + client: &reqwest::Client, +) -> Result> { + let url = format!( + "https://api.github.com/repos/{}/releases/latest", + GITHUB_REPO + ); + + let response = client + .get(&url) + .header("User-Agent", format!("codirigent/{current_version}")) + .header("Accept", "application/vnd.github+json") + .send() + .await + .context("Failed to reach GitHub Releases API")?; + + let status = response.status(); + + // 404 → repository has no releases yet. + if status == reqwest::StatusCode::NOT_FOUND { + debug!("No releases found (404)"); + return Ok(None); + } + + // 403 / 429 → rate-limited; treat as "no update" and try again later. + if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::TOO_MANY_REQUESTS + { + warn!( + status = status.as_u16(), + "GitHub API rate limit hit — will retry later" + ); + return Ok(None); + } + + let body = response + .error_for_status() + .context("GitHub API returned an error")? + .text() + .await + .context("Failed to read GitHub API response body")?; + + parse_release(&body, current_version) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper to build a minimal GitHub release JSON payload. + fn make_release_json( + tag: &str, + assets: &[(&str, &str)], // (name, url) + ) -> String { + let asset_entries: Vec = assets + .iter() + .map(|(name, url)| format!(r#"{{"name": "{name}", "browser_download_url": "{url}"}}"#)) + .collect(); + + format!( + r#"{{ + "tag_name": "{tag}", + "html_url": "https://github.com/oso95/Codirigent/releases/tag/{tag}", + "assets": [{assets}] + }}"#, + assets = asset_entries.join(",") + ) + } + + /// Build release JSON with typical assets for the current platform. + fn make_platform_release(tag: &str) -> String { + let (triple, suffix) = platform_asset_filter().unwrap_or(("aarch64-apple-darwin", ".dmg")); + let artifact_name = format!("Codirigent-{triple}{suffix}"); + let artifact_url = format!("https://example.com/{artifact_name}"); + make_release_json( + tag, + &[ + (&artifact_name, &artifact_url), + ( + "checksums-sha256.txt", + "https://example.com/checksums-sha256.txt", + ), + ], + ) + } + + #[test] + fn newer_version_returns_update_info() { + let current: semver::Version = "0.1.0".parse().unwrap(); + let json = make_platform_release("v0.2.0"); + let result = parse_release(&json, ¤t).expect("parse should succeed"); + assert!(result.is_some(), "should detect newer version"); + let info = result.unwrap(); + assert_eq!(info.version, "0.2.0".parse::().unwrap()); + } + + #[test] + fn same_version_returns_none() { + let current: semver::Version = "0.2.0".parse().unwrap(); + let json = make_platform_release("v0.2.0"); + let result = parse_release(&json, ¤t).expect("parse should succeed"); + assert!(result.is_none(), "same version should return None"); + } + + #[test] + fn older_version_returns_none() { + let current: semver::Version = "0.3.0".parse().unwrap(); + let json = make_platform_release("v0.2.0"); + let result = parse_release(&json, ¤t).expect("parse should succeed"); + assert!(result.is_none(), "older version should return None"); + } + + #[test] + fn prerelease_user_gets_notified_for_stable() { + // User on 0.2.0-alpha should be notified about stable 0.2.0 + let current: semver::Version = "0.2.0-alpha".parse().unwrap(); + let json = make_platform_release("v0.2.0"); + let result = parse_release(&json, ¤t).expect("parse should succeed"); + assert!( + result.is_some(), + "prerelease user should be notified of stable release" + ); + } + + #[test] + fn prerelease_user_ahead_of_stable_gets_none() { + // User on 0.3.0-alpha is ahead of stable 0.2.0 + let current: semver::Version = "0.3.0-alpha".parse().unwrap(); + let json = make_platform_release("v0.2.0"); + let result = parse_release(&json, ¤t).expect("parse should succeed"); + assert!( + result.is_none(), + "prerelease user ahead of stable should get None" + ); + } + + #[test] + fn missing_checksum_asset_returns_none() { + let current: semver::Version = "0.1.0".parse().unwrap(); + let (triple, suffix) = platform_asset_filter().unwrap_or(("aarch64-apple-darwin", ".dmg")); + let artifact_name = format!("Codirigent-{triple}{suffix}"); + // Release with the artifact but NO checksums-sha256.txt + let json = make_release_json( + "v0.2.0", + &[(&artifact_name, "https://example.com/artifact")], + ); + let result = parse_release(&json, ¤t).expect("parse should succeed"); + assert!(result.is_none(), "missing checksum should return None"); + } + + #[test] + fn tag_without_v_prefix_parses_correctly() { + let current: semver::Version = "0.1.0".parse().unwrap(); + let json = make_platform_release("0.2.0"); // no 'v' prefix + let result = parse_release(&json, ¤t).expect("parse should succeed"); + assert!(result.is_some(), "tag without v prefix should still parse"); + let info = result.unwrap(); + assert_eq!(info.version, "0.2.0".parse::().unwrap()); + } + + #[test] + fn invalid_json_returns_error() { + let current: semver::Version = "0.1.0".parse().unwrap(); + let result = parse_release("not json at all", ¤t); + assert!(result.is_err(), "invalid JSON should return Err"); + } + + #[test] + fn platform_asset_filter_values() { + // This test verifies the function returns a value on the current platform. + // On CI or unsupported platforms, we just verify the function doesn't panic. + let filter = platform_asset_filter(); + + // On macOS (aarch64 or x86_64) or Windows, should be Some. + if cfg!(target_os = "macos") { + assert!(filter.is_some(), "macOS should have a platform filter"); + let (triple, suffix) = filter.unwrap(); + assert!(triple.contains("apple-darwin")); + assert_eq!(suffix, ".dmg"); + } else if cfg!(target_os = "windows") { + assert!(filter.is_some(), "Windows should have a platform filter"); + let (triple, suffix) = filter.unwrap(); + assert!(triple.contains("pc-windows-msvc")); + assert_eq!(suffix, ".msi"); + } + } + + #[test] + fn no_matching_platform_artifact_returns_none() { + let current: semver::Version = "0.1.0".parse().unwrap(); + // Only has a Linux artifact — no match on macOS or Windows. + let json = make_release_json( + "v0.2.0", + &[ + ( + "Codirigent-x86_64-unknown-linux-gnu.tar.gz", + "https://example.com/linux.tar.gz", + ), + ( + "checksums-sha256.txt", + "https://example.com/checksums-sha256.txt", + ), + ], + ); + let result = parse_release(&json, ¤t).expect("parse should succeed"); + // On macOS/Windows this returns None (no matching asset). + // On Linux (unsupported platform) it also returns None (no filter). + assert!( + result.is_none(), + "should return None when platform artifact is missing" + ); + } +} diff --git a/crates/codirigent-updater/src/downloader.rs b/crates/codirigent-updater/src/downloader.rs new file mode 100644 index 00000000..123c712f --- /dev/null +++ b/crates/codirigent-updater/src/downloader.rs @@ -0,0 +1,362 @@ +//! Artifact download and SHA256 checksum verification. +//! +//! Downloads release artifacts from GitHub with streaming progress callbacks, +//! then verifies integrity using SHA256 checksums from `checksums-sha256.txt`. + +use anyhow::{bail, Context, Result}; +use futures_util::StreamExt; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWriteExt; +use tracing::{debug, info}; + +/// Parse a `sha256sum`-format checksums file and return the hash for `filename`. +/// +/// The expected format is ` ` (two spaces between hash and +/// filename). Returns `None` if no matching line is found. +pub fn find_checksum(checksums_content: &str, filename: &str) -> Option { + for line in checksums_content.lines() { + // sha256sum format: " " (two spaces) + if let Some((hash, name)) = line.split_once(" ") { + if name.trim() == filename { + return Some(hash.trim().to_string()); + } + } + } + None +} + +/// Read a file and verify its SHA256 hash against an expected hex string. +/// +/// The comparison is case-insensitive. Returns `Ok(true)` on match, +/// `Ok(false)` on mismatch, or an error if the file cannot be read. +pub fn verify_sha256(file_path: &Path, expected_hex: &str) -> Result { + let data = std::fs::read(file_path).with_context(|| { + format!( + "Failed to read file for SHA256 verification: {}", + file_path.display() + ) + })?; + + let mut hasher = Sha256::new(); + hasher.update(&data); + let actual_hex = hex::encode(hasher.finalize()); + + Ok(actual_hex.eq_ignore_ascii_case(expected_hex)) +} + +/// Download the checksums file content from the given URL. +pub async fn download_checksums( + client: &reqwest::Client, + url: &str, + user_agent: &str, +) -> Result { + let response = client + .get(url) + .header("User-Agent", user_agent) + .header("Accept", "application/octet-stream") + .send() + .await + .context("Failed to download checksums file")? + .error_for_status() + .context("Checksums download returned an error status")?; + + response + .text() + .await + .context("Failed to read checksums response body") +} + +/// Download an artifact to `dest_dir` with streaming progress. +/// +/// The filename is extracted from the URL path. The `on_progress` callback +/// receives the percentage (0..=100) as downloads proceed. A 10-minute timeout +/// is applied to the overall request. +pub async fn download_artifact( + client: &reqwest::Client, + url: &str, + dest_dir: &Path, + user_agent: &str, + on_progress: F, +) -> Result +where + F: Fn(u8), +{ + // Extract filename from URL. + let filename = url + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .context("Could not extract filename from artifact URL")?; + + // Validate that the filename contains only safe characters to prevent + // path traversal or other filesystem attacks. + if !filename + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_') + { + bail!( + "Unsafe characters in artifact filename '{}': only alphanumeric, hyphens, dots, and underscores are allowed", + filename + ); + } + + // Ensure destination directory exists. + tokio::fs::create_dir_all(dest_dir).await.with_context(|| { + format!( + "Failed to create download directory: {}", + dest_dir.display() + ) + })?; + + let dest_path = dest_dir.join(filename); + + debug!(url, dest = %dest_path.display(), "Starting artifact download"); + + let response = client + .get(url) + .header("User-Agent", user_agent) + .header("Accept", "application/octet-stream") + .timeout(std::time::Duration::from_secs(600)) // 10 minutes + .send() + .await + .context("Failed to start artifact download")? + .error_for_status() + .context("Artifact download returned an error status")?; + + let total_size = response.content_length(); + let mut stream = response.bytes_stream(); + + let mut file = tokio::fs::File::create(&dest_path) + .await + .with_context(|| format!("Failed to create file: {}", dest_path.display()))?; + + let mut downloaded: u64 = 0; + let mut last_percent: u8 = 0; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Error reading download stream")?; + file.write_all(&chunk) + .await + .context("Failed to write chunk to file")?; + + downloaded += chunk.len() as u64; + + if let Some(total) = total_size { + let percent = if total > 0 { + ((downloaded as f64 / total as f64) * 100.0).min(100.0) as u8 + } else { + 0 + }; + if percent != last_percent { + last_percent = percent; + on_progress(percent); + } + } + } + + file.flush() + .await + .context("Failed to flush downloaded file")?; + + // Ensure 100% is reported. + if last_percent < 100 { + on_progress(100); + } + + info!( + dest = %dest_path.display(), + bytes = downloaded, + "Artifact download complete" + ); + + Ok(dest_path) +} + +/// Download an artifact and verify it against the checksums file. +/// +/// Returns both the artifact path and the expected SHA256 hash on success. +/// The caller can store the hash in [`StagedUpdate`] for re-verification +/// before applying the update. +/// +/// If the checksum does not match, the downloaded artifact is deleted and +/// an error is returned. +pub async fn download_and_verify( + client: &reqwest::Client, + asset_url: &str, + checksum_url: &str, + dest_dir: &Path, + user_agent: &str, + on_progress: F, +) -> Result<(PathBuf, String)> +where + F: Fn(u8), +{ + // 1. Download checksums file. + let checksums_content = download_checksums(client, checksum_url, user_agent).await?; + + // 2. Download the artifact. + let artifact_path = + download_artifact(client, asset_url, dest_dir, user_agent, on_progress).await?; + + // 3. Extract the filename to look up its expected hash. + let filename = artifact_path + .file_name() + .and_then(|n| n.to_str()) + .context("Artifact path has no filename")?; + + let expected_hash = find_checksum(&checksums_content, filename) + .with_context(|| format!("No checksum found for '{}' in checksums file", filename))?; + + // 4. Verify. + let valid = verify_sha256(&artifact_path, &expected_hash) + .with_context(|| format!("SHA256 verification failed for {}", artifact_path.display()))?; + + if !valid { + // Delete the corrupt artifact. + let _ = std::fs::remove_file(&artifact_path); + bail!( + "SHA256 checksum mismatch for '{}': expected {}", + filename, + expected_hash + ); + } + + info!( + artifact = %artifact_path.display(), + sha256 = %expected_hash, + "Artifact verified successfully" + ); + + Ok((artifact_path, expected_hash)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + // ----------------------------------------------------------------------- + // find_checksum tests + // ----------------------------------------------------------------------- + + #[test] + fn find_checksum_standard_format() { + let content = "\ +abc123def456 Codirigent-aarch64-apple-darwin.dmg +789abc012def Codirigent-x86_64-pc-windows-msvc.msi +fedcba987654 checksums-sha256.txt +"; + let hash = find_checksum(content, "Codirigent-aarch64-apple-darwin.dmg"); + assert_eq!(hash, Some("abc123def456".to_string())); + + let hash2 = find_checksum(content, "Codirigent-x86_64-pc-windows-msvc.msi"); + assert_eq!(hash2, Some("789abc012def".to_string())); + } + + #[test] + fn find_checksum_not_found() { + let content = "abc123def456 some-other-file.dmg\n"; + let hash = find_checksum(content, "nonexistent.dmg"); + assert_eq!(hash, None); + } + + #[test] + fn find_checksum_empty_content() { + let hash = find_checksum("", "anything.dmg"); + assert_eq!(hash, None); + } + + #[test] + fn find_checksum_no_double_space() { + // Lines without double-space separator should not match. + let content = "abc123 single-space-file.dmg\n"; + let hash = find_checksum(content, "single-space-file.dmg"); + assert_eq!(hash, None); + } + + // ----------------------------------------------------------------------- + // verify_sha256 tests + // ----------------------------------------------------------------------- + + #[test] + fn verify_sha256_correct_hash() { + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("test.bin"); + let data = b"hello world"; + + std::fs::write(&file_path, data).expect("write test file"); + + // Known SHA256 of "hello world" + let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; + let result = verify_sha256(&file_path, expected).expect("verify should not error"); + assert!(result, "hash should match for correct content"); + } + + #[test] + fn verify_sha256_incorrect_hash() { + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("test.bin"); + std::fs::write(&file_path, b"hello world").expect("write test file"); + + let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000"; + let result = verify_sha256(&file_path, wrong_hash).expect("verify should not error"); + assert!(!result, "hash should NOT match for wrong hash"); + } + + #[test] + fn verify_sha256_case_insensitive() { + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("test.bin"); + std::fs::write(&file_path, b"hello world").expect("write test file"); + + // Same hash as above but in UPPERCASE + let expected = "B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9"; + let result = verify_sha256(&file_path, expected).expect("verify should not error"); + assert!(result, "hash comparison should be case-insensitive"); + } + + #[test] + fn verify_sha256_missing_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("nonexistent.bin"); + + let result = verify_sha256(&file_path, "abc123"); + assert!(result.is_err(), "missing file should return an error"); + } + + #[test] + fn verify_sha256_empty_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("empty.bin"); + std::fs::File::create(&file_path).expect("create empty file"); + + // SHA256 of empty input + let expected = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let result = verify_sha256(&file_path, expected).expect("verify should not error"); + assert!(result, "empty file hash should match"); + } + + #[test] + fn verify_sha256_large_content() { + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("large.bin"); + + // Write 1MB of data + let mut file = std::fs::File::create(&file_path).expect("create file"); + let chunk = vec![0xABu8; 1024]; + for _ in 0..1024 { + file.write_all(&chunk).expect("write chunk"); + } + drop(file); + + // Compute expected hash + let data = std::fs::read(&file_path).expect("read"); + let mut hasher = Sha256::new(); + hasher.update(&data); + let expected = hex::encode(hasher.finalize()); + + let result = verify_sha256(&file_path, &expected).expect("verify should not error"); + assert!(result, "large file hash should match"); + } +} diff --git a/crates/codirigent-updater/src/lib.rs b/crates/codirigent-updater/src/lib.rs new file mode 100644 index 00000000..125d5760 --- /dev/null +++ b/crates/codirigent-updater/src/lib.rs @@ -0,0 +1,35 @@ +//! Codirigent Updater +//! +//! Automatic update checking and installation for Codirigent. +//! +//! This crate provides: +//! - Background version checking against GitHub Releases +//! - Artifact downloading with SHA256 verification +//! - Platform-specific update application (macOS DMG, Windows MSI) +//! +//! # Overview +//! +//! The updater checks `api.github.com/repos/oso95/Codirigent/releases/latest` +//! on startup and every 24 hours. When a newer stable version is found, it +//! publishes an `UpdateAvailable` event on the EventBus. The UI shows a toast +//! notification, and the user can choose when to download and apply the update. +//! +//! # Modules +//! +//! - [`checker`] - GitHub Releases API polling and semver comparison +//! - [`downloader`] - Artifact download and SHA256 verification +//! - [`service`] - Update state machine and orchestration +//! - [`platform`] - Platform-specific update application + +#![warn(missing_docs)] +#![warn(clippy::all)] + +pub mod checker; +pub mod downloader; +pub mod platform; +pub mod service; +pub mod state; + +pub use checker::UpdateInfo; +pub use service::{StagedUpdate, UpdateService, UpdateState}; +pub use state::{StagedUpdateState, UpdatePersistentState}; diff --git a/crates/codirigent-updater/src/platform/macos.rs b/crates/codirigent-updater/src/platform/macos.rs new file mode 100644 index 00000000..de2984a0 --- /dev/null +++ b/crates/codirigent-updater/src/platform/macos.rs @@ -0,0 +1,256 @@ +//! macOS update application -- mount DMG, swap .app bundle, relaunch. +//! +//! Generates a bash helper script that: +//! 1. Waits for the current process to exit +//! 2. Backs up the existing .app bundle +//! 3. Mounts the DMG and copies the new .app +//! 4. Restores the backup on failure +//! 5. Relaunches the application + +use anyhow::{Context, Result}; +use std::path::Path; +use tracing::info; + +/// Generate a bash script that performs the macOS update. +/// +/// The script waits for the running process (`pid`) to exit, mounts the DMG, +/// swaps the .app bundle with a backup/restore safety net, and relaunches. +pub fn generate_update_script(dmg_path: &Path, current_app_path: &Path, pid: u32) -> String { + let dmg = dmg_path.display(); + let app = current_app_path.display(); + + // Derive the .app name from the path (e.g. "Codirigent.app") + let app_name = current_app_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Codirigent.app"); + + format!( + r#"#!/bin/bash +set -euo pipefail + +APP_PID={pid} +DMG_PATH="{dmg}" +APP_PATH="{app}" +APP_NAME="{app_name}" +APP_PARENT="$(dirname "$APP_PATH")" +BACKUP_PATH="${{APP_PARENT}}/.codirigent-update-backup" + +# --- Wait for the application to exit --- +echo "Waiting for PID $APP_PID to exit..." +while kill -0 "$APP_PID" 2>/dev/null; do + sleep 0.5 +done +echo "Process $APP_PID has exited." + +# --- Create a unique mount point --- +MOUNT_POINT="$(mktemp -d "${{TMPDIR:-/tmp}}/codirigent-mount.XXXXXX")" + +cleanup() {{ + # Unmount the DMG if mounted + if [ -d "$MOUNT_POINT" ]; then + hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null || true + rmdir "$MOUNT_POINT" 2>/dev/null || true + fi + # Clean up the DMG file + rm -f "$DMG_PATH" +}} +trap cleanup EXIT + +# --- Back up the current .app --- +echo "Backing up $APP_PATH to $BACKUP_PATH..." +if ! cp -Rp "$APP_PATH" "$BACKUP_PATH"; then + echo "ERROR: Failed to create backup. Aborting update." + open "$APP_PATH" + exit 1 +fi + +# --- Mount the DMG --- +echo "Mounting $DMG_PATH..." +if ! hdiutil attach "$DMG_PATH" -mountpoint "$MOUNT_POINT" -nobrowse -quiet; then + echo "ERROR: Failed to mount DMG. Restoring backup..." + rm -rf "$APP_PATH" + mv "$BACKUP_PATH" "$APP_PATH" + open "$APP_PATH" + exit 1 +fi + +# --- Copy the new .app --- +echo "Installing new version..." +if ! (rm -rf "$APP_PATH" && cp -Rp "$MOUNT_POINT/$APP_NAME" "$APP_PATH"); then + echo "ERROR: Failed to copy new app. Restoring backup..." + rm -rf "$APP_PATH" + mv "$BACKUP_PATH" "$APP_PATH" + open "$APP_PATH" + exit 1 +fi + +# --- Success: remove backup --- +rm -rf "$BACKUP_PATH" + +echo "Update applied successfully." + +# --- Relaunch --- +open "$APP_PATH" +"# + ) +} + +/// Apply the update on macOS by writing and launching a helper script. +/// +/// Writes the update script to the cache directory and launches it as a +/// detached process. The script will wait for the current app to exit before +/// performing the swap. +pub fn apply_update(artifact_path: &Path, current_app_path: &Path, current_pid: u32) -> Result<()> { + let cache = crate::state::cache_dir().context("Could not determine cache directory")?; + std::fs::create_dir_all(&cache) + .with_context(|| format!("Failed to create cache directory: {}", cache.display()))?; + + let script_path = cache.join("codirigent-update.sh"); + let script = generate_update_script(artifact_path, current_app_path, current_pid); + + std::fs::write(&script_path, &script) + .with_context(|| format!("Failed to write update script: {}", script_path.display()))?; + + // Make the script executable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&script_path, perms) + .with_context(|| format!("Failed to set permissions on {}", script_path.display()))?; + } + + info!(script = %script_path.display(), "Launching update script"); + + std::process::Command::new("bash") + .arg(&script_path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .context("Failed to launch update script")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn script_contains_pid() { + let script = generate_update_script( + &PathBuf::from("/tmp/Codirigent-0.2.0.dmg"), + &PathBuf::from("/Applications/Codirigent.app"), + 12345, + ); + assert!( + script.contains("APP_PID=12345"), + "Script should contain the PID" + ); + } + + #[test] + fn script_contains_correct_paths() { + let dmg = PathBuf::from("/tmp/downloads/Codirigent-0.2.0.dmg"); + let app = PathBuf::from("/Applications/Codirigent.app"); + let script = generate_update_script(&dmg, &app, 99999); + + assert!( + script.contains("/tmp/downloads/Codirigent-0.2.0.dmg"), + "Script should contain the DMG path" + ); + assert!( + script.contains("/Applications/Codirigent.app"), + "Script should contain the app path" + ); + } + + #[test] + fn script_has_backup_restore_logic() { + let script = generate_update_script( + &PathBuf::from("/tmp/Codirigent.dmg"), + &PathBuf::from("/Applications/Codirigent.app"), + 1000, + ); + + assert!( + script.contains("BACKUP_PATH"), + "Script should define a backup path" + ); + assert!( + script.contains("cp -Rp \"$APP_PATH\" \"$BACKUP_PATH\""), + "Script should back up the current app" + ); + assert!( + script.contains("mv \"$BACKUP_PATH\" \"$APP_PATH\""), + "Script should restore backup on failure" + ); + } + + #[test] + fn script_has_relaunch_command() { + let script = generate_update_script( + &PathBuf::from("/tmp/Codirigent.dmg"), + &PathBuf::from("/Applications/Codirigent.app"), + 1000, + ); + + assert!( + script.contains("open \"$APP_PATH\""), + "Script should relaunch the app with 'open'" + ); + } + + #[test] + fn script_mounts_dmg_with_unique_mount_point() { + let script = generate_update_script( + &PathBuf::from("/tmp/Codirigent.dmg"), + &PathBuf::from("/Applications/Codirigent.app"), + 1000, + ); + + assert!( + script.contains("mktemp -d \"${TMPDIR:-/tmp}/codirigent-mount.XXXXXX\""), + "Script should create unique mount point with mktemp" + ); + assert!( + script.contains("hdiutil attach"), + "Script should mount the DMG" + ); + assert!( + script.contains("hdiutil detach"), + "Script should unmount the DMG on cleanup" + ); + } + + #[test] + fn script_waits_for_process_exit() { + let script = generate_update_script( + &PathBuf::from("/tmp/Codirigent.dmg"), + &PathBuf::from("/Applications/Codirigent.app"), + 42, + ); + + assert!( + script.contains("kill -0 \"$APP_PID\""), + "Script should poll for process exit using kill -0" + ); + } + + #[test] + fn script_cleans_up_dmg() { + let script = generate_update_script( + &PathBuf::from("/tmp/Codirigent.dmg"), + &PathBuf::from("/Applications/Codirigent.app"), + 1000, + ); + + assert!( + script.contains("rm -f \"$DMG_PATH\""), + "Script should clean up the DMG file" + ); + } +} diff --git a/crates/codirigent-updater/src/platform/mod.rs b/crates/codirigent-updater/src/platform/mod.rs new file mode 100644 index 00000000..3c918e52 --- /dev/null +++ b/crates/codirigent-updater/src/platform/mod.rs @@ -0,0 +1,61 @@ +//! Platform-specific update application. +//! +//! Dispatches to macOS or Windows implementations via `#[cfg(target_os)]`. +//! Both modules are always compiled so that their unit tests (which only test +//! script generation, not execution) run on every platform. + +pub mod macos; +pub mod windows; + +use anyhow::Result; +use std::path::Path; + +/// Apply a staged update. Platform-specific. +pub fn apply_update(artifact_path: &Path, current_pid: u32) -> Result<()> { + #[cfg(target_os = "macos")] + return macos::apply_update(artifact_path, &detect_app_path()?, current_pid); + + #[cfg(target_os = "windows")] + { + let exe = std::env::current_exe()?; + let exe_name = exe + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("codirigent.exe"); + windows::apply_update(artifact_path, &detect_app_path()?, current_pid, exe_name) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let _ = (artifact_path, current_pid); + anyhow::bail!("Auto-update is not supported on this platform") + } +} + +/// Detect the current application path. +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn detect_app_path() -> Result { + let exe = std::env::current_exe()?; + + #[cfg(target_os = "macos")] + { + let mut path = exe.as_path(); + while let Some(parent) = path.parent() { + if path.extension().map(|e| e == "app").unwrap_or(false) { + return Ok(path.to_path_buf()); + } + path = parent; + } + anyhow::bail!( + "Could not find .app bundle from exe path: {}", + exe.display() + ) + } + + #[cfg(target_os = "windows")] + { + exe.parent() + .map(|p| p.to_path_buf()) + .ok_or_else(|| anyhow::anyhow!("Could not determine install directory")) + } +} diff --git a/crates/codirigent-updater/src/platform/windows.rs b/crates/codirigent-updater/src/platform/windows.rs new file mode 100644 index 00000000..341b90a3 --- /dev/null +++ b/crates/codirigent-updater/src/platform/windows.rs @@ -0,0 +1,201 @@ +//! Windows update application -- run MSI installer via msiexec. +//! +//! Generates a batch helper script that: +//! 1. Waits for the current process to exit +//! 2. Runs `msiexec /passive /i` on the MSI +//! 3. Cleans up the MSI file +//! 4. Relaunches the application + +use anyhow::{Context, Result}; +use std::path::Path; +use tracing::info; + +/// Generate a batch script that performs the Windows update. +/// +/// The script waits for the running process (`pid`) to exit using `tasklist`, +/// runs the MSI installer in passive mode, then relaunches the application. +pub fn generate_update_script( + msi_path: &Path, + install_path: &Path, + pid: u32, + exe_name: &str, +) -> String { + let msi = msi_path.display(); + let install = install_path.display(); + + format!( + r#"@echo off +setlocal + +set "APP_PID={pid}" +set "MSI_PATH={msi}" +set "INSTALL_PATH={install}" + +REM --- Wait for the application to exit --- +echo Waiting for PID %APP_PID% to exit... +:wait_loop +tasklist /FI "PID eq %APP_PID%" 2>NUL | find /I "%APP_PID%" >NUL +if not errorlevel 1 ( + timeout /t 1 /nobreak >NUL + goto wait_loop +) +echo Process %APP_PID% has exited. + +REM --- Run the MSI installer --- +echo Installing update... +msiexec /passive /i "%MSI_PATH%" +if errorlevel 1 ( + echo ERROR: MSI installation failed with exit code %ERRORLEVEL%. + del /f "%MSI_PATH%" 2>NUL + exit /b 1 +) + +REM --- Clean up MSI --- +del /f "%MSI_PATH%" 2>NUL + +echo Update applied successfully. + +REM --- Relaunch --- +start "" "%INSTALL_PATH%\{exe_name}" +"# + ) +} + +/// Apply the update on Windows by writing and launching a helper batch script. +/// +/// Writes the update script to the cache directory and launches it as a +/// detached process. The script will wait for the current app to exit before +/// running the MSI installer. +pub fn apply_update( + artifact_path: &Path, + install_path: &Path, + current_pid: u32, + exe_name: &str, +) -> Result<()> { + let cache = crate::state::cache_dir().context("Could not determine cache directory")?; + std::fs::create_dir_all(&cache) + .with_context(|| format!("Failed to create cache directory: {}", cache.display()))?; + + let script_path = cache.join("codirigent-update.bat"); + let script = generate_update_script(artifact_path, install_path, current_pid, exe_name); + + std::fs::write(&script_path, &script) + .with_context(|| format!("Failed to write update script: {}", script_path.display()))?; + + info!(script = %script_path.display(), "Launching update script"); + + std::process::Command::new("cmd") + .args(["/C", "start", "/B", ""]) + .arg(&script_path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .context("Failed to launch update script")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn script_contains_pid() { + let script = generate_update_script( + &PathBuf::from("C:\\Users\\user\\Downloads\\Codirigent-0.2.0.msi"), + &PathBuf::from("C:\\Program Files\\Codirigent"), + 12345, + "codirigent.exe", + ); + assert!( + script.contains("APP_PID=12345"), + "Script should contain the PID" + ); + } + + #[test] + fn script_contains_correct_paths() { + let msi = PathBuf::from("C:\\temp\\Codirigent-0.2.0.msi"); + let install = PathBuf::from("C:\\Program Files\\Codirigent"); + let script = generate_update_script(&msi, &install, 99999, "codirigent.exe"); + + assert!( + script.contains("C:\\temp\\Codirigent-0.2.0.msi"), + "Script should contain the MSI path" + ); + assert!( + script.contains("C:\\Program Files\\Codirigent"), + "Script should contain the install path" + ); + } + + #[test] + fn script_has_wait_loop() { + let script = generate_update_script( + &PathBuf::from("C:\\temp\\update.msi"), + &PathBuf::from("C:\\Program Files\\Codirigent"), + 1000, + "codirigent.exe", + ); + + assert!( + script.contains("tasklist"), + "Script should use tasklist to poll for process exit" + ); + assert!( + script.contains(":wait_loop"), + "Script should have a wait loop label" + ); + assert!( + script.contains("goto wait_loop"), + "Script should loop back to wait" + ); + } + + #[test] + fn script_runs_msiexec() { + let script = generate_update_script( + &PathBuf::from("C:\\temp\\update.msi"), + &PathBuf::from("C:\\Program Files\\Codirigent"), + 1000, + "codirigent.exe", + ); + + assert!( + script.contains("msiexec /passive /i"), + "Script should run msiexec in passive mode" + ); + } + + #[test] + fn script_has_relaunch_command() { + let script = generate_update_script( + &PathBuf::from("C:\\temp\\update.msi"), + &PathBuf::from("C:\\Program Files\\Codirigent"), + 1000, + "codirigent.exe", + ); + + assert!( + script.contains(r#"start "" "%INSTALL_PATH%\codirigent.exe""#), + "Script should relaunch the app" + ); + } + + #[test] + fn script_cleans_up_msi() { + let script = generate_update_script( + &PathBuf::from("C:\\temp\\update.msi"), + &PathBuf::from("C:\\Program Files\\Codirigent"), + 1000, + "codirigent.exe", + ); + + assert!( + script.contains(r#"del /f "%MSI_PATH%""#), + "Script should delete the MSI after install" + ); + } +} diff --git a/crates/codirigent-updater/src/service.rs b/crates/codirigent-updater/src/service.rs new file mode 100644 index 00000000..2464ea6a --- /dev/null +++ b/crates/codirigent-updater/src/service.rs @@ -0,0 +1,642 @@ +//! Update state machine and orchestration. +//! +//! The [`UpdateService`] manages the full lifecycle of an update: checking for +//! newer releases, downloading and verifying artifacts, and applying them. +//! State transitions are communicated via the [`EventBus`]. + +use crate::checker::{self, UpdateInfo}; +use crate::downloader; +use crate::state::{self, StagedUpdateState}; +use codirigent_core::{CodirigentEvent, EventBus}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +/// Interval between automatic update checks (24 hours). +const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Current state of the update process. +#[derive(Debug, Clone, PartialEq)] +pub enum UpdateState { + /// No update activity. + Idle, + /// Checking GitHub for a new release. + Checking, + /// A newer version is available. + UpdateAvailable(UpdateInfo), + /// Downloading the update artifact. + Downloading { + /// Download progress percentage (0-100). + percent: u8, + }, + /// Download complete, ready to apply. + Staged(StagedUpdate), + /// Applying the update (app is about to quit). + Applying, +} + +/// A downloaded update ready to apply. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StagedUpdate { + /// The version of the staged update. + pub version: semver::Version, + /// Path to the downloaded artifact. + pub artifact_path: PathBuf, + /// URL to the GitHub release page. + pub release_url: String, + /// Expected SHA256 hash of the artifact (for re-verification before apply). + pub expected_sha256: String, +} + +/// Orchestrates update checking, downloading, and applying. +pub struct UpdateService { + current_version: semver::Version, + event_bus: Arc, + state: Arc>, + client: reqwest::Client, + download_cancel: Arc>, +} + +impl UpdateService { + /// Create a new `UpdateService`. + /// + /// # Arguments + /// + /// * `current_version` - The currently running version string (e.g. "0.1.0"). + /// * `event_bus` - The event bus for publishing update events. + /// + /// # Errors + /// + /// Returns an error if `current_version` is not valid semver. + pub fn new(current_version: &str, event_bus: Arc) -> anyhow::Result { + let version: semver::Version = current_version + .parse() + .map_err(|e| anyhow::anyhow!("Invalid current version '{}': {}", current_version, e))?; + + Ok(Self { + current_version: version, + event_bus, + state: Arc::new(Mutex::new(UpdateState::Idle)), + client: reqwest::Client::new(), + download_cancel: Arc::new(Mutex::new(CancellationToken::new())), + }) + } + + /// Get the current update state. + pub fn state(&self) -> UpdateState { + self.state.lock().unwrap_or_else(|p| p.into_inner()).clone() + } + + /// Start a background update check. + /// + /// Spawns a tokio task that: + /// 1. Loads persistent state + /// 2. Detects post-update launch (version changed since last known) + /// 3. Handles stale staged updates (artifact missing, version already applied) + /// 4. Restores a valid staged update if present + /// 5. Checks for updates if 24h have elapsed since last check + /// 6. Schedules periodic checks every 24h + pub fn start_background_check(&self) { + let version = self.current_version.clone(); + let client = self.client.clone(); + let event_bus = self.event_bus.clone(); + let state = self.state.clone(); + + tokio::spawn(async move { + // 1. Load persistent state. + let mut persistent = match state::load_state() { + Ok(s) => s, + Err(e) => { + warn!("Failed to load update persistent state: {e}"); + state::UpdatePersistentState::default() + } + }; + + // 2. Detect post-update launch: if the running version differs from + // last_known_version, the user just updated. + if let Some(ref last_known) = persistent.last_known_version { + if last_known != &version.to_string() { + info!( + last_known = %last_known, + current = %version, + "Post-update launch detected — clearing staged update" + ); + // Clear any staged update from the old version. + if let Some(ref staged) = persistent.staged_update { + if let Err(e) = std::fs::remove_file(&staged.artifact_path) { + warn!("Failed to remove old staged artifact: {e}"); + } + } + persistent.staged_update = None; + persistent.last_known_version = Some(version.to_string()); + if let Err(e) = state::save_state(&persistent) { + warn!("Failed to save update state after post-update clear: {e}"); + } + } + } else { + // First launch — record the current version. + persistent.last_known_version = Some(version.to_string()); + if let Err(e) = state::save_state(&persistent) { + warn!("Failed to save initial version: {e}"); + } + } + + // 3. Handle stale staged update. + if let Some(ref staged) = persistent.staged_update { + let staged_version: Option = staged.version.parse().ok(); + + if !staged.artifact_path.exists() { + // Artifact is gone — clear staged state. + info!( + path = %staged.artifact_path.display(), + "Staged artifact missing — clearing stale staged update" + ); + persistent.staged_update = None; + if let Err(e) = state::save_state(&persistent) { + warn!("Failed to save state after clearing missing artifact: {e}"); + } + } else if staged_version.as_ref() == Some(&version) { + // Already running the staged version — clear it. + info!( + version = %version, + "Already running staged version — clearing and deleting artifact" + ); + if let Err(e) = std::fs::remove_file(&staged.artifact_path) { + warn!("Failed to remove same-version staged artifact: {e}"); + } + persistent.staged_update = None; + if let Err(e) = state::save_state(&persistent) { + warn!("Failed to save state after clearing same-version staged: {e}"); + } + } + } + + // 4. Auto-apply valid staged update on startup. + // The user already acknowledged this update (clicked "Later" in a + // previous session), so apply it now while no sessions are active. + // The helper script waits for this process to exit, swaps the app, + // and relaunches. + if let Some(staged) = persistent.staged_update.clone() { + if let Ok(staged_ver) = staged.version.parse::() { + if staged.artifact_path.exists() && staged_ver > version { + info!( + staged_version = %staged_ver, + artifact = %staged.artifact_path.display(), + "Auto-applying staged update on startup" + ); + + // Verify SHA256 if available. + let mut verified = true; + if staged.expected_sha256.is_empty() { + warn!( + "No SHA256 hash stored for staged artifact — skipping verification" + ); + } else { + match downloader::verify_sha256( + &staged.artifact_path, + &staged.expected_sha256, + ) { + Ok(true) => {} + Ok(false) => { + warn!("SHA256 mismatch on staged artifact — clearing"); + if let Err(e) = std::fs::remove_file(&staged.artifact_path) { + warn!("Failed to remove mismatched artifact: {e}"); + } + persistent.staged_update = None; + if let Err(e) = state::save_state(&persistent) { + warn!("Failed to save state after SHA256 mismatch: {e}"); + } + verified = false; + } + Err(e) => { + warn!( + "SHA256 verification error: {e} — clearing staged update" + ); + if let Err(e) = std::fs::remove_file(&staged.artifact_path) { + warn!("Failed to remove unverifiable artifact: {e}"); + } + persistent.staged_update = None; + if let Err(e) = state::save_state(&persistent) { + warn!("Failed to save state after verification error: {e}"); + } + verified = false; + } + } + } + + // If staged update is still valid after verification, apply it. + if verified { + let pid = std::process::id(); + match crate::platform::apply_update(&staged.artifact_path, pid) { + Ok(()) => { + *state.lock().unwrap_or_else(|p| p.into_inner()) = + UpdateState::Applying; + event_bus.publish(CodirigentEvent::UpdateApplyingOnStartup); + return; + } + Err(e) => { + warn!("Failed to auto-apply staged update: {e}"); + if let Err(e) = std::fs::remove_file(&staged.artifact_path) { + warn!("Failed to remove artifact after apply failure: {e}"); + } + persistent.staged_update = None; + if let Err(e) = state::save_state(&persistent) { + warn!("Failed to save state after apply failure: {e}"); + } + } + } + } + } + } + } + + // 5. Check if enough time has elapsed since the last check. + let should_check_now = match persistent.last_check_timestamp { + Some(last) => { + let elapsed = chrono::Utc::now().signed_duration_since(last); + elapsed.num_seconds() >= CHECK_INTERVAL.as_secs() as i64 + } + None => true, // Never checked before. + }; + + if should_check_now { + do_check(&version, &client, &event_bus, &state).await; + } + + // 6. Schedule periodic checks every 24h. + let mut interval = tokio::time::interval(CHECK_INTERVAL); + // The first tick fires immediately — skip it since we just checked. + interval.tick().await; + + loop { + interval.tick().await; + do_check(&version, &client, &event_bus, &state).await; + } + }); + } + + /// Start downloading the available update. + /// + /// Only works when the current state is `UpdateAvailable`. Spawns a tokio + /// task that downloads and verifies the artifact, transitions through + /// `Downloading` to `Staged`, and publishes appropriate events. + pub fn start_download(&self) { + let state = self.state.clone(); + let event_bus = self.event_bus.clone(); + let client = self.client.clone(); + let current_version = self.current_version.clone(); + let cancel_store = self.download_cancel.clone(); + + // Create a fresh cancellation token. + let token = CancellationToken::new(); + *cancel_store.lock().unwrap_or_else(|p| p.into_inner()) = token.clone(); + + tokio::spawn(async move { + // Extract UpdateInfo — only proceed from UpdateAvailable. + let info = { + let guard = state.lock().unwrap_or_else(|p| p.into_inner()); + match &*guard { + UpdateState::UpdateAvailable(info) => info.clone(), + other => { + warn!( + state = ?other, + "start_download called in wrong state — expected UpdateAvailable" + ); + return; + } + } + }; + + // Transition to Downloading. + *state.lock().unwrap_or_else(|p| p.into_inner()) = + UpdateState::Downloading { percent: 0 }; + + // Determine download directory. + let dest_dir = match state::cache_dir() { + Some(d) => d.join("updates"), + None => { + let msg = "Could not determine cache directory for download"; + error!(msg); + event_bus.publish(CodirigentEvent::UpdateFailed { + error: msg.to_string(), + }); + *state.lock().unwrap_or_else(|p| p.into_inner()) = + UpdateState::UpdateAvailable(info); + return; + } + }; + + // Clean up old staged artifacts in the download directory. + if dest_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&dest_dir) { + for entry in entries.flatten() { + if let Err(e) = std::fs::remove_file(entry.path()) { + warn!("Failed to clean up old artifact {:?}: {e}", entry.path()); + } + } + } + } + + // Progress callback — publishes events and updates state. + let state_for_progress = state.clone(); + let bus_for_progress = event_bus.clone(); + let on_progress = move |percent: u8| { + *state_for_progress.lock().unwrap_or_else(|p| p.into_inner()) = + UpdateState::Downloading { percent }; + bus_for_progress.publish(CodirigentEvent::UpdateDownloadProgress { percent }); + }; + + let user_agent = format!("codirigent/{current_version}"); + + // Download and verify, respecting cancellation. + let result = tokio::select! { + _ = token.cancelled() => { + info!("Download cancelled by user"); + *state.lock().unwrap_or_else(|p| p.into_inner()) = UpdateState::UpdateAvailable(info); + return; + } + result = downloader::download_and_verify( + &client, + &info.asset_url, + &info.checksum_url, + &dest_dir, + &user_agent, + on_progress, + ) => result + }; + + match result { + Ok((artifact_path, expected_sha256)) => { + let staged = StagedUpdate { + version: info.version.clone(), + artifact_path: artifact_path.clone(), + release_url: info.release_url.clone(), + expected_sha256, + }; + + // Persist staged update for crash recovery. + let mut persistent = state::load_state().unwrap_or_default(); + persistent.staged_update = Some(StagedUpdateState { + version: info.version.to_string(), + artifact_path, + release_url: info.release_url.clone(), + expected_sha256: staged.expected_sha256.clone(), + }); + if let Err(e) = state::save_state(&persistent) { + warn!("Failed to persist staged update: {e}"); + } + + *state.lock().unwrap_or_else(|p| p.into_inner()) = UpdateState::Staged(staged); + event_bus.publish(CodirigentEvent::UpdateReadyToApply); + + info!( + version = %info.version, + "Update downloaded and staged successfully" + ); + } + Err(e) => { + error!("Download failed: {e:#}"); + event_bus.publish(CodirigentEvent::UpdateFailed { + error: format!("{e:#}"), + }); + *state.lock().unwrap_or_else(|p| p.into_inner()) = + UpdateState::UpdateAvailable(info); + } + } + }); + } + + /// Apply a staged update. + /// + /// Only works from the `Staged` state. Re-verifies the artifact SHA256, + /// then delegates to the platform-specific apply logic. + /// + /// # Errors + /// + /// Returns an error if the state is not `Staged`, the SHA256 verification + /// fails, or the platform apply fails. + pub fn apply(&self) -> anyhow::Result<()> { + let staged = { + let guard = self.state.lock().unwrap_or_else(|p| p.into_inner()); + match &*guard { + UpdateState::Staged(s) => s.clone(), + other => { + anyhow::bail!( + "Cannot apply update: expected Staged state, got {:?}", + std::mem::discriminant(other) + ); + } + } + }; + + // Re-verify SHA256 before applying (unless the hash is empty, which + // means it was restored from persistent state without hash). + if !staged.expected_sha256.is_empty() { + let valid = downloader::verify_sha256(&staged.artifact_path, &staged.expected_sha256) + .map_err(|e| anyhow::anyhow!("SHA256 re-verification failed: {e:#}"))?; + + if !valid { + // Delete the corrupt artifact and clear state. + if let Err(e) = std::fs::remove_file(&staged.artifact_path) { + warn!("Failed to remove corrupt artifact: {e}"); + } + *self.state.lock().unwrap_or_else(|p| p.into_inner()) = UpdateState::Idle; + anyhow::bail!("SHA256 mismatch on re-verification — artifact may be corrupt"); + } + } + + // Transition to Applying. + *self.state.lock().unwrap_or_else(|p| p.into_inner()) = UpdateState::Applying; + + let current_pid = std::process::id(); + crate::platform::apply_update(&staged.artifact_path, current_pid)?; + + Ok(()) + } + + /// Cancel an in-progress download. + /// + /// If a download is running, cancels it via the cancellation token and + /// transitions the state back to `UpdateAvailable`. + pub fn cancel_download(&self) { + let token = self + .download_cancel + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone(); + token.cancel(); + // The download task will handle the state transition when it observes + // the cancellation. + } +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/// Perform a single update check against the GitHub Releases API. +/// +/// On success: updates state to `UpdateAvailable` and publishes event. +/// On failure: publishes `UpdateFailed` and returns state to `Idle`. +/// Saves `last_check_timestamp` on successful API call regardless of result. +async fn do_check( + version: &semver::Version, + client: &reqwest::Client, + event_bus: &Arc, + state: &Arc>, +) { + info!("Checking for updates..."); + *state.lock().unwrap_or_else(|p| p.into_inner()) = UpdateState::Checking; + + match checker::check_for_update(version, client).await { + Ok(Some(info)) => { + info!( + new_version = %info.version, + "Update available" + ); + event_bus.publish(CodirigentEvent::UpdateAvailable { + version: info.version.to_string(), + release_url: info.release_url.clone(), + }); + *state.lock().unwrap_or_else(|p| p.into_inner()) = UpdateState::UpdateAvailable(info); + } + Ok(None) => { + info!("Already up to date"); + *state.lock().unwrap_or_else(|p| p.into_inner()) = UpdateState::Idle; + } + Err(e) => { + error!("Update check failed: {e:#}"); + event_bus.publish(CodirigentEvent::UpdateFailed { + error: format!("Update check failed: {e:#}"), + }); + *state.lock().unwrap_or_else(|p| p.into_inner()) = UpdateState::Idle; + } + } + + // Save the last check timestamp regardless of result. + let mut persistent = state::load_state().unwrap_or_default(); + persistent.last_check_timestamp = Some(chrono::Utc::now()); + if let Err(e) = state::save_state(&persistent) { + warn!("Failed to save last_check_timestamp: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codirigent_core::CodirigentEvent; + use std::sync::Mutex as StdMutex; + use tokio::sync::broadcast; + + /// A minimal EventBus implementation for testing. + struct TestEventBus { + tx: broadcast::Sender, + events: Arc>>, + } + + impl TestEventBus { + fn new() -> Self { + let (tx, _) = broadcast::channel(64); + Self { + tx, + events: Arc::new(StdMutex::new(Vec::new())), + } + } + + #[allow(dead_code)] + fn events(&self) -> Vec { + self.events + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + } + } + + impl EventBus for TestEventBus { + fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } + + fn publish(&self, event: CodirigentEvent) { + self.events + .lock() + .unwrap_or_else(|p| p.into_inner()) + .push(event.clone()); + let _ = self.tx.send(event); + } + } + + #[test] + fn new_parses_valid_version() { + let bus = Arc::new(TestEventBus::new()); + let svc = UpdateService::new("0.1.0", bus).unwrap(); + assert_eq!( + svc.current_version, + "0.1.0".parse::().unwrap() + ); + } + + #[test] + fn new_parses_prerelease_version() { + let bus = Arc::new(TestEventBus::new()); + let svc = UpdateService::new("0.1.0-alpha.1", bus).unwrap(); + assert_eq!( + svc.current_version, + "0.1.0-alpha.1".parse::().unwrap() + ); + } + + #[test] + fn new_rejects_invalid_version() { + let bus = Arc::new(TestEventBus::new()); + let result = UpdateService::new("not-a-version", bus); + assert!(result.is_err()); + } + + #[test] + fn initial_state_is_idle() { + let bus = Arc::new(TestEventBus::new()); + let svc = UpdateService::new("0.1.0", bus).unwrap(); + assert_eq!(svc.state(), UpdateState::Idle); + } + + #[test] + fn apply_rejects_non_staged_state() { + let bus = Arc::new(TestEventBus::new()); + let svc = UpdateService::new("0.1.0", bus).unwrap(); + let result = svc.apply(); + assert!(result.is_err()); + assert!( + format!("{}", result.unwrap_err()).contains("Staged"), + "Error should mention expected Staged state" + ); + } + + #[test] + fn cancel_download_does_not_panic_when_idle() { + let bus = Arc::new(TestEventBus::new()); + let svc = UpdateService::new("0.1.0", bus).unwrap(); + // Cancelling when nothing is downloading should not panic. + svc.cancel_download(); + } + + #[test] + fn state_clone_returns_current() { + let bus = Arc::new(TestEventBus::new()); + let svc = UpdateService::new("0.1.0", bus).unwrap(); + + // Manually set state to verify the accessor works. + let info = UpdateInfo { + version: "0.2.0".parse().unwrap(), + release_url: "https://example.com/release".to_string(), + asset_url: "https://example.com/asset.dmg".to_string(), + checksum_url: "https://example.com/checksums.txt".to_string(), + }; + *svc.state.lock().unwrap_or_else(|p| p.into_inner()) = + UpdateState::UpdateAvailable(info.clone()); + assert_eq!(svc.state(), UpdateState::UpdateAvailable(info)); + } +} diff --git a/crates/codirigent-updater/src/state.rs b/crates/codirigent-updater/src/state.rs new file mode 100644 index 00000000..4b9c280e --- /dev/null +++ b/crates/codirigent-updater/src/state.rs @@ -0,0 +1,240 @@ +//! Persistent state for the update checker. +//! +//! Tracks the last-known version, the last time we checked for updates, and +//! any staged (downloaded but not yet applied) update. State is stored as a +//! JSON file in the platform config directory so it survives restarts. + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Persistent state for the auto-updater. +/// +/// Serialized to `update-state.json` in the platform config directory. All +/// fields are optional so the file can evolve without breaking older installs. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct UpdatePersistentState { + /// The last version we know about (may be newer than the running version + /// if an update was found but not yet applied). + pub last_known_version: Option, + + /// Timestamp of the most recent successful check against the GitHub API. + pub last_check_timestamp: Option>, + + /// A downloaded update that is ready to apply on next restart. + pub staged_update: Option, +} + +/// Metadata for a staged (downloaded) update artifact. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct StagedUpdateState { + /// Semantic version string of the staged release. + pub version: String, + + /// Path to the downloaded artifact on disk. + pub artifact_path: PathBuf, + + /// URL to the GitHub release page (for user-facing links). + pub release_url: String, + + /// Expected SHA256 hash of the artifact (hex-encoded). + /// + /// Persisted so we can re-verify the artifact before applying, even after + /// a restart. Empty string means the hash was not recorded (e.g. from an + /// older state file format). + pub expected_sha256: String, +} + +impl Default for StagedUpdateState { + fn default() -> Self { + Self { + version: String::new(), + artifact_path: PathBuf::new(), + release_url: String::new(), + expected_sha256: String::new(), + } + } +} + +/// Returns the default path for `update-state.json`. +/// +/// Uses `dirs::config_dir()` which maps to: +/// - macOS: `~/Library/Application Support/codirigent/update-state.json` +/// - Windows: `{FOLDERID_RoamingAppData}\codirigent\update-state.json` +/// - Linux: `$XDG_CONFIG_HOME/codirigent/update-state.json` +pub fn state_file_path() -> Option { + dirs::config_dir().map(|d| d.join("codirigent").join("update-state.json")) +} + +/// Returns the platform cache directory for Codirigent. +/// +/// Used for storing downloaded update artifacts. Maps to: +/// - macOS: `~/Library/Caches/codirigent` +/// - Windows: `{FOLDERID_LocalAppData}\codirigent` +/// - Linux: `$XDG_CACHE_HOME/codirigent` +pub fn cache_dir() -> Option { + dirs::cache_dir().map(|d| d.join("codirigent")) +} + +/// Load the update state from the default location. +/// +/// Returns `Ok(Default)` if the file does not exist. +pub fn load_state() -> Result { + let path = state_file_path().context("Could not determine config directory")?; + load_state_from(&path) +} + +/// Save the update state to the default location. +pub fn save_state(state: &UpdatePersistentState) -> Result<()> { + let path = state_file_path().context("Could not determine config directory")?; + save_state_to(state, &path) +} + +/// Load the update state from an explicit path. +/// +/// Returns `Ok(Default)` if the file does not exist. +pub fn load_state_from(path: &Path) -> Result { + if !path.exists() { + return Ok(UpdatePersistentState::default()); + } + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", path.display())) +} + +/// Save the update state to an explicit path. +/// +/// Uses an atomic write pattern (PID-scoped temp file + rename) to avoid +/// corruption if two instances write simultaneously. +pub fn save_state_to(state: &UpdatePersistentState, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {}", parent.display()))?; + } + + let json = serde_json::to_string_pretty(state).context("Failed to serialize update state")?; + + // Atomic write: write to a PID-scoped temp file then rename. + // Using the process ID prevents two concurrent Codirigent instances from + // clobbering each other's temp file during simultaneous startup. + let tmp = path.with_file_name(format!(".update-state-{}.tmp", std::process::id())); + std::fs::write(&tmp, &json).with_context(|| format!("Failed to write {}", tmp.display()))?; + std::fs::rename(&tmp, path) + .with_context(|| format!("Failed to rename {} to {}", tmp.display(), path.display()))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn default_state_round_trip() { + let state = UpdatePersistentState::default(); + let json = serde_json::to_string(&state).expect("serialize default"); + let restored: UpdatePersistentState = + serde_json::from_str(&json).expect("deserialize default"); + assert_eq!(state, restored); + } + + #[test] + fn full_state_round_trip() { + let state = UpdatePersistentState { + last_known_version: Some("0.2.0".to_string()), + last_check_timestamp: Some(Utc.with_ymd_and_hms(2026, 3, 15, 10, 30, 0).unwrap()), + staged_update: Some(StagedUpdateState { + version: "0.2.0".to_string(), + artifact_path: PathBuf::from("/tmp/codirigent-0.2.0.dmg"), + release_url: "https://github.com/oso95/Codirigent/releases/tag/v0.2.0".to_string(), + expected_sha256: "abc123def456".to_string(), + }), + }; + let json = serde_json::to_string_pretty(&state).expect("serialize full"); + let restored: UpdatePersistentState = + serde_json::from_str(&json).expect("deserialize full"); + assert_eq!(state, restored); + } + + #[test] + fn load_save_to_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("update-state.json"); + + let state = UpdatePersistentState { + last_known_version: Some("1.0.0".to_string()), + last_check_timestamp: Some(Utc::now()), + staged_update: None, + }; + + save_state_to(&state, &path).expect("save"); + let loaded = load_state_from(&path).expect("load"); + assert_eq!(state, loaded); + } + + #[test] + fn load_missing_file_returns_default() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("nonexistent.json"); + + let loaded = load_state_from(&path).expect("load missing"); + assert_eq!(loaded, UpdatePersistentState::default()); + } + + #[test] + fn state_file_path_returns_some() { + // On macOS, Windows, and Linux with a home directory, this should return Some. + let path = state_file_path(); + assert!( + path.is_some(), + "state_file_path() should return Some on supported platforms" + ); + let path = path.unwrap(); + assert!(path.ends_with("codirigent/update-state.json")); + } + + #[test] + fn cache_dir_returns_some() { + let dir = cache_dir(); + assert!( + dir.is_some(), + "cache_dir() should return Some on supported platforms" + ); + let dir = dir.unwrap(); + assert!(dir.ends_with("codirigent")); + } + + #[test] + fn save_creates_parent_directories() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir + .path() + .join("nested") + .join("deep") + .join("update-state.json"); + + let state = UpdatePersistentState::default(); + save_state_to(&state, &path).expect("save with nested dirs"); + assert!(path.exists()); + } + + #[test] + fn deserialize_with_missing_fields() { + // Simulate an older file that only has `last_known_version`. + let json = r#"{"last_known_version": "0.1.0"}"#; + let state: UpdatePersistentState = serde_json::from_str(json).expect("partial parse"); + assert_eq!(state.last_known_version, Some("0.1.0".to_string())); + assert_eq!(state.last_check_timestamp, None); + assert_eq!(state.staged_update, None); + } + + #[test] + fn deserialize_empty_object() { + let state: UpdatePersistentState = serde_json::from_str("{}").expect("empty object"); + assert_eq!(state, UpdatePersistentState::default()); + } +} diff --git a/docs/ghostty-theme-registry-plan.md b/docs/ghostty-theme-registry-plan.md new file mode 100644 index 00000000..ab40bc2d --- /dev/null +++ b/docs/ghostty-theme-registry-plan.md @@ -0,0 +1,546 @@ +# Ghostty-Style Theme Registry Plan + +Implementation plan for expanding Codirigent's theme system from a built-in +`dark/light` toggle into a registry-backed theme model with custom theme files, +runtime theme IDs, and terminal palette behavior that can scale toward a +Ghostty-style theme experience. + +This document is intentionally written before code changes. It is the working +plan for the branch `feat/ghostty-theme-registry`. + +--- + +## Execution Summary + +This branch is now complete. The implementation landed as the planned task +series with small reviewable commits: + +1. `ced6530` `Add theme registry conversion backbone` +2. `d2c0be0` `Apply saved theme IDs during settings load` +3. `03d208f` `Make settings theme picker registry-driven` +4. `f632676` `Load custom themes during settings startup` +5. `4df2b36` `Apply selection text color in terminal rendering` +6. `eb020b1` `Update lockfile for theme rendering dependency` + +Final outcome: + +- theme selection is registry-based instead of hardcoded `dark/light` +- `appearance.theme` now round-trips as a durable theme ID +- startup/settings load resolves and applies saved theme IDs through one path +- custom themes load from the user config `themes/` directory off the UI thread +- terminal fg/bg/cursor/selection/ANSI palette are all on the active theme path +- terminal selection foreground is now actually rendered, not just stored in the schema + +--- + +## Purpose + +Codirigent already has the terminal rendering primitives needed for richer +themes, but the application still behaves like a two-theme product: + +- the runtime UI uses `CodirigentTheme` +- the settings UI only exposes `dark` and `light` +- the saved setting is treated like a boolean mode rather than a durable + registry theme ID +- custom theme loading infrastructure exists separately but is not wired into + app startup or live theme application + +The goal of this task series is to make themes a first-class product feature +instead of a hardcoded toggle. + +--- + +## Problem Statement + +The current implementation has four structural gaps: + +1. **Two parallel theme models** + - `crates/codirigent-ui/src/theme.rs` defines the runtime theme actually used + by UI and terminal rendering. + - `crates/codirigent-ui/src/theme_config.rs` and + `crates/codirigent-ui/src/theme_manager.rs` define a serializable theme + model and registry, but they are not the active runtime path. + +2. **Theme selection is hardcoded** + - Settings only present `dark` and `light`. + - Theme switching constructs `CodirigentTheme::dark()` or + `CodirigentTheme::light()` directly. + +3. **Saved theme identity is not durable** + - `appearance.theme` is stored as a `String`, but the settings page rebuild + currently infers the value from current background lightness instead of + preserving the active theme ID. + +4. **Load/apply path is incomplete** + - User settings are loaded and cached, but the selected theme is not treated + as a registry-resolved startup input. + +--- + +## Current Architecture Inventory + +### Runtime Theme Path + +- `crates/codirigent-ui/src/theme.rs` + - owns `CodirigentTheme` + - contains UI colors, terminal colors, typography, spacing + - contains ANSI 16-color palette and 256-color indexed conversion + +- `crates/codirigent-ui/src/terminal_colors.rs` + - maps terminal named/indexed/spec colors into runtime theme colors + +- `crates/codirigent-ui/src/terminal_view.rs` + - caches terminal bg/fg from `CodirigentTheme` + - updates terminal runtime when theme changes + +- `crates/codirigent-ui/src/workspace/core.rs` + - stores the active `CodirigentTheme` + +### Settings and Persistence Path + +- `crates/codirigent-core/src/config.rs` + - `AppearanceSettings.theme: String` + - `TerminalSettings` stores font/cursor/line-height preferences + +- `crates/codirigent-ui/src/workspace/settings_panels.rs` + - theme dropdown is currently `["dark", "light"]` + - directly constructs built-in runtime themes + +- `crates/codirigent-ui/src/workspace/impl_settings.rs` + - settings page rebuild overwrites `appearance.theme` based on background + lightness + - settings load path updates cached settings but does not appear to resolve + and apply an arbitrary theme ID through a registry + +### Unused or Underused Theme Registry Path + +- `crates/codirigent-ui/src/theme_config.rs` + - serializable `Theme` + - `ThemeColors`, `TerminalColors`, typography, spacing + +- `crates/codirigent-ui/src/theme_manager.rs` + - registry for built-in and JSON-loaded themes + - theme loading from a directory or file + - active theme switching by ID + +--- + +## Target End State + +After this work series: + +- Codirigent loads a theme registry on startup. +- The active theme is identified by a durable theme ID. +- `appearance.theme` means "selected theme ID", not "dark mode boolean". +- Settings list all available themes, not just `dark/light`. +- Built-in themes and custom JSON themes use the same application path. +- Runtime theme application updates both UI and terminal state consistently. +- The terminal palette model is structured so it can grow toward a + Ghostty-style theme schema without another large refactor. + +--- + +## Non-Goals For The First Pass + +The first implementation pass should not try to do all theme features at once. +These are explicitly out of scope unless they fall out naturally: + +- importing Ghostty theme files verbatim with full syntax compatibility +- automatic OS appearance switching +- a theme editor UI +- remote theme downloads +- dynamic generation of 256-color cube replacements on the first pass + +The first pass is about establishing the correct architecture and durable +runtime behavior. + +--- + +## Design Principles + +1. **One runtime source of truth** + - The app should resolve every selected theme into one runtime + `CodirigentTheme`. + +2. **Theme ID is stable** + - Any active theme must have a durable identifier that round-trips through + settings and app restart. + +3. **Custom themes should not be a side path** + - Built-in and file-loaded themes should use the same selection and apply + flow. + +4. **Terminal fidelity matters** + - ANSI palette, foreground/background, cursor, and selection colors must all + switch with the active theme. + +5. **Incremental delivery** + - The work should land as small, reviewable tasks following + `docs/task-verification-workflow.md`. + +--- + +## Implementation Constraints + +These constraints apply to every task in this branch: + +1. **Do not load theme files on the UI thread** + - theme discovery, directory scans, and file reads must happen on a + background executor + - the UI thread may receive resolved theme data and apply it, but must not + block on filesystem traversal or JSON file IO + +2. **Keep files at manageable length** + - do not keep expanding already-large files with unrelated theme logic + - when a change starts to push a file into "grab bag" territory, extract a + focused helper/module instead + - prefer small, reviewable modules over one large integration file + +3. **Prefer reusable components over duplicated wiring** + - shared theme resolution, fallback, conversion, and apply behavior should be + centralized + - avoid copy-pasting theme selection logic across startup, settings, and + terminal update paths + +4. **Avoid magic numbers unless they are inherent to the domain** + - filesystem polling delays, cache TTLs, directory limits, and fallback + constants must be named + - if a number is part of a terminal standard or palette definition, document + why it is fixed + +5. **Separate IO, state, and presentation concerns** + - file loading belongs in a theme loading/service layer + - theme registry state belongs in app/workspace state + - settings UI should only render options and trigger actions + +--- + +## Proposed Implementation Shape + +### 1. Introduce a Registry-to-Runtime Conversion Layer + +Create a conversion path from the serializable registry theme model into +`CodirigentTheme`. + +Options: + +- add `impl TryFrom for CodirigentTheme` +- or add `Theme::to_runtime_theme() -> Result` + +Expected result: + +- the registry model becomes useful without replacing the runtime renderer +- theme parsing and theme application stop being separate systems + +### 2. Make Theme Selection Registry-Driven + +Replace direct `dark/light` branching with: + +1. resolve selected theme ID from settings +2. look it up in the theme registry +3. convert it into `CodirigentTheme` +4. apply it to workspace and terminals + +Fallback behavior: + +- if the theme ID is missing or invalid, fall back to built-in `dark` +- log the failure with enough detail to diagnose bad custom themes + +### 3. Preserve Theme IDs In Settings + +Remove the current behavior that reconstructs `appearance.theme` by inspecting +background lightness. + +Instead: + +- track the current active theme ID in workspace settings state +- persist and rebuild the settings page using that actual ID + +### 4. Load Custom Themes From A Well-Defined Directory + +Decide and document the custom theme directory. Likely candidate: + +- `%APPDATA%/codirigent/themes/` on Windows +- `~/.config/codirigent/themes/` on Linux/macOS + +The initial implementation should: + +- load built-in themes first +- then overlay custom themes from disk +- allow custom themes to coexist with built-ins under unique IDs +- perform file discovery and JSON loading off the UI thread + +### 5. Keep Runtime Theme Mutations Compatible + +Today the code mutates parts of the active runtime theme after applying a base +theme, for example: + +- grid gap +- UI font size +- terminal font size +- terminal font family +- terminal line height + +The new registry-driven apply path must preserve those user overrides rather +than resetting them when a theme changes. + +### 6. Prepare For Ghostty-Style Theme Growth + +The first pass does not need full Ghostty config syntax, but the schema should +be able to expand toward these terminal concepts cleanly: + +- background +- foreground +- cursor color +- cursor text color +- selection background +- selection foreground +- ANSI 16 palette +- optional split between light and dark variants + +If a schema change is needed, prefer a backward-compatible addition over a +throwaway one-off field. + +--- + +## Task Series + +This branch should be executed as a small task series, not one large patch. + +### Task 1. Document and Wire The Runtime Registry Backbone + +Deliverables: + +- conversion path from serializable theme model to runtime `CodirigentTheme` +- built-in themes exposed through the registry path +- unit tests for conversion and fallback behavior + +Done when: + +- a theme ID can produce a runtime theme without `if theme == "light"` + +Status: + +- complete in `ced6530` + +### Task 2. Apply Saved Theme IDs During Settings Load / Startup + +Deliverables: + +- startup or settings load path resolves `appearance.theme` +- invalid IDs fall back safely +- active theme ID is retained in workspace state + +Done when: + +- restarting the app with a non-default theme keeps the same theme selected + +Status: + +- complete in `d2c0be0` + +### Task 3. Make The Settings Theme Picker Dynamic + +Deliverables: + +- settings theme dropdown is populated from the registry +- selection applies by theme ID +- settings rebuild preserves the active theme ID + +Done when: + +- custom or built-in registry themes are selectable from settings without + hardcoded branching + +Status: + +- complete in `03d208f` + +### Task 4. Load Custom Theme Files From Disk + +Deliverables: + +- custom theme directory resolution +- file loading on startup +- invalid file handling with non-fatal logging +- tests for loading valid and invalid theme files + +Done when: + +- dropping a valid theme JSON file into the theme directory makes it selectable + +Status: + +- complete in `f632676` + +### Task 5. Expand Terminal Theme Fidelity Where Needed + +Deliverables: + +- review the serializable theme schema against runtime terminal needs +- add missing fields only if required for correct runtime parity +- verify terminal fg/bg/cursor/selection/ANSI palette switch correctly + +Done when: + +- terminal behavior remains visually consistent after switching among themes + +Status: + +- complete in `4df2b36` + +--- + +## Risks And Review Focus + +### Risk 1. Theme Drift Between Models + +If `theme_config::Theme` cannot fully represent runtime needs, conversion logic +may silently drop behavior. + +Review focus: + +- terminal fields +- status colors +- typography/spacings that are currently mutated at runtime + +### Risk 2. Settings Page Regressions + +The current settings page rebuild flow reconstructs display state from runtime +theme values. That can easily wipe out the selected theme ID. + +Review focus: + +- open settings after switching themes +- close and reopen settings +- restart app and reopen settings + +### Risk 3. Startup Ordering + +If theme loading happens after UI creation or after terminal views are +constructed, the app may flash the wrong theme or only partially update. + +Review focus: + +- initial workspace creation +- settings background load path +- terminal creation after theme application +- background theme loading handoff back to UI state application + +### Risk 4. Overwriting User Overrides + +Applying a new base theme must not discard user font size, terminal font +preferences, or grid gap choices. + +Review focus: + +- theme switch after changing font sizes +- theme switch after changing terminal line height +- theme switch after changing terminal font family + +--- + +## Verification Strategy + +This task series follows `docs/task-verification-workflow.md`. + +Per task, after implementation: + +```bash +cargo clean +cargo build --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo fmt --all --check +bash scripts/audit-unwraps.sh +``` + +Required review pass after verification: + +- inspect the diff for dead theme paths and duplicate logic +- review fallback behavior for invalid theme IDs and broken JSON files +- review startup ordering and settings rebuild behavior +- review terminal palette behavior, not just UI chrome colors +- confirm file IO and theme discovery do not happen on the UI thread +- confirm new constants are named and justified +- confirm touched files remain at maintainable size + +Final verification executed on the completed branch: + +```bash +cargo build --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo fmt --all --check +bash scripts/audit-unwraps.sh +``` + +Result: + +- build passed +- full test suite passed +- `gpui-full` UI tests passed +- clippy passed with `-D warnings` +- formatting check passed +- unwrap audit reported only the existing repository-wide baseline + +--- + +## Suggested File Touch Order + +To keep the series reviewable, prefer this order: + +1. `crates/codirigent-ui/src/theme.rs` +2. `crates/codirigent-ui/src/theme_config.rs` +3. `crates/codirigent-ui/src/theme_manager.rs` +4. `crates/codirigent-ui/src/workspace/impl_settings.rs` +5. `crates/codirigent-ui/src/workspace/settings_panels.rs` +6. any startup/bootstrap files that need registry initialization +7. tests +8. follow-up docs updates if behavior changes materially + +This order keeps model changes ahead of UI wiring. + +--- + +## Open Questions Before Implementation + +1. Where should the registry live at runtime? + - central app state + - workspace state + - settings state + +2. Should built-in themes remain defined in `theme.rs`, or should they be + generated from `theme_config.rs` and then converted into runtime themes? + +3. Do we want the first pass to add richer terminal fields to + `theme_config.rs`, or keep schema changes minimal and only fill the missing + runtime wiring? + +4. Should custom theme discovery be automatic on every startup, or only when + the settings panel opens? + +Recommended answers for the first pass: + +- keep the registry in app/workspace state +- preserve `theme.rs` as the runtime authority initially +- add only the schema fields required for parity +- load custom themes on startup so the selected theme is valid before settings + open + +--- + +## Completion Standard + +This plan is complete only when all of the following are true: + +- theme selection is registry-based +- `appearance.theme` stores and preserves a real theme ID +- startup and settings load paths apply the saved theme +- custom themes can be loaded from disk +- terminal colors switch consistently with the active theme +- each task is verified and reviewed per `docs/task-verification-workflow.md` + +This completion standard is now satisfied for `feat/ghostty-theme-registry`. diff --git a/docs/superpowers/plans/2026-03-16-auto-update-notifications.md b/docs/superpowers/plans/2026-03-16-auto-update-notifications.md new file mode 100644 index 00000000..08e2f280 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-auto-update-notifications.md @@ -0,0 +1,2019 @@ +# Auto-Update Notifications Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add automatic update checking, download, and installation so users are notified of new releases and can update with a single click. + +**Architecture:** New `codirigent-updater` crate with checker (GitHub API), downloader (artifact + SHA256), and platform-specific apply logic (macOS DMG swap, Windows MSI). Communicates with UI via existing EventBus. Toast notification in workspace view. + +**Tech Stack:** reqwest (HTTP), semver (version comparison), sha2 + hex (checksum verification), dirs (platform paths), tokio (async runtime) + +**Spec:** `docs/superpowers/specs/2026-03-16-auto-update-notifications-design.md` + +--- + +## Chunk 1: Foundation — Workspace Setup + Core Events + +### Task 1: Add workspace dependencies and create crate skeleton + +**Files:** +- Modify: `Cargo.toml` (root workspace) +- Create: `crates/codirigent-updater/Cargo.toml` +- Create: `crates/codirigent-updater/src/lib.rs` + +- [ ] **Step 1: Add new dependencies to workspace Cargo.toml** + +In the root `Cargo.toml`, add to `[workspace]` `members` array: + +```toml +"crates/codirigent-updater", +``` + +Add to `[workspace.dependencies]`: + +```toml +# Auto-update +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +semver = "1" +sha2 = "0.10" +hex = "0.4" +futures-util = "0.3" +tokio-util = { version = "0.7", features = ["rt"] } + +# Internal crate +codirigent-updater = { path = "crates/codirigent-updater" } +``` + +- [ ] **Step 2: Create the crate directory and Cargo.toml** + +Create `crates/codirigent-updater/Cargo.toml`: + +```toml +[package] +name = "codirigent-updater" +description = "Auto-update checking and installation for Codirigent" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +codirigent-core.workspace = true +anyhow.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +reqwest.workspace = true +semver.workspace = true +sha2.workspace = true +hex.workspace = true +dirs.workspace = true +chrono.workspace = true +futures-util.workspace = true +tokio-util.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio-test.workspace = true +``` + +- [ ] **Step 3: Create lib.rs skeleton** + +Create `crates/codirigent-updater/src/lib.rs`: + +```rust +//! Codirigent Updater +//! +//! Automatic update checking and installation for Codirigent. +//! +//! This crate provides: +//! - Background version checking against GitHub Releases +//! - Artifact downloading with SHA256 verification +//! - Platform-specific update application (macOS DMG, Windows MSI) +//! +//! # Overview +//! +//! The updater checks `api.github.com/repos/oso95/Codirigent/releases/latest` +//! on startup and every 24 hours. When a newer stable version is found, it +//! publishes an `UpdateAvailable` event on the EventBus. The UI shows a toast +//! notification, and the user can choose when to download and apply the update. +//! +//! # Modules +//! +//! - [`checker`] - GitHub Releases API polling and semver comparison +//! - [`downloader`] - Artifact download and SHA256 verification +//! - [`service`] - Update state machine and orchestration +//! - [`platform`] - Platform-specific update application + +#![warn(missing_docs)] +#![warn(clippy::all)] + +pub mod checker; +pub mod downloader; +pub mod platform; +pub mod service; + +pub use checker::UpdateInfo; +pub use service::{StagedUpdate, UpdateService, UpdateState}; +``` + +- [ ] **Step 4: Create empty module files so the crate compiles** + +Create the following empty module files (each with a one-line module doc): + +`crates/codirigent-updater/src/checker.rs`: +```rust +//! GitHub Releases API polling and version comparison. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Information about an available update. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UpdateInfo { + /// The new version. + pub version: semver::Version, + /// URL to the GitHub release page. + pub release_url: String, + /// Direct download URL for the platform artifact. + pub asset_url: String, + /// Direct download URL for checksums-sha256.txt. + pub checksum_url: String, +} +``` + +`crates/codirigent-updater/src/downloader.rs`: +```rust +//! Artifact download and SHA256 checksum verification. +``` + +`crates/codirigent-updater/src/platform/mod.rs`: +```rust +//! Platform-specific update application. +//! +//! Dispatches to macOS or Windows implementations via `#[cfg(target_os)]`. + +#[cfg(target_os = "macos")] +pub mod macos; + +#[cfg(target_os = "windows")] +pub mod windows; + +use anyhow::Result; +use std::path::Path; + +/// Apply a staged update. Platform-specific. +pub fn apply_update( + artifact_path: &Path, + current_app_path: &Path, + current_pid: u32, +) -> Result<()> { + #[cfg(target_os = "macos")] + return macos::apply_update(artifact_path, current_app_path, current_pid); + + #[cfg(target_os = "windows")] + return windows::apply_update(artifact_path, current_app_path, current_pid); + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + anyhow::bail!("Auto-update is not supported on this platform") +} +``` + +`crates/codirigent-updater/src/platform/macos.rs`: +```rust +//! macOS update application — mount DMG, swap .app bundle, relaunch. + +use anyhow::Result; +use std::path::Path; + +/// Apply the update on macOS by writing and launching a helper script. +pub fn apply_update( + _artifact_path: &Path, + _current_app_path: &Path, + _current_pid: u32, +) -> Result<()> { + todo!("macOS apply_update") +} +``` + +`crates/codirigent-updater/src/platform/windows.rs`: +```rust +//! Windows update application — run MSI installer via msiexec. + +use anyhow::Result; +use std::path::Path; + +/// Apply the update on Windows by writing and launching a helper batch script. +pub fn apply_update( + _artifact_path: &Path, + _current_app_path: &Path, + _current_pid: u32, +) -> Result<()> { + todo!("Windows apply_update") +} +``` + +`crates/codirigent-updater/src/service.rs`: +```rust +//! Update state machine and orchestration. + +use crate::checker::UpdateInfo; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Current state of the update process. +#[derive(Debug, Clone, PartialEq)] +pub enum UpdateState { + /// No update activity. + Idle, + /// Checking GitHub for a new release. + Checking, + /// A newer version is available. + UpdateAvailable(UpdateInfo), + /// Downloading the update artifact. + Downloading { + /// Download progress percentage (0-100). + percent: u8, + }, + /// Download complete, ready to apply. + Staged(StagedUpdate), + /// Applying the update (app is about to quit). + Applying, +} + +/// A downloaded update ready to apply. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StagedUpdate { + /// The version of the staged update. + pub version: semver::Version, + /// Path to the downloaded artifact. + pub artifact_path: PathBuf, + /// URL to the GitHub release page. + pub release_url: String, +} + +/// Orchestrates update checking, downloading, and applying. +pub struct UpdateService; +``` + +- [ ] **Step 5: Verify the crate compiles** + +Run: `cd /Users/cyw/Desktop/github/Dirigent/.worktrees/auto-update && cargo check -p codirigent-updater` + +Expected: Compiles with no errors (warnings about unused items are fine). + +- [ ] **Step 6: Commit** + +```bash +git add crates/codirigent-updater/ Cargo.toml Cargo.lock +git commit -m "feat: scaffold codirigent-updater crate with module structure" +``` + +--- + +### Task 2: Add update event variants to CodirigentEvent + +**Files:** +- Modify: `crates/codirigent-core/src/events.rs` + +**Context:** The `CodirigentEvent` enum is at line 54 of `events.rs`. Add new variants at the end of the enum, before the closing brace. The enum already uses `#[derive(Debug, Clone)]`. + +- [ ] **Step 1: Add update event variants** + +Add these variants to the `CodirigentEvent` enum in `crates/codirigent-core/src/events.rs`, in a new section after the `WorkingDirectoryChanged` variants (around line 354): + +```rust + // ── Update Events ─────────────────────────────────────────────── + + /// A newer stable version is available on GitHub. + UpdateAvailable { + /// The new version string (e.g., "0.2.0"). + version: String, + /// URL to the GitHub release page. + release_url: String, + }, + + /// Download progress for an update artifact. + UpdateDownloadProgress { + /// Percentage complete (0–100). + percent: u8, + }, + + /// The update artifact has been downloaded and verified, ready to apply. + UpdateReadyToApply, + + /// An update operation failed. + UpdateFailed { + /// Human-readable error description. + error: String, + }, +``` + +- [ ] **Step 2: Verify the workspace compiles** + +Run: `cd /Users/cyw/Desktop/github/Dirigent/.worktrees/auto-update && cargo check --all` + +Expected: Compiles. Existing code that matches on `CodirigentEvent` with `_ => {}` wildcard arms will still compile. + +- [ ] **Step 3: Commit** + +```bash +git add crates/codirigent-core/src/events.rs +git commit -m "feat: add update event variants to CodirigentEvent" +``` + +--- + +## Chunk 2: Data Layer — Checker + Persistent State + +### Task 3: Implement persistent state (update-state.json) + +**Files:** +- Create: `crates/codirigent-updater/src/state.rs` +- Modify: `crates/codirigent-updater/src/lib.rs` + +**Why first:** Both the checker (needs `last_check_timestamp`) and the service (needs `staged_update`, `last_known_version`) depend on persistent state. Build this first. + +- [ ] **Step 1: Write tests for state persistence** + +Create `crates/codirigent-updater/src/state.rs`: + +```rust +//! Persistent update state stored at `dirs::config_dir()/codirigent/update-state.json`. + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Persistent update state across app restarts. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UpdatePersistentState { + /// The last version the app was known to be running. + #[serde(default)] + pub last_known_version: Option, + + /// Timestamp of the last update check. + #[serde(default)] + pub last_check_timestamp: Option>, + + /// A staged (downloaded but not yet applied) update. + #[serde(default)] + pub staged_update: Option, +} + +/// Serializable representation of a staged update. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StagedUpdateState { + /// Version string of the staged update. + pub version: String, + /// Path to the downloaded artifact. + pub artifact_path: PathBuf, + /// URL to the GitHub release page. + pub release_url: String, +} + +/// Returns the path to the update state file. +pub fn state_file_path() -> Option { + dirs::config_dir().map(|d| d.join("codirigent").join("update-state.json")) +} + +/// Returns the path to the artifact cache directory. +pub fn cache_dir() -> Option { + dirs::cache_dir().map(|d| d.join("codirigent")) +} + +/// Load persistent state from disk. Returns default state if file doesn't exist. +pub fn load_state() -> Result { + let Some(path) = state_file_path() else { + return Ok(UpdatePersistentState::default()); + }; + if !path.exists() { + return Ok(UpdatePersistentState::default()); + } + let content = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", path.display())) +} + +/// Save persistent state to disk. Uses atomic write (temp + rename). +pub fn save_state(state: &UpdatePersistentState) -> Result<()> { + let Some(path) = state_file_path() else { + anyhow::bail!("Could not determine config directory"); + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + let json = serde_json::to_string_pretty(state)?; + let tmp = path.with_file_name(format!(".update-state-{}.tmp", std::process::id())); + std::fs::write(&tmp, &json) + .with_context(|| format!("Failed to write {}", tmp.display()))?; + std::fs::rename(&tmp, &path) + .with_context(|| format!("Failed to rename {} to {}", tmp.display(), path.display()))?; + Ok(()) +} + +/// Load state from a specific path (for testing). +pub fn load_state_from(path: &Path) -> Result { + if !path.exists() { + return Ok(UpdatePersistentState::default()); + } + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", path.display())) +} + +/// Save state to a specific path (for testing). +pub fn save_state_to(state: &UpdatePersistentState, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(state)?; + let tmp = path.with_file_name(format!(".update-state-{}.tmp", std::process::id())); + std::fs::write(&tmp, &json)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_state_serializes() { + let state = UpdatePersistentState::default(); + let json = serde_json::to_string(&state).unwrap(); + let loaded: UpdatePersistentState = serde_json::from_str(&json).unwrap(); + assert!(loaded.last_known_version.is_none()); + assert!(loaded.last_check_timestamp.is_none()); + assert!(loaded.staged_update.is_none()); + } + + #[test] + fn test_full_state_round_trips() { + let state = UpdatePersistentState { + last_known_version: Some("0.1.0".to_string()), + last_check_timestamp: Some(Utc::now()), + staged_update: Some(StagedUpdateState { + version: "0.2.0".to_string(), + artifact_path: PathBuf::from("/tmp/test.dmg"), + release_url: "https://github.com/test".to_string(), + }), + }; + let json = serde_json::to_string_pretty(&state).unwrap(); + let loaded: UpdatePersistentState = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.last_known_version, Some("0.1.0".to_string())); + assert!(loaded.staged_update.is_some()); + } + + #[test] + fn test_load_save_to_file() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("update-state.json"); + + let state = UpdatePersistentState { + last_known_version: Some("0.1.0".to_string()), + last_check_timestamp: None, + staged_update: None, + }; + save_state_to(&state, &path).unwrap(); + let loaded = load_state_from(&path).unwrap(); + assert_eq!(loaded.last_known_version, Some("0.1.0".to_string())); + } + + #[test] + fn test_load_missing_file_returns_default() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("nonexistent.json"); + let loaded = load_state_from(&path).unwrap(); + assert!(loaded.last_known_version.is_none()); + } + + #[test] + fn test_state_file_path_returns_some() { + // dirs::config_dir() returns Some on macOS and Windows + let path = state_file_path(); + if cfg!(any(target_os = "macos", target_os = "windows")) { + assert!(path.is_some()); + } + } + + #[test] + fn test_cache_dir_returns_some() { + let path = cache_dir(); + if cfg!(any(target_os = "macos", target_os = "windows")) { + assert!(path.is_some()); + } + } +} +``` + +- [ ] **Step 2: Add state module to lib.rs** + +Add `pub mod state;` and `pub use state::{UpdatePersistentState, StagedUpdateState};` to `lib.rs`. + +- [ ] **Step 3: Run tests** + +Run: `cd /Users/cyw/Desktop/github/Dirigent/.worktrees/auto-update && cargo test -p codirigent-updater` + +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/codirigent-updater/ +git commit -m "feat: add persistent state for update checker" +``` + +--- + +### Task 4: Implement version checker (GitHub API + semver) + +**Files:** +- Modify: `crates/codirigent-updater/src/checker.rs` + +**Context:** The checker calls `GET https://api.github.com/repos/oso95/Codirigent/releases/latest`, parses the response, selects the correct platform asset, and compares versions using semver. This is a pure-logic module with async HTTP — tests mock the HTTP responses. + +- [ ] **Step 1: Write tests for version comparison and response parsing** + +Replace `crates/codirigent-updater/src/checker.rs` with the full implementation including tests: + +```rust +//! GitHub Releases API polling and version comparison. + +use anyhow::{Context, Result}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use tracing::{debug, info, warn}; + +/// GitHub repository to check for releases. +const GITHUB_REPO: &str = "oso95/Codirigent"; + +/// Information about an available update. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UpdateInfo { + /// The new version. + pub version: Version, + /// URL to the GitHub release page. + pub release_url: String, + /// Direct download URL for the platform artifact. + pub asset_url: String, + /// Direct download URL for checksums-sha256.txt. + pub checksum_url: String, +} + +/// Subset of the GitHub Release API response we care about. +#[derive(Debug, Deserialize)] +struct GitHubRelease { + tag_name: String, + html_url: String, + assets: Vec, +} + +/// Subset of the GitHub Asset API response. +#[derive(Debug, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +/// Determine the target triple and asset suffix for the current platform. +fn platform_asset_filter() -> Option<(&'static str, &'static str)> { + match (std::env::consts::ARCH, std::env::consts::OS) { + ("aarch64", "macos") => Some(("aarch64-apple-darwin", ".dmg")), + ("x86_64", "windows") => Some(("x86_64-pc-windows-msvc", ".msi")), + _ => None, + } +} + +/// Parse a GitHub release response and extract update info for the current platform. +/// +/// Returns `None` if the release version is not newer than `current_version`, +/// or if no matching platform asset is found. +pub fn parse_release( + response_json: &str, + current_version: &Version, +) -> Result> { + let release: GitHubRelease = + serde_json::from_str(response_json).context("Failed to parse GitHub release JSON")?; + + let version_str = release.tag_name.strip_prefix('v').unwrap_or(&release.tag_name); + let latest_version = + Version::parse(version_str).context("Failed to parse release version")?; + + if latest_version <= *current_version { + debug!( + current = %current_version, + latest = %latest_version, + "Already up to date" + ); + return Ok(None); + } + + let Some((target_triple, suffix)) = platform_asset_filter() else { + warn!("Unsupported platform for auto-update"); + return Ok(None); + }; + + let artifact = release + .assets + .iter() + .find(|a| a.name.contains(target_triple) && a.name.ends_with(suffix)); + + let checksum_asset = release + .assets + .iter() + .find(|a| a.name == "checksums-sha256.txt"); + + match (artifact, checksum_asset) { + (Some(art), Some(chk)) => { + info!( + current = %current_version, + latest = %latest_version, + "Update available" + ); + Ok(Some(UpdateInfo { + version: latest_version, + release_url: release.html_url, + asset_url: art.browser_download_url.clone(), + checksum_url: chk.browser_download_url.clone(), + })) + } + _ => { + warn!( + %target_triple, + "No matching asset or checksum file found in release" + ); + Ok(None) + } + } +} + +/// Check GitHub for the latest release. +pub async fn check_for_update( + current_version: &Version, + client: &reqwest::Client, +) -> Result> { + let url = format!( + "https://api.github.com/repos/{}/releases/latest", + GITHUB_REPO + ); + + let response = client + .get(&url) + .header("User-Agent", format!("codirigent/{}", current_version)) + .header("Accept", "application/vnd.github+json") + .send() + .await + .context("Failed to reach GitHub API")?; + + if response.status() == reqwest::StatusCode::FORBIDDEN + || response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS + { + warn!(status = %response.status(), "GitHub API rate limit or forbidden"); + anyhow::bail!("GitHub API rate limited (status {})", response.status()); + } + + if response.status() == reqwest::StatusCode::NOT_FOUND { + debug!("No releases found"); + return Ok(None); + } + + let body = response + .text() + .await + .context("Failed to read GitHub API response")?; + + parse_release(&body, current_version) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_release_json(tag: &str) -> String { + format!( + r#"{{ + "tag_name": "{}", + "html_url": "https://github.com/oso95/Codirigent/releases/tag/{}", + "assets": [ + {{ + "name": "codirigent-{}-aarch64-apple-darwin.dmg", + "browser_download_url": "https://github.com/download/{}/darwin.dmg" + }}, + {{ + "name": "codirigent-{}-x86_64-pc-windows-msvc.msi", + "browser_download_url": "https://github.com/download/{}/windows.msi" + }}, + {{ + "name": "checksums-sha256.txt", + "browser_download_url": "https://github.com/download/{}/checksums-sha256.txt" + }} + ] + }}"#, + tag, tag, tag, tag, tag, tag, tag + ) + } + + #[test] + fn test_newer_version_returns_update_info() { + let current = Version::parse("0.1.0").unwrap(); + let json = sample_release_json("v0.2.0"); + let result = parse_release(&json, ¤t).unwrap(); + // Result depends on platform — on macOS it finds the .dmg, on Windows the .msi + if platform_asset_filter().is_some() { + let info = result.expect("should find update on supported platform"); + assert_eq!(info.version, Version::parse("0.2.0").unwrap()); + assert!(info.release_url.contains("v0.2.0")); + } + } + + #[test] + fn test_same_version_returns_none() { + let current = Version::parse("0.2.0").unwrap(); + let json = sample_release_json("v0.2.0"); + let result = parse_release(&json, ¤t).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_older_version_returns_none() { + let current = Version::parse("0.3.0").unwrap(); + let json = sample_release_json("v0.2.0"); + let result = parse_release(&json, ¤t).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_prerelease_user_gets_notified_for_stable() { + let current = Version::parse("0.2.0-alpha").unwrap(); + let json = sample_release_json("v0.2.0"); + let result = parse_release(&json, ¤t).unwrap(); + if platform_asset_filter().is_some() { + assert!(result.is_some(), "alpha user should see stable update"); + } + } + + #[test] + fn test_prerelease_user_ahead_of_stable_no_update() { + let current = Version::parse("0.3.0-alpha").unwrap(); + let json = sample_release_json("v0.2.0"); + let result = parse_release(&json, ¤t).unwrap(); + assert!(result.is_none(), "alpha user ahead of stable should not see update"); + } + + #[test] + fn test_missing_checksum_asset_returns_none() { + let json = r#"{ + "tag_name": "v0.2.0", + "html_url": "https://github.com/test", + "assets": [ + { + "name": "codirigent-v0.2.0-aarch64-apple-darwin.dmg", + "browser_download_url": "https://example.com/test.dmg" + } + ] + }"#; + let current = Version::parse("0.1.0").unwrap(); + let result = parse_release(json, ¤t).unwrap(); + // No checksums-sha256.txt asset → returns None + assert!(result.is_none()); + } + + #[test] + fn test_tag_without_v_prefix_parses() { + let json = r#"{ + "tag_name": "0.2.0", + "html_url": "https://github.com/test", + "assets": [] + }"#; + let current = Version::parse("0.1.0").unwrap(); + // Should not error, just return None (no assets) + let result = parse_release(json, ¤t).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_invalid_json_returns_error() { + let current = Version::parse("0.1.0").unwrap(); + let result = parse_release("not json", ¤t); + assert!(result.is_err()); + } + + #[test] + fn test_platform_asset_filter_returns_some_on_supported() { + let filter = platform_asset_filter(); + if cfg!(all(target_arch = "aarch64", target_os = "macos")) { + assert_eq!(filter, Some(("aarch64-apple-darwin", ".dmg"))); + } else if cfg!(all(target_arch = "x86_64", target_os = "windows")) { + assert_eq!(filter, Some(("x86_64-pc-windows-msvc", ".msi"))); + } + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cd /Users/cyw/Desktop/github/Dirigent/.worktrees/auto-update && cargo test -p codirigent-updater -- checker` + +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add crates/codirigent-updater/src/checker.rs +git commit -m "feat: implement GitHub release checker with semver comparison" +``` + +--- + +## Chunk 3: Download + Platform Apply + +### Task 5: Implement artifact downloader with SHA256 verification + +**Files:** +- Modify: `crates/codirigent-updater/src/downloader.rs` + +- [ ] **Step 1: Write the downloader with tests** + +Replace `crates/codirigent-updater/src/downloader.rs`: + +```rust +//! Artifact download and SHA256 checksum verification. + +use anyhow::{bail, Context, Result}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWriteExt; +use tracing::{debug, info, warn}; + +/// Download timeout in seconds. +const DOWNLOAD_TIMEOUT_SECS: u64 = 600; // 10 minutes + +/// Parse a `checksums-sha256.txt` file and find the hash for a given filename. +/// +/// Format: ` ` (two spaces between hash and name). +pub fn find_checksum(checksums_content: &str, filename: &str) -> Option { + for line in checksums_content.lines() { + // sha256sum format: hash followed by two spaces then filename + if let Some((hash, name)) = line.split_once(" ") { + if name.trim() == filename { + return Some(hash.trim().to_lowercase()); + } + } + } + None +} + +/// Verify a file's SHA256 hash matches the expected value. +pub fn verify_sha256(file_path: &Path, expected_hex: &str) -> Result { + let data = std::fs::read(file_path) + .with_context(|| format!("Failed to read {}", file_path.display()))?; + let hash = Sha256::digest(&data); + let actual_hex = hex::encode(hash); + Ok(actual_hex == expected_hex.to_lowercase()) +} + +/// Download the checksums file and return its content. +pub async fn download_checksums( + client: &reqwest::Client, + checksum_url: &str, + user_agent: &str, +) -> Result { + let response = client + .get(checksum_url) + .header("User-Agent", user_agent) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .context("Failed to download checksums file")?; + + response + .text() + .await + .context("Failed to read checksums response body") +} + +/// Download an artifact to the given path, reporting progress via callback. +/// +/// Returns the path to the downloaded file. +pub async fn download_artifact( + client: &reqwest::Client, + asset_url: &str, + dest_dir: &Path, + user_agent: &str, + on_progress: F, +) -> Result +where + F: Fn(u8) + Send, +{ + std::fs::create_dir_all(dest_dir) + .with_context(|| format!("Failed to create cache dir {}", dest_dir.display()))?; + + let filename = asset_url + .rsplit('/') + .next() + .unwrap_or("update-artifact"); + let dest_path = dest_dir.join(filename); + + info!(url = %asset_url, dest = %dest_path.display(), "Downloading update artifact"); + + let response = client + .get(asset_url) + .header("User-Agent", user_agent) + .timeout(std::time::Duration::from_secs(DOWNLOAD_TIMEOUT_SECS)) + .send() + .await + .context("Failed to start artifact download")?; + + let total_size = response.content_length().unwrap_or(0); + let mut downloaded: u64 = 0; + + let mut file = tokio::fs::File::create(&dest_path) + .await + .with_context(|| format!("Failed to create {}", dest_path.display()))?; + + let mut stream = response.bytes_stream(); + use futures_util::StreamExt; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Error reading download stream")?; + file.write_all(&chunk) + .await + .context("Error writing to file")?; + downloaded += chunk.len() as u64; + if total_size > 0 { + let percent = ((downloaded as f64 / total_size as f64) * 100.0).min(100.0) as u8; + on_progress(percent); + } + } + + file.flush().await?; + info!(path = %dest_path.display(), "Download complete"); + + Ok(dest_path) +} + +/// Full download + verify flow. +pub async fn download_and_verify( + client: &reqwest::Client, + asset_url: &str, + checksum_url: &str, + dest_dir: &Path, + user_agent: &str, + on_progress: F, +) -> Result +where + F: Fn(u8) + Send, +{ + // Download checksums first + let checksums = download_checksums(client, checksum_url, user_agent).await?; + + // Download artifact + let artifact_path = + download_artifact(client, asset_url, dest_dir, user_agent, on_progress).await?; + + // Extract filename for checksum lookup + let filename = artifact_path + .file_name() + .and_then(|n| n.to_str()) + .context("Invalid artifact filename")?; + + // Verify checksum + let expected_hash = find_checksum(&checksums, filename) + .with_context(|| format!("No checksum found for {} in checksums file", filename))?; + + if !verify_sha256(&artifact_path, &expected_hash)? { + // Clean up failed download + let _ = std::fs::remove_file(&artifact_path); + bail!("SHA256 checksum mismatch for {}", filename); + } + + debug!(file = %filename, "SHA256 checksum verified"); + Ok(artifact_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_checksum_standard_format() { + let content = "abc123def456 codirigent-v0.2.0-aarch64-apple-darwin.dmg\nfed321cba654 codirigent-v0.2.0-x86_64-pc-windows-msvc.msi\n"; + let result = find_checksum(content, "codirigent-v0.2.0-aarch64-apple-darwin.dmg"); + assert_eq!(result, Some("abc123def456".to_string())); + } + + #[test] + fn test_find_checksum_windows_asset() { + let content = "abc123 darwin.dmg\nfed321 windows.msi\n"; + let result = find_checksum(content, "windows.msi"); + assert_eq!(result, Some("fed321".to_string())); + } + + #[test] + fn test_find_checksum_not_found() { + let content = "abc123 other-file.dmg\n"; + let result = find_checksum(content, "nonexistent.dmg"); + assert!(result.is_none()); + } + + #[test] + fn test_find_checksum_empty_content() { + assert!(find_checksum("", "any.dmg").is_none()); + } + + #[test] + fn test_verify_sha256_correct() { + let tmp = tempfile::TempDir::new().unwrap(); + let file_path = tmp.path().join("test.bin"); + std::fs::write(&file_path, b"hello world").unwrap(); + + // SHA256 of "hello world" + let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; + assert!(verify_sha256(&file_path, expected).unwrap()); + } + + #[test] + fn test_verify_sha256_incorrect() { + let tmp = tempfile::TempDir::new().unwrap(); + let file_path = tmp.path().join("test.bin"); + std::fs::write(&file_path, b"hello world").unwrap(); + + assert!(!verify_sha256(&file_path, "0000000000000000").unwrap()); + } + + #[test] + fn test_verify_sha256_case_insensitive() { + let tmp = tempfile::TempDir::new().unwrap(); + let file_path = tmp.path().join("test.bin"); + std::fs::write(&file_path, b"hello world").unwrap(); + + let expected = "B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9"; + assert!(verify_sha256(&file_path, expected).unwrap()); + } + + #[test] + fn test_verify_sha256_missing_file() { + let result = verify_sha256(Path::new("/nonexistent/file"), "abc"); + assert!(result.is_err()); + } +} +``` + +- [ ] **Step 2: Add `futures-util` dependency** + +The downloader uses `futures_util::StreamExt` for streaming. + +Add to root `Cargo.toml` `[workspace.dependencies]`: +```toml +futures-util = "0.3" +``` + +Add to `crates/codirigent-updater/Cargo.toml` under `[dependencies]`: +```toml +futures-util.workspace = true +``` + +- [ ] **Step 3: Run tests** + +Run: `cd /Users/cyw/Desktop/github/Dirigent/.worktrees/auto-update && cargo test -p codirigent-updater -- downloader` + +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/codirigent-updater/ +git commit -m "feat: implement artifact downloader with SHA256 verification" +``` + +--- + +### Task 6: Implement platform-specific apply logic + +**Files:** +- Modify: `crates/codirigent-updater/src/platform/mod.rs` +- Modify: `crates/codirigent-updater/src/platform/macos.rs` +- Modify: `crates/codirigent-updater/src/platform/windows.rs` + +- [ ] **Step 1: Implement platform/mod.rs with app path detection** + +Replace `crates/codirigent-updater/src/platform/mod.rs`: + +```rust +//! Platform-specific update application. +//! +//! Dispatches to macOS or Windows implementations via `#[cfg(target_os)]`. +//! On unsupported platforms, returns an error. + +#[cfg(target_os = "macos")] +pub mod macos; + +#[cfg(target_os = "windows")] +pub mod windows; + +use anyhow::Result; +use std::path::{Path, PathBuf}; + +/// Apply a staged update. Platform-specific. +/// +/// This function writes a helper script, launches it detached, and returns. +/// The caller should quit the app immediately after this returns successfully. +pub fn apply_update( + artifact_path: &Path, + current_pid: u32, +) -> Result<()> { + let app_path = detect_app_path()?; + + #[cfg(target_os = "macos")] + return macos::apply_update(artifact_path, &app_path, current_pid); + + #[cfg(target_os = "windows")] + return windows::apply_update(artifact_path, &app_path, current_pid); + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let _ = (artifact_path, &app_path, current_pid); + anyhow::bail!("Auto-update is not supported on this platform") + } +} + +/// Detect the current application path. +/// +/// On macOS: walks up from the current exe to find the `.app` bundle. +/// On Windows: returns the directory containing the current exe. +fn detect_app_path() -> Result { + let exe = std::env::current_exe()?; + + #[cfg(target_os = "macos")] + { + // Walk up from e.g. /Applications/Codirigent.app/Contents/MacOS/codirigent + // to find the .app bundle + let mut path = exe.as_path(); + while let Some(parent) = path.parent() { + if path + .extension() + .map(|e| e == "app") + .unwrap_or(false) + { + return Ok(path.to_path_buf()); + } + path = parent; + } + anyhow::bail!( + "Could not find .app bundle from exe path: {}", + exe.display() + ) + } + + #[cfg(target_os = "windows")] + { + // Return the directory containing the exe + exe.parent() + .map(|p| p.to_path_buf()) + .ok_or_else(|| anyhow::anyhow!("Could not determine install directory")) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + anyhow::bail!("Platform not supported") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_app_path_does_not_panic() { + // On dev machines this won't find a .app bundle (macOS) or will return the + // cargo target dir (Windows), but it should not panic. + let result = detect_app_path(); + // On macOS in dev, this will error because we're not inside a .app bundle. + // On Windows, it should succeed. + #[cfg(target_os = "windows")] + assert!(result.is_ok()); + // Just ensure no panic on any platform + let _ = result; + } +} +``` + +- [ ] **Step 2: Implement macOS apply logic** + +Replace `crates/codirigent-updater/src/platform/macos.rs`: + +```rust +//! macOS update application — mount DMG, swap .app bundle, relaunch. + +use crate::state::cache_dir; +use anyhow::{Context, Result}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; +use tracing::info; + +/// Generate the macOS update helper script content. +pub fn generate_update_script( + dmg_path: &Path, + current_app_path: &Path, + pid: u32, +) -> String { + format!( + r#"#!/bin/bash +APP_PID={} + +# Wait for the app to fully exit +while kill -0 "$APP_PID" 2>/dev/null; do sleep 0.5; done + +DMG_PATH="{}" +CURRENT_APP_PATH="{}" + +# Use a unique mount point to avoid collisions +MOUNT_DIR=$(mktemp -d /tmp/codirigent-mount.XXXXXX) + +# Backup current app (abort if backup fails) +if ! cp -R "$CURRENT_APP_PATH" "$CURRENT_APP_PATH.bak"; then + open "$CURRENT_APP_PATH" + exit 1 +fi + +# Mount DMG and replace +if hdiutil attach "$DMG_PATH" -mountpoint "$MOUNT_DIR" -quiet; then + rm -rf "$CURRENT_APP_PATH" + if cp -R "$MOUNT_DIR/Codirigent.app" "$CURRENT_APP_PATH"; then + rm -rf "$CURRENT_APP_PATH.bak" + else + rm -rf "$CURRENT_APP_PATH" + mv "$CURRENT_APP_PATH.bak" "$CURRENT_APP_PATH" + fi + hdiutil detach "$MOUNT_DIR" -quiet +else + rm -rf "$CURRENT_APP_PATH" + mv "$CURRENT_APP_PATH.bak" "$CURRENT_APP_PATH" +fi + +rmdir "$MOUNT_DIR" 2>/dev/null +open "$CURRENT_APP_PATH" +rm -f "$DMG_PATH" +"#, + pid, + dmg_path.display(), + current_app_path.display() + ) +} + +/// Apply the update on macOS by writing and launching a helper script. +pub fn apply_update( + artifact_path: &Path, + current_app_path: &Path, + current_pid: u32, +) -> Result<()> { + let cache = cache_dir().context("Could not determine cache directory")?; + std::fs::create_dir_all(&cache)?; + let script_path = cache.join("codirigent-update.sh"); + + let script = generate_update_script(artifact_path, current_app_path, current_pid); + std::fs::write(&script_path, &script) + .with_context(|| format!("Failed to write update script to {}", script_path.display()))?; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?; + + info!(script = %script_path.display(), "Launching update helper script"); + + Command::new("bash") + .arg(&script_path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .context("Failed to launch update helper script")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_generate_update_script_contains_pid() { + let script = generate_update_script( + &PathBuf::from("/tmp/test.dmg"), + &PathBuf::from("/Applications/Codirigent.app"), + 12345, + ); + assert!(script.contains("APP_PID=12345")); + } + + #[test] + fn test_generate_update_script_contains_paths() { + let script = generate_update_script( + &PathBuf::from("/tmp/test.dmg"), + &PathBuf::from("/Applications/Codirigent.app"), + 1, + ); + assert!(script.contains("/tmp/test.dmg")); + assert!(script.contains("/Applications/Codirigent.app")); + } + + #[test] + fn test_generate_update_script_has_backup_logic() { + let script = generate_update_script( + &PathBuf::from("/tmp/test.dmg"), + &PathBuf::from("/Applications/Codirigent.app"), + 1, + ); + assert!(script.contains(".bak")); + assert!(script.contains("mktemp -d")); + } + + #[test] + fn test_generate_update_script_has_relaunch() { + let script = generate_update_script( + &PathBuf::from("/tmp/test.dmg"), + &PathBuf::from("/Applications/Codirigent.app"), + 1, + ); + assert!(script.contains("open \"$CURRENT_APP_PATH\"")); + } +} +``` + +- [ ] **Step 3: Implement Windows apply logic** + +Replace `crates/codirigent-updater/src/platform/windows.rs`: + +```rust +//! Windows update application — run MSI installer via msiexec. + +use crate::state::cache_dir; +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; +use tracing::info; + +/// Generate the Windows update helper batch script content. +pub fn generate_update_script( + msi_path: &Path, + install_path: &Path, + pid: u32, +) -> String { + format!( + r#"@echo off +set APP_PID={} + +REM Wait for the app to fully exit +:wait_loop +tasklist /FI "PID eq %APP_PID%" 2>nul | find "%APP_PID%" >nul +if not errorlevel 1 ( + timeout /t 1 /nobreak >nul + goto wait_loop +) + +msiexec /passive /i "{}" +del "{}" + +REM Relaunch the app after MSI completes +start "" "{}\codirigent.exe" +"#, + pid, + msi_path.display(), + msi_path.display(), + install_path.display() + ) +} + +/// Apply the update on Windows by writing and launching a helper batch script. +pub fn apply_update( + artifact_path: &Path, + install_path: &Path, + current_pid: u32, +) -> Result<()> { + let cache = cache_dir().context("Could not determine cache directory")?; + std::fs::create_dir_all(&cache)?; + let script_path = cache.join("codirigent-update.bat"); + + let script = generate_update_script(artifact_path, install_path, current_pid); + std::fs::write(&script_path, &script) + .with_context(|| format!("Failed to write update script to {}", script_path.display()))?; + + info!(script = %script_path.display(), "Launching update helper script"); + + Command::new("cmd") + .args(["/C", "start", "/B", ""]) + .arg(&script_path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .context("Failed to launch update helper script")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_generate_update_script_contains_pid() { + let script = generate_update_script( + &PathBuf::from(r"C:\temp\update.msi"), + &PathBuf::from(r"C:\Program Files\Codirigent"), + 12345, + ); + assert!(script.contains("APP_PID=12345")); + } + + #[test] + fn test_generate_update_script_contains_msiexec() { + let script = generate_update_script( + &PathBuf::from(r"C:\temp\update.msi"), + &PathBuf::from(r"C:\Program Files\Codirigent"), + 1, + ); + assert!(script.contains("msiexec /passive /i")); + } + + #[test] + fn test_generate_update_script_has_relaunch() { + let script = generate_update_script( + &PathBuf::from(r"C:\temp\update.msi"), + &PathBuf::from(r"C:\Program Files\Codirigent"), + 1, + ); + assert!(script.contains(r"C:\Program Files\Codirigent\codirigent.exe")); + } + + #[test] + fn test_generate_update_script_has_wait_loop() { + let script = generate_update_script( + &PathBuf::from(r"C:\temp\update.msi"), + &PathBuf::from(r"C:\Program Files\Codirigent"), + 1, + ); + assert!(script.contains(":wait_loop")); + assert!(script.contains("tasklist")); + } +} +``` + +- [ ] **Step 4: Run all tests** + +Run: `cd /Users/cyw/Desktop/github/Dirigent/.worktrees/auto-update && cargo test -p codirigent-updater` + +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/codirigent-updater/src/platform/ +git commit -m "feat: implement platform-specific update apply logic" +``` + +--- + +## Chunk 4: Service Orchestration + UI Integration + +### Task 7: Implement UpdateService state machine + +**Files:** +- Modify: `crates/codirigent-updater/src/service.rs` +- Modify: `crates/codirigent-updater/src/lib.rs` + +**Context:** The `UpdateService` owns the state machine. It is constructed by the UI, runs a background check on startup, and exposes methods for the UI to call (start_download, apply_update, etc.). It publishes events on the EventBus. + +- [ ] **Step 1: Implement the full UpdateService** + +Replace `crates/codirigent-updater/src/service.rs` with the full implementation. Key parts: + +```rust +//! Update state machine and orchestration. + +use crate::checker::{self, UpdateInfo}; +use crate::downloader; +use crate::state::{self, UpdatePersistentState, StagedUpdateState}; +use anyhow::Result; +use codirigent_core::{CodirigentEvent, EventBus}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::watch; +use tracing::{error, info, warn}; + +/// Current state of the update process. +#[derive(Debug, Clone, PartialEq)] +pub enum UpdateState { + /// No update activity. + Idle, + /// Checking GitHub for a new release. + Checking, + /// A newer version is available. + UpdateAvailable(UpdateInfo), + /// Downloading the update artifact. + Downloading { percent: u8 }, + /// Download complete, ready to apply. + Staged(StagedUpdate), + /// Applying the update. + Applying, +} + +/// A downloaded update ready to apply. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StagedUpdate { + /// The version of the staged update. + pub version: Version, + /// Path to the downloaded artifact. + pub artifact_path: PathBuf, + /// URL to the GitHub release page. + pub release_url: String, + /// Expected SHA256 hash of the artifact (for re-verification before apply). + pub expected_sha256: String, +} + +/// Check interval between update checks. +const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours + +/// Orchestrates update checking, downloading, and applying. +pub struct UpdateService { + current_version: Version, + event_bus: Arc, + state: Arc>, + client: reqwest::Client, + /// Cancellation token for in-progress downloads. + download_cancel: Arc>, +} + +impl UpdateService { + /// Create a new UpdateService. + pub fn new(current_version: &str, event_bus: Arc) -> Result { + let version = Version::parse(current_version)?; + Ok(Self { + current_version: version, + event_bus, + state: Arc::new(Mutex::new(UpdateState::Idle)), + client: reqwest::Client::new(), + download_cancel: Arc::new(Mutex::new(tokio_util::sync::CancellationToken::new())), + }) + } + + /// Get the current update state. + pub fn state(&self) -> UpdateState { + self.state.lock().unwrap().clone() + } + + /// Start background update checking. Call once at app startup. + /// + /// This spawns a tokio task that: + /// 1. Checks for stale staged updates on startup + /// 2. Restores staged updates from persistent state + /// 3. Checks for updates immediately (if interval has elapsed) + /// 4. Re-checks every 24 hours + pub fn start_background_check(&self) { + let version = self.current_version.clone(); + let event_bus = self.event_bus.clone(); + let state = self.state.clone(); + let client = self.client.clone(); + + tokio::spawn(async move { + // Load persistent state and handle startup conditions + let mut persistent = state::load_state().unwrap_or_default(); + + // Check for post-update (version changed) + if let Some(ref last_version) = persistent.last_known_version { + if last_version != &version.to_string() { + // Version changed — this is a post-update launch + info!( + old = %last_version, + new = %version, + "Detected version change — post-update launch" + ); + persistent.staged_update = None; + persistent.last_known_version = Some(version.to_string()); + let _ = state::save_state(&persistent); + // The UI will detect the version change and show the "Updated to" toast + } + } else { + persistent.last_known_version = Some(version.to_string()); + let _ = state::save_state(&persistent); + } + + // Check for stale staged update + if let Some(ref staged) = persistent.staged_update { + let artifact_exists = PathBuf::from(&staged.artifact_path).exists(); + let version_matches = persistent.last_known_version.as_deref() + == Some(&version.to_string()); + + if !artifact_exists { + warn!("Staged artifact missing, clearing stale entry"); + persistent.staged_update = None; + let _ = state::save_state(&persistent); + } else if version_matches { + // Version matches but staged update exists — apply failed last time + warn!("Stale staged update detected (version matches), clearing"); + let _ = std::fs::remove_file(&staged.artifact_path); + persistent.staged_update = None; + let _ = state::save_state(&persistent); + } else { + // Restore staged state + let staged_update = StagedUpdate { + version: Version::parse(&staged.version).unwrap_or(version.clone()), + artifact_path: staged.artifact_path.clone(), + release_url: staged.release_url.clone(), + }; + *state.lock().unwrap() = UpdateState::Staged(staged_update); + event_bus.publish(CodirigentEvent::UpdateReadyToApply); + return; // Don't check for updates if we already have one staged + } + } + + // Check if enough time has passed since last check + let should_check = persistent + .last_check_timestamp + .map(|ts| { + let elapsed = chrono::Utc::now() - ts; + elapsed.num_seconds() >= CHECK_INTERVAL.as_secs() as i64 + }) + .unwrap_or(true); // Always check on first run + + if should_check { + Self::do_check(&version, &client, &event_bus, &state).await; + } + + // Schedule periodic checks + let mut interval = tokio::time::interval(CHECK_INTERVAL); + interval.tick().await; // Skip the first immediate tick + loop { + interval.tick().await; + Self::do_check(&version, &client, &event_bus, &state).await; + } + }); + } + + async fn do_check( + version: &Version, + client: &reqwest::Client, + event_bus: &Arc, + state: &Arc>, + ) { + *state.lock().unwrap() = UpdateState::Checking; + + match checker::check_for_update(version, client).await { + Ok(Some(info)) => { + event_bus.publish(CodirigentEvent::UpdateAvailable { + version: info.version.to_string(), + release_url: info.release_url.clone(), + }); + *state.lock().unwrap() = UpdateState::UpdateAvailable(info); + } + Ok(None) => { + *state.lock().unwrap() = UpdateState::Idle; + } + Err(e) => { + warn!("Update check failed: {}", e); + event_bus.publish(CodirigentEvent::UpdateFailed { + error: e.to_string(), + }); + *state.lock().unwrap() = UpdateState::Idle; + } + } + + // Save check timestamp + if let Ok(mut persistent) = state::load_state() { + persistent.last_check_timestamp = Some(chrono::Utc::now()); + let _ = state::save_state(&persistent); + } + } + + /// Start downloading the update. Call when user clicks "Update". + pub fn start_download(&self) { + let state = self.state.clone(); + let event_bus = self.event_bus.clone(); + let client = self.client.clone(); + let version = self.current_version.clone(); + + let update_info = { + let current = state.lock().unwrap(); + match &*current { + UpdateState::UpdateAvailable(info) => info.clone(), + _ => return, // Can only download from UpdateAvailable state + } + }; + + *state.lock().unwrap() = UpdateState::Downloading { percent: 0 }; + + tokio::spawn(async move { + let Some(dest_dir) = state::cache_dir() else { + error!("Could not determine cache directory"); + *state.lock().unwrap() = UpdateState::UpdateAvailable(update_info); + return; + }; + + let user_agent = format!("codirigent/{}", version); + + // Clean up any old staged artifacts + if let Ok(persistent) = state::load_state() { + if let Some(old_staged) = &persistent.staged_update { + let _ = std::fs::remove_file(&old_staged.artifact_path); + } + } + + let event_bus_progress = event_bus.clone(); + let state_progress = state.clone(); + + match downloader::download_and_verify( + &client, + &update_info.asset_url, + &update_info.checksum_url, + &dest_dir, + &user_agent, + move |percent| { + *state_progress.lock().unwrap() = + UpdateState::Downloading { percent }; + event_bus_progress.publish(CodirigentEvent::UpdateDownloadProgress { + percent, + }); + }, + ) + .await + { + Ok(artifact_path) => { + let staged = StagedUpdate { + version: update_info.version.clone(), + artifact_path: artifact_path.clone(), + release_url: update_info.release_url.clone(), + }; + + // Persist staged state + if let Ok(mut persistent) = state::load_state() { + persistent.staged_update = Some(StagedUpdateState { + version: staged.version.to_string(), + artifact_path, + release_url: staged.release_url.clone(), + }); + let _ = state::save_state(&persistent); + } + + *state.lock().unwrap() = UpdateState::Staged(staged); + event_bus.publish(CodirigentEvent::UpdateReadyToApply); + } + Err(e) => { + error!("Download failed: {}", e); + event_bus.publish(CodirigentEvent::UpdateFailed { + error: e.to_string(), + }); + *state.lock().unwrap() = UpdateState::UpdateAvailable(update_info); + } + } + }); + } + + /// Apply the staged update. Call when user clicks "Restart Now". + /// + /// This re-verifies the SHA256 checksum, writes the helper script, and + /// launches it. The caller should quit the app after this returns Ok. + pub fn apply(&self) -> Result<()> { + let current_state = self.state.lock().unwrap().clone(); + let staged = match current_state { + UpdateState::Staged(s) => s, + _ => anyhow::bail!("No staged update to apply"), + }; + + // Re-verify SHA256 before applying (spec requirement) + if !staged.artifact_path.exists() { + anyhow::bail!("Staged artifact no longer exists"); + } + if !downloader::verify_sha256(&staged.artifact_path, &staged.expected_sha256)? { + anyhow::bail!("SHA256 re-verification failed — artifact may be corrupted"); + } + + *self.state.lock().unwrap() = UpdateState::Applying; + + let pid = std::process::id(); + crate::platform::apply_update(&staged.artifact_path, pid)?; + + Ok(()) + } + + /// Cancel an in-progress download. + /// + /// Signals the download task to stop via the cancellation token and + /// transitions back to UpdateAvailable so the user can retry. + pub fn cancel_download(&self) { + self.download_cancel.lock().unwrap().cancel(); + // State will be reset to UpdateAvailable by the download task's + // cancellation handler. If the task already completed, this is a no-op. + } +} +``` + +- [ ] **Step 2: Update lib.rs exports** + +Update `crates/codirigent-updater/src/lib.rs` to include the state module and re-exports: + +```rust +pub mod checker; +pub mod downloader; +pub mod platform; +pub mod service; +pub mod state; + +pub use checker::UpdateInfo; +pub use service::{StagedUpdate, UpdateService, UpdateState}; +pub use state::{UpdatePersistentState, StagedUpdateState}; +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cd /Users/cyw/Desktop/github/Dirigent/.worktrees/auto-update && cargo check -p codirigent-updater` + +Expected: Compiles. Some warnings about unused code are acceptable at this stage. + +- [ ] **Step 4: Run all updater tests** + +Run: `cd /Users/cyw/Desktop/github/Dirigent/.worktrees/auto-update && cargo test -p codirigent-updater` + +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/codirigent-updater/ +git commit -m "feat: implement UpdateService state machine and orchestration" +``` + +--- + +### Task 8: Wire UpdateService into workspace view and add toast notification + +**Files:** +- Modify: `crates/codirigent-ui/Cargo.toml` — add `codirigent-updater` dependency +- Modify: `crates/codirigent-ui/src/workspace/gpui.rs` — add update state fields to `WorkspaceView`, instantiate `UpdateService` in `new()` +- Create: `crates/codirigent-ui/src/workspace/toast_render.rs` — toast notification rendering +- Modify: `crates/codirigent-ui/src/workspace/render.rs` — call toast render in the main render function + +**This is the UI integration task. The exact GPUI rendering code will depend on inspecting the existing render patterns in the workspace. The implementer should:** + +- [ ] **Step 1: Add codirigent-updater dependency to codirigent-ui** + +In `crates/codirigent-ui/Cargo.toml`, add under `[dependencies]`: + +```toml +codirigent-updater.workspace = true +``` + +- [ ] **Step 2: Add update state fields to WorkspaceView** + +In `crates/codirigent-ui/src/workspace/gpui.rs`, add these fields to the `WorkspaceView` struct (around line 150, in the sub-state groups section): + +```rust + /// Update service for auto-update checking and downloading. + update_service: Option>, + /// Current update info from the checker. + update_info: Option, + /// Whether the user dismissed the update toast this session. + update_dismissed: bool, + /// Download progress percentage (0-100) during download. + update_download_progress: Option, + /// Staged update ready to apply. + staged_update: Option, + /// Whether this is the first launch after a successful update. + post_update_version: Option, +``` + +Initialize them all to `None`/`false` in the `WorkspaceView::new()` constructor. + +- [ ] **Step 3: Detect post-update and instantiate UpdateService in WorkspaceView::new()** + +In the `WorkspaceView::new()` function, after the event bus is available, add: + +**IMPORTANT:** Post-update detection MUST happen before `start_background_check()` to avoid a race condition. The background task also updates `last_known_version`. + +```rust + // Detect post-update launch BEFORE starting the background check + let post_update_version = { + if let Ok(persistent) = codirigent_updater::state::load_state() { + if let Some(ref last_ver) = persistent.last_known_version { + if last_ver != env!("CARGO_PKG_VERSION") { + Some(env!("CARGO_PKG_VERSION").to_string()) + } else { + None + } + } else { + None + } + } else { + None + } + }; + + let update_service = match codirigent_updater::UpdateService::new( + env!("CARGO_PKG_VERSION"), + event_bus.clone(), + ) { + Ok(svc) => { + svc.start_background_check(); + Some(Arc::new(svc)) + } + Err(e) => { + tracing::warn!("Failed to initialize update service: {}", e); + None + } + }; +``` + +- [ ] **Step 4: Handle update events in the polling loop** + +In the workspace view's event processing (where it processes `CodirigentEvent` variants from the EventBus), add handlers: + +```rust + CodirigentEvent::UpdateAvailable { version, release_url } => { + if !self.update_dismissed { + // The update_info is set from the service's state + if let Some(svc) = &self.update_service { + if let codirigent_updater::UpdateState::UpdateAvailable(info) = svc.state() { + self.update_info = Some(info); + } + } + cx.notify(); + } + } + CodirigentEvent::UpdateDownloadProgress { percent } => { + self.update_download_progress = Some(percent); + cx.notify(); + } + CodirigentEvent::UpdateReadyToApply => { + if let Some(svc) = &self.update_service { + if let codirigent_updater::UpdateState::Staged(staged) = svc.state() { + self.staged_update = Some(staged); + self.update_download_progress = None; + } + } + cx.notify(); + } + CodirigentEvent::UpdateFailed { error } => { + tracing::warn!("Update failed: {}", error); + self.update_download_progress = None; + cx.notify(); + } +``` + +- [ ] **Step 5: Create toast rendering module** + +Create `crates/codirigent-ui/src/workspace/toast_render.rs`. This module renders the toast notification in the bottom-right corner of the workspace. Follow the existing rendering patterns from `modal_render.rs` — use `div()`, theme colors, and GPUI's layout system. + +The toast should show different content based on state: +- `update_info` is Some + no staged + no progress → "New version available (vX.Y.Z)" with [Update] button +- `update_download_progress` is Some → "Downloading... N%" with [Cancel] button +- `staged_update` is Some → "Update ready (vX.Y.Z)" with [Restart Now] and [Later] buttons +- `post_update_version` is Some → "Updated to vX.Y.Z" with [Release Notes] button + +Button handlers: +- **[Update]**: call `self.update_service.as_ref().unwrap().start_download()` +- **[Cancel]**: call `self.update_service.as_ref().unwrap().cancel_download()`; set `update_download_progress = None` +- **[Restart Now]**: check for working sessions first. If any `SessionStatus::Working`, show confirmation. Then call `self.update_service.as_ref().unwrap().apply()`; if Ok, quit the app via `cx.quit()` +- **[Later]**: set `update_dismissed = true`; clear `staged_update` from local state (it persists on disk) +- **[Release Notes]**: open `release_url` in browser; set `post_update_version = None` +- **Dismiss (X or click outside)**: set `update_dismissed = true` + +- [ ] **Step 6: Wire toast rendering into the main render function** + +In `crates/codirigent-ui/src/workspace/render.rs`, add a call to render the toast overlay. It should be rendered last (on top of everything else) as an absolutely-positioned element in the bottom-right corner. + +- [ ] **Step 7: Add `mod toast_render;` to workspace mod.rs** + +In `crates/codirigent-ui/src/workspace/mod.rs`, add with the other `#[cfg(feature = "gpui-full")]` module declarations: + +```rust +#[cfg(feature = "gpui-full")] +mod toast_render; +``` + +- [ ] **Step 8: Verify full workspace compiles** + +Run: `cd /Users/cyw/Desktop/github/Dirigent/.worktrees/auto-update && cargo check --all --all-targets` + +Expected: Compiles with no errors. + +- [ ] **Step 9: Run all tests** + +Run: `cd /Users/cyw/Desktop/github/Dirigent/.worktrees/auto-update && cargo test --all --all-targets` + +Expected: All tests pass (existing + new). + +- [ ] **Step 10: Commit** + +```bash +git add crates/codirigent-ui/ crates/codirigent-updater/ +git commit -m "feat: integrate auto-update toast notification into workspace UI" +``` diff --git a/docs/superpowers/plans/2026-03-16-restore-cli-on-startup.md b/docs/superpowers/plans/2026-03-16-restore-cli-on-startup.md new file mode 100644 index 00000000..d68572a9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-restore-cli-on-startup.md @@ -0,0 +1,388 @@ +# Restore CLI on Startup Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a user settings toggle that controls whether CLI resume commands are sent during session restore, treating sessions as generic shells when disabled. + +**Architecture:** Three focused changes — add the config field, gate four CLI behaviours in the restore path on that field, then wire a toggle in the settings UI. Each task is independent and can be committed separately. + +**Tech Stack:** Rust, serde_json, GPUI 0.2, codirigent-core config types + +--- + +## File Map + +| File | Role | +|------|------| +| `crates/codirigent-core/src/config.rs` | Add `restore_cli_on_startup` field to `GeneralSettings` | +| `crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs` | Gate CLI type, codex metadata, resume commands on the setting | +| `crates/codirigent-ui/src/workspace/settings_panels.rs` | Add toggle row in General → Startup section | + +**Working directory for all commands:** Run this once before starting any task: + +```bash +cd /Users/cyw/Desktop/github/Dirigent/.worktrees/feature/session-restore-prompt +``` + +--- + +## Task 1: Add `restore_cli_on_startup` to `GeneralSettings` + +**Files:** +- Modify: `crates/codirigent-core/src/config.rs` + +### Background + +`GeneralSettings` lives in `codirigent-core/src/config.rs`. It uses a hand-written `impl Default` (not `#[derive(Default)]`). The `default_true()` free function already exists in this file and is used by `NotificationSettings` fields — reuse it. The `#[serde(default = "default_true")]` attribute is required on the new field because existing settings files may have a `general` key but lack this field; without it, deserialization would fail. + +- [ ] **Step 1: Write the failing tests** + +In the `#[cfg(test)]` module at the bottom of `config.rs`, find the `// UserSettings tests` comment block. Add a new `// GeneralSettings tests` comment block immediately before `// AppearanceSettings tests` (which follows the UserSettings tests): + +```rust +#[test] +fn test_general_settings_restore_cli_defaults_true() { + let settings = GeneralSettings::default(); + assert!(settings.restore_cli_on_startup); +} + +#[test] +fn test_general_settings_restore_cli_serialization() { + let settings = GeneralSettings { + editor_command: "vim".to_string(), + default_shell: String::new(), + default_working_dir: None, + show_splash: true, + restore_cli_on_startup: false, + }; + let json = serde_json::to_string(&settings).unwrap(); + let parsed: GeneralSettings = serde_json::from_str(&json).unwrap(); + assert!(!parsed.restore_cli_on_startup); +} + +#[test] +fn test_general_settings_restore_cli_backward_compat() { + // Existing settings files lack this field — must deserialize as true + let json = r#"{"editor_command":"code","default_shell":"","show_splash":true}"#; + let parsed: GeneralSettings = serde_json::from_str(json).unwrap(); + assert!(parsed.restore_cli_on_startup); +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cargo test -p codirigent-core test_general_settings_restore_cli 2>&1 | tail -10 +``` + +Expected: compile error — `restore_cli_on_startup` does not exist yet, or struct literal missing field. + +- [ ] **Step 3: Add the field to `GeneralSettings`** + +In `crates/codirigent-core/src/config.rs`, add after `show_splash`: + +```rust +/// Resume CLI sessions (claude/codex/gemini) on startup restore. +/// When false, sessions open as generic shells with no CLI launched. +#[serde(default = "default_true")] +pub restore_cli_on_startup: bool, +``` + +- [ ] **Step 4: Add the field to `impl Default for GeneralSettings`** + +```rust +impl Default for GeneralSettings { + fn default() -> Self { + Self { + editor_command: "code".to_string(), + default_shell: String::new(), + default_working_dir: None, + show_splash: true, + restore_cli_on_startup: true, + } + } +} +``` + +- [ ] **Step 5: Run tests to confirm they pass** + +```bash +cargo test -p codirigent-core test_general_settings_restore_cli 2>&1 | tail -10 +``` + +Expected: `test result: ok. 3 passed` + +- [ ] **Step 6: Run the full config test suite to check for regressions** + +```bash +cargo test -p codirigent-core 2>&1 | tail -5 +``` + +Expected: all tests pass, 0 failed. + +- [ ] **Step 7: Commit** + +```bash +git add crates/codirigent-core/src/config.rs +git commit -m "feat: add restore_cli_on_startup to GeneralSettings" +``` + +--- + +## Task 2: Gate CLI restore behaviour in `finalize_restored_session_bootstrap` + +**Files:** +- Modify: `crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs` + +### Background + +`finalize_restored_session_bootstrap` is a `&mut self` method in `WorkspaceView`. It is the sole place where CLI resume commands, the CLI type badge, and codex session metadata are applied during restore. All four must be gated on `restore_cli_on_startup`. + +Read the existing function body carefully before editing — the changes interleave with existing code. The current structure is (simplified): + +1. `start_bootstrapped_session_monitoring(...)` — leave alone +2. `sync_manager_session_shell(...)` — leave alone +3. `record_effective_session_shell(...)` — leave alone +4. `record_restored_shell_warning(...)` — leave alone +5. `clipboard_service.set_session_cli_type(...)` ← **gate (a)** +6. `if plan.codex_execution_mode.is_some() || ...` block ← **gate (b)** +7. `let mut session = bootstrapped.session; session.codex_execution_mode = ...` ← **gate (c)** +8. `attach_bootstrapped_session(...)` — leave alone +9. `set_session_group(...)` — leave alone +10. `for command in restore_resume_commands(...)` ← **gate (d)** + +### Tests + +`finalize_restored_session_bootstrap` requires a full `WorkspaceView` and cannot be easily unit tested in isolation. The pure helper functions it calls are already tested. Add one focused unit test for `restore_plan_cli_type` confirming the `GenericShell` fallback is the correct value to use when gating, and one confirming `restore_resume_commands` returns empty for a plan with no CLI fields (documents the no-op path when all CLI fields are `None`): + +- [ ] **Step 1: Write confirmatory tests (these document existing behaviour and will pass immediately)** + +Add inside the existing `mod tests` block in `impl_session_lifecycle.rs`: + +```rust +#[test] +fn restore_plan_cli_type_returns_generic_shell_for_empty_plan() { + let plan = RestoreSessionPlan { + original_session_id: SessionId(1), + session_uuid: "uuid".to_string(), + session_name: "Session 1".to_string(), + working_dir: sample_working_dir(), + shell: None, + group: None, + color: None, + claude_resume: None, + codex_resume: None, + codex_execution_mode: None, + codex_started_at: None, + gemini_resume: None, + }; + assert_eq!(restore_plan_cli_type(&plan), CliType::GenericShell); +} + +#[test] +fn restore_resume_commands_empty_for_plan_with_no_cli_fields() { + let plan = RestoreSessionPlan { + original_session_id: SessionId(1), + session_uuid: "uuid".to_string(), + session_name: "Session 1".to_string(), + working_dir: sample_working_dir(), + shell: None, + group: None, + color: None, + claude_resume: None, + codex_resume: None, + codex_execution_mode: None, + codex_started_at: None, + gemini_resume: None, + }; + assert!(restore_resume_commands(&plan).is_empty()); +} +``` + +- [ ] **Step 2: Run tests to confirm they pass (these document existing behaviour)** + +```bash +cargo test -p codirigent-ui restore_plan_cli_type_returns_generic_shell 2>&1 | tail -5 +cargo test -p codirigent-ui restore_resume_commands_empty_for_plan 2>&1 | tail -5 +``` + +Expected: both pass. + +- [ ] **Step 3: Apply the four gates to `finalize_restored_session_bootstrap`** + +At the **top** of `finalize_restored_session_bootstrap`, immediately after the function opening brace, add: + +```rust +let restore_cli = self.effective_user_settings().general.restore_cli_on_startup; +``` + +**Gate (a)** — replace the existing `set_session_cli_type` call: + +```rust +// Before: +self.clipboard + .clipboard_service + .set_session_cli_type(bootstrapped.session_id, restore_plan_cli_type(&plan)); + +// After: +let cli_type = if restore_cli { + restore_plan_cli_type(&plan) +} else { + CliType::GenericShell +}; +self.clipboard + .clipboard_service + .set_session_cli_type(bootstrapped.session_id, cli_type); +``` + +**Gate (b)** — add `restore_cli &&` to the codex session manager block: + +```rust +// Before: +if plan.codex_execution_mode.is_some() || plan.codex_started_at.is_some() { + +// After: +if restore_cli && (plan.codex_execution_mode.is_some() || plan.codex_started_at.is_some()) { +``` + +**Gate (c)** — after `session.codex_started_at = plan.codex_started_at;` (line ~1431), add: + +```rust +if !restore_cli { + session.codex_execution_mode = None; + session.codex_started_at = None; +} +``` + +**Gate (d)** — wrap the existing resume commands loop: + +```rust +// Before: +for command in restore_resume_commands(&plan) { + ... +} + +// After: +if restore_cli { + for command in restore_resume_commands(&plan) { + if let Ok(manager) = self.session_manager.lock() { + if let Err(error) = manager.send_input(bootstrapped.session_id, command.as_bytes()) { + warn!(?bootstrapped.session_id, %error, "Failed to send resume command"); + } + } + } +} +``` + +- [ ] **Step 4: Confirm the crate compiles** + +```bash +cargo build -p codirigent-ui 2>&1 | tail -5 +``` + +Expected: `Finished` with no errors. + +- [ ] **Step 5: Run the full test suite** + +```bash +cargo test --all 2>&1 | tail -5 +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs +git commit -m "feat: gate CLI restore on restore_cli_on_startup setting" +``` + +--- + +## Task 3: Add the settings toggle to the General panel + +**Files:** +- Modify: `crates/codirigent-ui/src/workspace/settings_panels.rs` + +### Background + +`render_general_settings` builds the General settings page. Near the top of the function, several `let` bindings extract values from `page.user_settings` — this is required because `page` is an immutable borrow and the toggle callbacks need mutable access to `this`. All existing toggles follow the same pattern. + +The Startup section already contains the `show_splash` toggle. The new row goes **after** `show_splash` and **before** the `.child(settings_section_header("Notifications", theme, false))` line. + +`setting_row(label, description, theme, control)` is a free function defined in this file. `theme` is already extracted as a local near the top of `render_general_settings`. + +### No automated test + +Settings panel rendering requires the GPUI test harness and is not unit-tested in this codebase. Verify visually by running the app and opening Settings → General. + +- [ ] **Step 1: Extract the local bool at the top of `render_general_settings`** + +Find the block of `let` extractions at the top of `render_general_settings` (near `let show_splash = page.user_settings.general.show_splash;`) and add: + +```rust +let restore_cli_on_startup = page.user_settings.general.restore_cli_on_startup; +``` + +- [ ] **Step 2: Add the toggle row after `show_splash` and before the Notifications header** + +Find: +```rust + .child(settings_section_header("Notifications", theme, false)) +``` + +Insert immediately before it: + +```rust + .child(setting_row( + "Restore AI sessions", + "Resume previous Claude/Codex/Gemini sessions on startup", + theme, + self.render_toggle_control( + "toggle-restore-cli", + restore_cli_on_startup, + cx, + |this, _, cx| { + if let Some(page) = this.settings.page.as_mut() { + page.user_settings.general.restore_cli_on_startup = + !page.user_settings.general.restore_cli_on_startup; + page.user_save_pending = true; + } + cx.notify(); + }, + ), + )) +``` + +- [ ] **Step 3: Confirm the crate compiles** + +```bash +cargo build -p codirigent-ui 2>&1 | tail -5 +``` + +Expected: `Finished` with no errors. + +- [ ] **Step 4: Run the full test suite** + +```bash +cargo test --all 2>&1 | tail -5 +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/codirigent-ui/src/workspace/settings_panels.rs +git commit -m "feat: add restore AI sessions toggle in General settings" +``` + +--- + +## Final Verification + +- [ ] Run `cargo clippy --all -- -D warnings` and fix any warnings +- [ ] Run `cargo test --all` one final time — all tests pass +- [ ] Open the app, go to Settings → General → Startup, confirm the new toggle appears between "Show splash screen" and the Notifications section +- [ ] Toggle it off, quit, relaunch — confirm sessions restore as shells only (no CLI prompt) +- [ ] Toggle it back on, quit, relaunch — confirm sessions restore with CLI resume commands diff --git a/docs/superpowers/plans/2026-03-17-tab-status-indicator.md b/docs/superpowers/plans/2026-03-17-tab-status-indicator.md new file mode 100644 index 00000000..d4fcce76 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-tab-status-indicator.md @@ -0,0 +1,698 @@ +# Tab Status Indicator Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move session status indicator from pane header into each session tab, with three configurable styles (dot, badge, glow) and pulse animation for NeedsAttention/ResponseReady states. + +**Architecture:** New `tab_status_render.rs` module handles per-style rendering. Config gets a new `tab_status_style` string field. Pulse animation piggybacks on the existing 250ms maintenance polling loop. Settings page gets a dropdown in the Appearance section. + +**Tech Stack:** Rust, GPUI 0.2, serde, codirigent-core config system + +**Spec:** `docs/superpowers/specs/2026-03-17-tab-status-indicator-design.md` + +**Verification workflow:** `docs/task-verification-workflow.md` — run full matrix after each task. + +--- + +### Task 1: Add `tab_status_style` to `AppearanceSettings` + +**Files:** +- Modify: `crates/codirigent-core/src/config.rs:358-382` (AppearanceSettings struct + Default impl) + +- [ ] **Step 1: Write the failing tests** + +Add to the existing `#[cfg(test)] mod tests` block at line 534: + +```rust +// AppearanceSettings tests + +#[test] +fn test_appearance_settings_default_tab_status_style() { + let settings = AppearanceSettings::default(); + assert_eq!(settings.tab_status_style, "dot"); +} + +#[test] +fn test_appearance_settings_tab_status_style_serialization() { + for style in &["dot", "badge", "glow"] { + let settings = AppearanceSettings { + tab_status_style: style.to_string(), + ..Default::default() + }; + let json = serde_json::to_string(&settings).unwrap(); + let parsed: AppearanceSettings = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.tab_status_style, *style); + } +} + +#[test] +fn test_appearance_settings_missing_tab_status_style_defaults() { + let json = r#"{"theme":"dark","font_size":13.0,"grid_gap":4}"#; + let parsed: AppearanceSettings = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.tab_status_style, "dot"); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p codirigent-core --lib -- tests::test_appearance_settings` +Expected: FAIL — `tab_status_style` field does not exist. + +- [ ] **Step 3: Add the field to AppearanceSettings** + +In `config.rs`, add to the `AppearanceSettings` struct (after `grid_gap`): + +```rust +/// Tab status indicator style: "dot", "badge", or "glow". +#[serde(default = "AppearanceSettings::default_tab_status_style")] +pub tab_status_style: String, +``` + +Add to the `impl AppearanceSettings` block: + +```rust +fn default_tab_status_style() -> String { + "dot".to_string() +} +``` + +Update the `Default` impl to include: + +```rust +tab_status_style: "dot".to_string(), +``` + +**Also fix the existing test** `test_appearance_settings_serialization` (line 882) which constructs `AppearanceSettings` without the new field — add `..Default::default()`: + +```rust +let settings = AppearanceSettings { + theme: "light".to_string(), + font_size: 14.0, + grid_gap: 8, + ..Default::default() +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p codirigent-core --lib -- tests::test_appearance_settings` +Expected: PASS (all 3 new tests) + +- [ ] **Step 5: Run verification matrix** + +```bash +cargo clean +cargo build --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo fmt --all --check +bash scripts/audit-unwraps.sh +``` + +- [ ] **Step 6: Commit** + +```bash +git add crates/codirigent-core/src/config.rs +git commit -m "feat: add tab_status_style to AppearanceSettings + +Add configurable tab status indicator style with 'dot' default. +Supports 'dot', 'badge', and 'glow' variants." +``` + +--- + +### Task 2: Create `tab_status_render.rs` module + +**Files:** +- Create: `crates/codirigent-ui/src/workspace/tab_status_render.rs` +- Modify: `crates/codirigent-ui/src/workspace/mod.rs:119-120` (add module declaration) + +- [ ] **Step 1: Write the failing tests** + +Create the new file with tests first: + +```rust +//! Tab status indicator rendering for session tabs. +//! +//! Provides three configurable styles (dot, badge, glow) for showing +//! session status on tab pills. Animation policy: only NeedsAttention +//! and ResponseReady pulse, and only on background (non-active) tabs. + +use codirigent_core::SessionStatus; +use gpui::Hsla; + +/// Decoration produced by the tab status renderer. +/// +/// The caller uses this to apply the status indicator to each tab: +/// - `child`: a dot/badge element to prepend or append to the tab name +/// - `tab_bg` / `tab_border`: background tint for glow style +/// - `should_pulse`: whether this tab should animate (pulse opacity) +pub struct TabStatusDecoration { + /// Optional child element (dot/badge circle). None for glow style. + pub child: Option, + /// Optional background color for the tab container (glow style). + pub tab_bg: Option, + /// Optional border color for the tab container (glow style). + pub tab_border: Option, + /// Whether this tab should pulse (NeedsAttention/ResponseReady on background tabs). + pub should_pulse: bool, +} + +/// Map a `SessionStatus` to its indicator color. +fn status_color(status: SessionStatus) -> Hsla { + use gpui::rgba; + match status { + SessionStatus::Idle => rgba(0x52525bff).into(), + SessionStatus::Working => rgba(0xf59e0bff).into(), + SessionStatus::NeedsAttention => rgba(0xf43f5eff).into(), + SessionStatus::ResponseReady => rgba(0x22c55eff).into(), + SessionStatus::Error => rgba(0xef4444ff).into(), + } +} + +/// Whether the given status should pulse on a background tab. +fn should_pulse_status(status: SessionStatus, is_active: bool) -> bool { + if is_active { + return false; + } + matches!( + status, + SessionStatus::NeedsAttention | SessionStatus::ResponseReady + ) +} + +/// Render tab status decoration for the given style. +/// +/// `style` is one of "dot", "badge", or "glow". Unknown values fall back to "dot". +/// `is_active` is true for the currently visible tab in the pane. +pub fn render_tab_status( + style: &str, + status: SessionStatus, + is_active: bool, +) -> TabStatusDecoration { + let color = status_color(status); + let pulse = should_pulse_status(status, is_active); + + match style { + "glow" => TabStatusDecoration { + child: None, + tab_bg: Some(color.opacity(0.15)), + tab_border: Some(color.opacity(0.25)), + should_pulse: pulse, + }, + // "dot", "badge", and any unknown value all produce a dot child. + // The caller decides placement (prepend for "dot", append for "badge"). + _ => { + use gpui::{div, px, IntoElement, ParentElement, Styled}; + let dot = div() + .w(px(8.0)) + .h(px(8.0)) + .rounded_full() + .bg(color) + .flex_shrink_0() + .into_any_element(); + TabStatusDecoration { + child: Some(dot), + tab_bg: None, + tab_border: None, + should_pulse: pulse, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── status_color tests ────────────────────────────────────────── + + #[test] + fn test_status_color_idle() { + let color = status_color(SessionStatus::Idle); + // Gray: #52525b → verify non-zero (exact HSLA conversion is lossy) + assert!(color.a > 0.0); + } + + #[test] + fn test_status_color_all_variants_are_distinct() { + let colors: Vec = [ + SessionStatus::Idle, + SessionStatus::Working, + SessionStatus::NeedsAttention, + SessionStatus::ResponseReady, + SessionStatus::Error, + ] + .iter() + .map(|s| status_color(*s)) + .collect(); + + // Each adjacent pair should differ + for i in 0..colors.len() - 1 { + assert_ne!( + (colors[i].h, colors[i].s), + (colors[i + 1].h, colors[i + 1].s), + "colors for variants {} and {} should differ", + i, + i + 1 + ); + } + } + + // ── should_pulse_status tests ─────────────────────────────────── + + #[test] + fn test_pulse_needs_attention_background() { + assert!(should_pulse_status(SessionStatus::NeedsAttention, false)); + } + + #[test] + fn test_pulse_response_ready_background() { + assert!(should_pulse_status(SessionStatus::ResponseReady, false)); + } + + #[test] + fn test_no_pulse_needs_attention_active() { + assert!(!should_pulse_status(SessionStatus::NeedsAttention, true)); + } + + #[test] + fn test_no_pulse_response_ready_active() { + assert!(!should_pulse_status(SessionStatus::ResponseReady, true)); + } + + #[test] + fn test_no_pulse_idle() { + assert!(!should_pulse_status(SessionStatus::Idle, false)); + assert!(!should_pulse_status(SessionStatus::Idle, true)); + } + + #[test] + fn test_no_pulse_working() { + assert!(!should_pulse_status(SessionStatus::Working, false)); + assert!(!should_pulse_status(SessionStatus::Working, true)); + } + + #[test] + fn test_no_pulse_error() { + assert!(!should_pulse_status(SessionStatus::Error, false)); + assert!(!should_pulse_status(SessionStatus::Error, true)); + } + + // ── render_tab_status tests ───────────────────────────────────── + // + // Note: render_tab_status for "dot"/"badge" styles creates GPUI div + // elements via div().into_any_element(), which may require a GPUI + // context. If these tests fail at runtime, guard them behind a GPUI + // test context or test only the glow path (which produces no elements) + // and the pure functions above. + + #[test] + fn test_glow_style_has_bg_no_child() { + let dec = render_tab_status("glow", SessionStatus::Working, false); + assert!(dec.child.is_none()); + assert!(dec.tab_bg.is_some()); + assert!(dec.tab_border.is_some()); + } + + #[test] + fn test_glow_pulse_on_needs_attention_background() { + let dec = render_tab_status("glow", SessionStatus::NeedsAttention, false); + assert!(dec.should_pulse); + } + + #[test] + fn test_glow_no_pulse_on_active_tab() { + let dec = render_tab_status("glow", SessionStatus::NeedsAttention, true); + assert!(!dec.should_pulse); + } + + #[test] + fn test_glow_no_pulse_idle() { + let dec = render_tab_status("glow", SessionStatus::Idle, false); + assert!(!dec.should_pulse); + } + + // Dot/badge tests that create GPUI elements — if these fail without + // a GPUI context, move them to an integration test or remove them + // and rely on the pure function tests above. + #[test] + fn test_dot_style_has_child_no_bg() { + let dec = render_tab_status("dot", SessionStatus::Working, false); + assert!(dec.child.is_some()); + assert!(dec.tab_bg.is_none()); + assert!(dec.tab_border.is_none()); + } + + #[test] + fn test_badge_style_has_child_no_bg() { + let dec = render_tab_status("badge", SessionStatus::Idle, true); + assert!(dec.child.is_some()); + assert!(dec.tab_bg.is_none()); + assert!(dec.tab_border.is_none()); + } + + #[test] + fn test_unknown_style_falls_back_to_dot() { + let dec = render_tab_status("unknown", SessionStatus::Idle, false); + assert!(dec.child.is_some()); + assert!(dec.tab_bg.is_none()); + } +} +``` + +- [ ] **Step 2: Add module declaration to mod.rs** + +In `crates/codirigent-ui/src/workspace/mod.rs`, after line 120 (`mod pane_header_render;`), add: + +```rust +#[cfg(feature = "gpui-full")] +mod tab_status_render; +``` + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `cargo test -p codirigent-ui --lib --features gpui-full -- tab_status_render::tests` +Expected: PASS (all tests) + +- [ ] **Step 4: Run verification matrix** + +```bash +cargo clean +cargo build --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo fmt --all --check +bash scripts/audit-unwraps.sh +``` + +- [ ] **Step 5: Commit** + +```bash +git add crates/codirigent-ui/src/workspace/tab_status_render.rs crates/codirigent-ui/src/workspace/mod.rs +git commit -m "feat: add tab_status_render module with tests + +TabStatusDecoration struct, status_color mapping, pulse logic, +and render_tab_status function for dot/badge/glow styles." +``` + +--- + +### Task 3: Add `pulse_counter` to WorkspaceView and increment in maintenance loop + +**Files:** +- Modify: `crates/codirigent-ui/src/workspace/gpui.rs:80-150` (WorkspaceView struct) +- Modify: `crates/codirigent-ui/src/workspace/impl_output_polling.rs:170-175` (poll_maintenance) + +- [ ] **Step 1: Add `pulse_counter` field to WorkspaceView** + +In `gpui.rs`, add to the `WorkspaceView` struct (after line 146, before the closing `}`): + +```rust +/// Counter incremented each maintenance poll cycle for tab pulse animation. +/// Render code derives pulse phase from `pulse_counter % 6` (3 ticks on, 3 off = ~750ms each). +pub(super) pulse_counter: u8, +``` + +Find the `WorkspaceView::new()` constructor and ensure `pulse_counter: 0` is set in the struct initialization. + +- [ ] **Step 2: Increment counter in poll_maintenance** + +In `impl_output_polling.rs`, at the end of `poll_maintenance()` (around line 175), add: + +```rust +self.pulse_counter = self.pulse_counter.wrapping_add(1); +``` + +- [ ] **Step 3: Run verification matrix** + +```bash +cargo clean +cargo build --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo fmt --all --check +bash scripts/audit-unwraps.sh +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/codirigent-ui/src/workspace/gpui.rs crates/codirigent-ui/src/workspace/impl_output_polling.rs +git commit -m "feat: add pulse_counter for tab animation + +Piggybacks on existing 250ms maintenance loop. No new timer." +``` + +--- + +### Task 4: Integrate tab status into tab strip and remove header dot + +**Files:** +- Modify: `crates/codirigent-ui/src/workspace/pane_header_render.rs:30-278` + +- [ ] **Step 1: Remove the header status dot** + +In `render_pane_header()` (around line 62), remove this line: + +```rust +.child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(status_color)) +``` + +Also remove the `status_color` variable declaration at line 35: + +```rust +let status_color: gpui::Hsla = hints.status.color.into(); +``` + +- [ ] **Step 2: Add status indicator to each tab in render_pane_tab_strip** + +In `render_pane_tab_strip()`, inside the `for tab_session_id in pane_tab_ids` loop (around line 172): + +After getting `tab_name` and before creating the `tab` div, add: + +```rust +let tab_status = self + .workspace() + .session(tab_session_id) + .map(|s| s.status) + .unwrap_or(SessionStatus::Idle); +let tab_status_style = self + .effective_user_settings() + .appearance + .tab_status_style + .as_str(); +let decoration = super::tab_status_render::render_tab_status( + tab_status_style, + tab_status, + tab_is_active, +); +``` + +Modify the `tab_bg` assignment to incorporate glow: + +```rust +let tab_bg = if let Some(glow_bg) = decoration.tab_bg { + if tab_is_active { + // Active tab keeps theme color but with subtle glow overlay + let mut base: gpui::Hsla = theme.active.into(); + base.h = glow_bg.h; + base.s = glow_bg.s.max(base.s); + base + } else { + glow_bg + } +} else if tab_is_active { + theme.active.into() +} else { + border_color.opacity(0.35) +}; +``` + +Add optional glow border to the tab div (after `.bg(tab_bg)`): + +```rust +let mut tab = div() + // ... existing properties ... + .bg(tab_bg); + +// Apply glow border if present +if let Some(glow_border) = decoration.tab_border { + tab = tab.border_1().border_color(glow_border); +} + +// Apply pulse opacity for animated states +if decoration.should_pulse { + let phase = self.pulse_counter % 6; + let opacity = if phase < 3 { 1.0 } else { 0.4 }; + tab = tab.opacity(opacity); +} +``` + +**IMPORTANT: Restructure the tab's children.** The existing code (lines 219-231) adds the name child inline via `.child(div().text_xs()...child(tab_name))` in the builder chain. You must **remove** this `.child(...)` from the builder chain and replace it with the sequenced approach below. The builder chain should end at `.cursor_pointer()` and the `.on_click(...)` handler, then children are added separately: + +```rust +// After creating `tab` div with properties + on_click handler, +// but WITHOUT the existing .child(div().text_xs()...child(tab_name)): + +// 1. Prepend dot (before name) for "dot" style or unknown styles +if tab_status_style != "badge" && tab_status_style != "glow" { + if let Some(child) = decoration.child { + tab = tab.child(child); + } +} + +// 2. Add the name label (moved from the original builder chain) +tab = tab.child( + div() + .text_xs() + .font_weight(if tab_is_active { + FontWeight::SEMIBOLD + } else { + FontWeight::MEDIUM + }) + .text_color(tab_fg) + .overflow_hidden() + .text_ellipsis() + .child(tab_name), +); + +// 3. Append badge (after name) for "badge" style +if tab_status_style == "badge" { + if let Some(child) = decoration.child { + tab = tab.child(child); + } +} +``` + +- [ ] **Step 3: Add necessary imports** + +At the top of `pane_header_render.rs`, ensure `SessionStatus` is imported: + +```rust +use codirigent_core::SessionStatus; +``` + +- [ ] **Step 4: Run verification matrix** + +```bash +cargo clean +cargo build --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo fmt --all --check +bash scripts/audit-unwraps.sh +``` + +- [ ] **Step 5: Commit** + +```bash +git add crates/codirigent-ui/src/workspace/pane_header_render.rs +git commit -m "feat: show status indicator on tabs, remove header dot + +Each tab now displays a status dot/badge/glow based on the +tab_status_style setting. Pulse animation for NeedsAttention +and ResponseReady on background tabs." +``` + +--- + +### Task 5: Add settings dropdown for tab status style + +**Files:** +- Modify: `crates/codirigent-ui/src/workspace/settings_panels.rs:723-841` + +- [ ] **Step 1: Add the dropdown to render_appearance_settings** + +In `render_appearance_settings()`, after the grid gap setting row (around line 839, before `.into_any_element()`), add: + +```rust +.child(settings_section_header("Status", theme, false)) +.child(setting_row( + "Tab status style", + "How session status is shown on tabs (dot, badge, or glow)", + theme, + self.render_dropdown_control( + "dd-tab-status-style", + &["dot", "badge", "glow"], + &page.user_settings.appearance.tab_status_style, + cx, + |this, val, _, cx| { + if let Some(page) = this.settings.page.as_mut() { + page.user_settings.appearance.tab_status_style = val; + page.user_save_pending = true; + } + cx.notify(); + }, + ), +)) +``` + +Note: need to read `tab_status_style` from `page` before the `div()` builder chain, similar to how `theme_id`, `font_size`, and `grid_gap` are extracted. Add at line 731: + +```rust +let tab_status_style = page.user_settings.appearance.tab_status_style.clone(); +``` + +Then use `&tab_status_style` in the dropdown `selected` parameter. + +**Note on settings wiring tests:** The spec requires tests for dropdown selection, save pending, and option mapping. However, the existing settings panels in the codebase have no unit tests (they require a full GPUI window context). This is an accepted pattern — settings wiring is verified by the build (compile-time correctness) and the verification matrix (no regressions). The dropdown follows the exact same pattern as cursor_style and theme dropdowns, which also have no unit tests. + +- [ ] **Step 2: Run verification matrix** + +```bash +cargo clean +cargo build --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo fmt --all --check +bash scripts/audit-unwraps.sh +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/codirigent-ui/src/workspace/settings_panels.rs +git commit -m "feat: add tab status style dropdown to Appearance settings + +Users can choose between dot, badge, and glow styles." +``` + +--- + +### Task 6: Final integration review + +- [ ] **Step 1: Run full verification matrix from clean state** + +```bash +cargo clean +cargo build --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo fmt --all --check +bash scripts/audit-unwraps.sh +``` + +- [ ] **Step 2: Depth code review** + +Review the full diff for: +- Behavioral regressions (header dot removal doesn't break anything) +- Missing edge cases (single-tab pane, empty workspace) +- Cross-platform issues (macOS + Windows rendering) +- Dead code from removed status dot +- UI/UX quality (dot sizing, spacing, pulse timing) + +```bash +git diff integration/all-features..HEAD -- crates/ +git status --short +``` + +- [ ] **Step 3: Summarize all commits and wait for human review** + +List all commits, note any pre-existing issues observed, stop and report. diff --git a/docs/superpowers/specs/2026-03-16-restore-cli-on-startup-design.md b/docs/superpowers/specs/2026-03-16-restore-cli-on-startup-design.md new file mode 100644 index 00000000..dc2e2a45 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-restore-cli-on-startup-design.md @@ -0,0 +1,164 @@ +# Design: Restore CLI on Startup Setting + +**Date:** 2026-03-16 +**Branch:** feature/session-restore-prompt + +## Summary + +Add a user-level settings toggle — **Restore AI sessions** — that controls whether CLI resume commands (`claude --resume`, `codex resume`, `gemini --resume`) are automatically sent to the shell when sessions are restored on startup. The shell, working directory, and layout always restore regardless of this setting. + +## Background + +Currently, session restore always sends CLI resume commands after bootstrapping each shell. There is no way for users to opt out of this behaviour without manually closing the CLI session each time. This setting gives users control over whether they want to resume their previous AI conversation context or start a fresh CLI session in the same directory. + +## Design + +### 1. Config (`codirigent-core/src/config.rs`) + +Add `restore_cli_on_startup: bool` to `GeneralSettings`: + +```rust +#[serde(default = "default_true")] +pub restore_cli_on_startup: bool, +``` + +Also add to `impl Default for GeneralSettings`: + +```rust +restore_cli_on_startup: true, +``` + +Both are required: +- `impl Default` — ensures `GeneralSettings::default()` compiles and returns the correct value. +- `#[serde(default = "default_true")]` — handles backward-compatible deserialization of existing settings files that contain a `general` key but lack this new field. `show_splash` has no such attribute because it was an original field and always exists in saved files; new fields added to an existing struct must carry field-level serde defaults. + +- Default: `true` (preserves current behaviour) +- Stored via the existing settings service (platform-specific path resolved at runtime) +- Note: `default_true()` helper already exists in `config.rs` and can be reused directly. + +### 2. Session restore gate (`codirigent-ui/src/workspace/impl_session_lifecycle.rs`) + +Extract the flag once at the top of `finalize_restored_session_bootstrap`, then gate all CLI-specific state on it: + +```rust +let restore_cli = self.effective_user_settings().general.restore_cli_on_startup; +``` + +**When `restore_cli = false`, the session is a generic shell.** Four things must be gated: + +**a) CLI type badge** — pass `GenericShell` instead of the saved CLI type: +```rust +let cli_type = if restore_cli { + restore_plan_cli_type(&plan) +} else { + CliType::GenericShell +}; +self.clipboard + .clipboard_service + .set_session_cli_type(bootstrapped.session_id, cli_type); +``` + +**b) Codex session manager state** — skip setting `codex_execution_mode` / `codex_started_at` on the session manager: +```rust +if restore_cli && (plan.codex_execution_mode.is_some() || plan.codex_started_at.is_some()) { + // ... existing with_session_state_mut block unchanged +} +``` + +**c) Codex session struct fields** — clear them on the session before attaching: +```rust +if !restore_cli { + session.codex_execution_mode = None; + session.codex_started_at = None; +} +``` + +**d) Resume commands** — skip sending: +```rust +if restore_cli { + for command in restore_resume_commands(&plan) { + if let Ok(manager) = self.session_manager.lock() { + if let Err(error) = manager.send_input(bootstrapped.session_id, command.as_bytes()) { + warn!(?bootstrapped.session_id, %error, "Failed to send resume command"); + } + } + } +} +``` + +Extracting to a local `bool` is a clarity/defensive measure. `effective_user_settings(&self)` takes only `&self` — no `cx` parameter needed. + +`finalize_restored_session_bootstrap` is the **sole call site** of `restore_resume_commands` and the sole place CLI type + codex metadata are applied during restore. + +### 3. Settings UI (`codirigent-ui/src/workspace/settings_panels.rs`) + +In `render_general_settings`, extract the value into a local `bool` (Copy) at the top of the function alongside the other locals (e.g. `show_splash`, `theme`): + +```rust +let restore_cli_on_startup = page.user_settings.general.restore_cli_on_startup; +``` + +Must be a `bool` copy (not a reference), to avoid a borrow conflict with the closure that later mutably borrows `this`. `theme` is already extracted as a local earlier in the function and is required by `setting_row`. + +Add a `setting_row(...)` call under the existing **Startup** section, **after `show_splash` and before the Notifications section header**. The full call site shape: + +```rust +.child(setting_row( + "Restore AI sessions", + "Resume previous Claude/Codex/Gemini sessions on startup", + theme, + self.render_toggle_control("toggle-restore-cli", restore_cli_on_startup, cx, |this, _, cx| { + // callback + }), +)) +``` + +- **Toggle ID:** `toggle-restore-cli` +- **Current value:** `restore_cli_on_startup` (the local extracted above) + +Callback exactly matches the existing pattern (including guard and notify): +```rust +|this, _, cx| { + if let Some(page) = this.settings.page.as_mut() { + page.user_settings.general.restore_cli_on_startup = + !page.user_settings.general.restore_cli_on_startup; + page.user_save_pending = true; + } + cx.notify(); +} +``` + +`user_save_pending = true` triggers the debounced save pipeline. `cx.notify()` triggers a UI re-render. + +## Data Flow + +``` +User toggles setting + → page.user_settings.general.restore_cli_on_startup updated + → page.user_save_pending = true + → debounced save persists via settings service + +On startup (settings load before restore runs, so the setting takes effect the same launch): + settings loaded into cached_user_settings + → spawn_restore_sessions_from_disk + → apply_restore_plan (per session batch) + → finalize_restored_session_bootstrap [sole CLI restore site] + → reads restore_cli_on_startup into local bool + → if true: set CLI type badge, set codex metadata, send resume commands + → if false: set CLI type = GenericShell, skip codex metadata, skip resume commands +``` + +## Files Changed + +| File | Change | +|------|--------| +| `crates/codirigent-core/src/config.rs` | Add `restore_cli_on_startup` field + serde default + `impl Default` + tests | +| `crates/codirigent-ui/src/workspace/settings_panels.rs` | Add toggle in Startup section | +| `crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs` | Gate CLI type, codex metadata, and resume commands on setting + tests | + +## Non-Goals + +- Does not affect shell/PTY restore +- Does not affect layout restore +- Does not affect working directory restore +- No startup prompt or dialog — this is a persistent preference only diff --git a/docs/superpowers/specs/2026-03-17-tab-status-indicator-design.md b/docs/superpowers/specs/2026-03-17-tab-status-indicator-design.md new file mode 100644 index 00000000..f1caf393 --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-tab-status-indicator-design.md @@ -0,0 +1,164 @@ +# Tab Status Indicator Design + +**Date:** 2026-03-17 +**Status:** Draft + +## Problem + +The status dot (Idle/Working/Attention/Ready/Error) only exists in the pane header. When a pane has multiple tabs, there is no way to tell which background session needs attention without switching to it. + +## Solution + +Move the status indicator from the pane header into each session tab. Users can choose from three visual styles via a setting. NeedsAttention and ResponseReady states flash on background tabs to draw the eye. + +## Status States + +| State | Color | Animated (background tab) | +|-------|-------|---------------------------| +| Idle | #52525b (gray) | No | +| Working | #f59e0b (amber) | No | +| NeedsAttention | #f43f5e (rose) | Yes — pulse | +| ResponseReady | #22c55e (green) | Yes — pulse | +| Error | #ef4444 (red) | No | + +Active tabs show the status indicator but never animate (you're already looking at it). + +**Note:** Tab animation rules differ from `StatusIndicator::animated` (which flags Working and NeedsAttention for the pane header). The tab module defines its own animation policy: only NeedsAttention and ResponseReady pulse, and only on background tabs. The existing `StatusIndicator::animated` field is not reused — the tab renderer makes its own decision based on `SessionStatus` and `is_active`. + +## Tab Status Styles (Configurable) + +A new `tab_status_style` field on `AppearanceSettings`, exposed as a dropdown in the Appearance settings page. + +### Dot (default) + +Small 8x8 colored circle to the **left** of the session name inside the tab pill. Mirrors VS Code / chat app conventions. Natural left-to-right scanning: see status, then read name. + +### Badge + +Small 8x8 colored circle to the **right** of the session name. Trailing indicator style, keeps left edge aligned. + +### Glow + +Subtle status-colored tint on the **entire tab background** (`status_color` at ~15% opacity) with a matching border (~25% opacity). No dot element. Flash is a background pulse rather than a dot pulse. + +## Config Changes + +### `AppearanceSettings` (codirigent-core, config.rs) + +Add field: + +```rust +#[serde(default = "default_tab_status_style")] +pub tab_status_style: String, +``` + +Default: `"dot"`. Valid values: `"dot"`, `"badge"`, `"glow"`. + +Follows the existing dropdown pattern used by `theme` in `AppearanceSettings` for the settings UI, and the string-match dispatch pattern used by `cursor_style` in `TerminalSettings` for the rendering code. + +## UI Changes + +### Remove header status dot + +In `pane_header_render.rs`, remove the 8x8 status dot from the pane header (currently around line 62). The header retains all other information (session name, git branch, CLI name, task badge, etc.). + +### New file: `tab_status_render.rs` + +A new file in the `workspace` module containing: + +**Return type:** + +```rust +pub struct TabStatusDecoration { + /// Optional child element (dot/badge circle). None for glow style. + pub child: Option, + /// Optional background color to apply to the tab container. Used by glow style. + pub tab_bg: Option, + /// Optional border color to apply to the tab container. Used by glow style. + pub tab_border: Option, + /// Whether this tab should pulse (NeedsAttention/ResponseReady on background tabs). + pub should_pulse: bool, +} +``` + +**Functions:** + +- `render_tab_status(style: &str, status: SessionStatus, is_active: bool) -> TabStatusDecoration` + - Reads the style string and dispatches to the appropriate renderer + - Unknown style values fall back to "dot" + - Sets `should_pulse = true` only for NeedsAttention/ResponseReady when `is_active == false` +- Dot renderer: returns `TabStatusDecoration` with `child` set to a colored 8x8 circle +- Badge renderer: same as dot (caller decides prepend vs append based on style) +- Glow renderer: returns `TabStatusDecoration` with `tab_bg` and `tab_border` set, no `child` + +**Animation:** The codebase does not currently use GPUI's animation API anywhere. Animation piggybacks on the **existing maintenance polling loop** (250ms interval) to avoid adding a new background timer: + +- Add a `pulse_counter: u8` field to `WorkspaceView` +- Increment it each maintenance poll cycle +- Derive pulse phase: `pulse_counter % 6` — 3 ticks on, 3 ticks off gives ~750ms equal duty cycle +- The render code reads `self.pulse_counter` to choose between full and reduced opacity (1.0 vs 0.4) for pulsing elements +- No new `cx.spawn()`, no new background task — reuses existing infrastructure + +### Tab strip rendering changes (pane_header_render.rs) + +In `render_pane_tab_strip()`, for each tab: + +1. Look up `tab_status_style` via `self.effective_user_settings().appearance.tab_status_style` (always available, in-memory) +2. Look up `SessionStatus` from the session's cached state (already available) +3. Determine `is_active` from the current pane's active session +4. Call `render_tab_status()` from the new module, receiving a `TabStatusDecoration` +5. If `decoration.child` is `Some`: for **dot** style, prepend before the name; for **badge** style, append after the name +6. If `decoration.tab_bg` / `decoration.tab_border` are `Some` (glow style): apply to the tab container div +7. If `decoration.should_pulse`: apply the current pulse phase opacity from the workspace's timer-driven toggle + +### Settings page (settings_panels.rs) + +Add a dropdown in the **Appearance** section: + +- Label: "Tab status style" +- Description: "How session status is shown on tabs" +- Options: `["Dot", "Badge", "Glow"]` +- On change: update `page.user_settings.appearance.tab_status_style` and set `user_save_pending = true` + +Follows the existing dropdown pattern used by `theme` in the Appearance section. + +## Threading & Performance + +- **No new async work in render path**: tab rendering reads cached `SessionStatus` from the session struct and `tab_status_style` from `effective_user_settings()`. Both are in-memory reads — no I/O, no locks, no blocking +- **No new timer**: pulse animation piggybacks on the existing 250ms maintenance polling loop by incrementing a counter. No new `cx.spawn()` or background task +- **No additional render cost**: reading `pulse_counter` is a single integer check. The status color computation is a match on a 5-variant enum. Both are negligible in the render pass +- **Settings wiring**: setting changes set `user_save_pending = true`, which is flushed by the render loop via `maybe_schedule_settings_save()` + +## File Changes Summary + +| File | Change | +|------|--------| +| `codirigent-core/src/config.rs` | Add `tab_status_style` to `AppearanceSettings` with default | +| `codirigent-ui/src/workspace/tab_status_render.rs` | **New** — status rendering per style + animation | +| `codirigent-ui/src/workspace/gpui.rs` | Add `pulse_counter` field to `WorkspaceView` | +| `codirigent-ui/src/workspace/impl_output_polling/*.rs` | Increment `pulse_counter` in maintenance loop | +| `codirigent-ui/src/workspace/pane_header_render.rs` | Remove header dot, integrate tab status rendering | +| `codirigent-ui/src/workspace/settings_panels.rs` | Add dropdown to Appearance section | +| `codirigent-ui/src/workspace/mod.rs` | Add `mod tab_status_render` | + +## Test Coverage + +### Config tests (codirigent-core) + +- Default value is `"dot"` +- Serialization roundtrip for all 3 variants +- Unknown/invalid value falls back to `"dot"` on deserialization +- Backward compatibility: missing `tab_status_style` field deserializes to `"dot"` + +### Tab status rendering tests (codirigent-ui) + +- Each style variant (dot/badge/glow) produces the correct element structure for each `SessionStatus` +- Animation flag is set only for NeedsAttention and ResponseReady +- Animation flag is never set for active tabs regardless of status +- Unknown style string falls back to dot behavior + +### Settings wiring tests (codirigent-ui) + +- Dropdown selection updates `user_settings.appearance.tab_status_style` +- Save is marked pending after selection change +- All 3 dropdown options are present and correctly mapped diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index ea22dd9f..21f52ad0 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -659,11 +659,13 @@ fn test_complete_workflow() { /// Test session state transition: Idle → Working #[test] fn test_session_idle_to_working() { + use codirigent_core::types::session::generate_session_uuid; use codirigent_core::types::{Session, SessionId, SessionStatus}; use std::path::PathBuf; let mut session = Session { id: SessionId(1), + session_uuid: generate_session_uuid(), name: "Test Session".to_string(), status: SessionStatus::Idle, working_directory: PathBuf::from("/tmp"), @@ -694,11 +696,13 @@ fn test_session_idle_to_working() { /// Test session state transition: Working → NeedsAttention #[test] fn test_session_working_to_needs_attention() { + use codirigent_core::types::session::generate_session_uuid; use codirigent_core::types::{Session, SessionId, SessionStatus}; use std::path::PathBuf; let mut session = Session { id: SessionId(1), + session_uuid: generate_session_uuid(), name: "Test Session".to_string(), status: SessionStatus::Working, working_directory: PathBuf::from("/tmp"), @@ -729,11 +733,13 @@ fn test_session_working_to_needs_attention() { /// Test session state transition: NeedsAttention → Idle #[test] fn test_session_needs_attention_to_idle() { + use codirigent_core::types::session::generate_session_uuid; use codirigent_core::types::{Session, SessionId, SessionStatus}; use std::path::PathBuf; let mut session = Session { id: SessionId(1), + session_uuid: generate_session_uuid(), name: "Test Session".to_string(), status: SessionStatus::NeedsAttention, working_directory: PathBuf::from("/tmp"), @@ -764,11 +770,13 @@ fn test_session_needs_attention_to_idle() { /// Test session state transition: Any → Error #[test] fn test_session_to_error_state() { + use codirigent_core::types::session::generate_session_uuid; use codirigent_core::types::{Session, SessionId, SessionStatus}; use std::path::PathBuf; let mut session = Session { id: SessionId(1), + session_uuid: generate_session_uuid(), name: "Test Session".to_string(), status: SessionStatus::Working, working_directory: PathBuf::from("/tmp"), @@ -796,11 +804,13 @@ fn test_session_to_error_state() { /// Test session state invariants #[test] fn test_session_state_invariants() { + use codirigent_core::types::session::generate_session_uuid; use codirigent_core::types::{Session, SessionId, SessionStatus}; use std::path::PathBuf; let session = Session { id: SessionId(1), + session_uuid: generate_session_uuid(), name: "Test Session".to_string(), status: SessionStatus::Idle, working_directory: PathBuf::from("/tmp"),