diff --git a/crates/codirigent-core/src/config.rs b/crates/codirigent-core/src/config.rs index 5fcfb54..4ce4dd5 100644 --- a/crates/codirigent-core/src/config.rs +++ b/crates/codirigent-core/src/config.rs @@ -207,10 +207,94 @@ impl Default for GeneralSettings { } } +/// Internal user-settings migration markers. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct UserSettingsMigrations { + /// Version of the terminal-safe keybinding migration already applied. + #[serde(default)] + pub terminal_safe_keybindings_version: u32, +} + // ============================================================================ // Terminal Settings // ============================================================================ +/// Optional ANSI palette overrides layered on top of the selected theme. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct TerminalPaletteOverrides { + /// ANSI black override. + #[serde(default)] + pub black: String, + /// ANSI red override. + #[serde(default)] + pub red: String, + /// ANSI green override. + #[serde(default)] + pub green: String, + /// ANSI yellow override. + #[serde(default)] + pub yellow: String, + /// ANSI blue override. + #[serde(default)] + pub blue: String, + /// ANSI magenta override. + #[serde(default)] + pub magenta: String, + /// ANSI cyan override. + #[serde(default)] + pub cyan: String, + /// ANSI white override. + #[serde(default)] + pub white: String, + /// ANSI bright black override. + #[serde(default)] + pub bright_black: String, + /// ANSI bright red override. + #[serde(default)] + pub bright_red: String, + /// ANSI bright green override. + #[serde(default)] + pub bright_green: String, + /// ANSI bright yellow override. + #[serde(default)] + pub bright_yellow: String, + /// ANSI bright blue override. + #[serde(default)] + pub bright_blue: String, + /// ANSI bright magenta override. + #[serde(default)] + pub bright_magenta: String, + /// ANSI bright cyan override. + #[serde(default)] + pub bright_cyan: String, + /// ANSI bright white override. + #[serde(default)] + pub bright_white: String, +} + +/// Per-user terminal theme overrides layered on top of the selected theme. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct TerminalThemeOverrides { + /// Terminal background color override. + #[serde(default)] + pub background: String, + /// Terminal foreground color override. + #[serde(default)] + pub foreground: String, + /// Terminal cursor color override. + #[serde(default)] + pub cursor: String, + /// Terminal selection background override. + #[serde(default)] + pub selection_background: String, + /// Terminal selection foreground override. + #[serde(default)] + pub selection_foreground: String, + /// ANSI palette overrides. + #[serde(default)] + pub palette: TerminalPaletteOverrides, +} + /// Terminal rendering preferences. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TerminalSettings { @@ -222,15 +306,39 @@ pub struct TerminalSettings { pub cursor_style: String, /// Line height multiplier (1.0 = natural font height). pub line_height: f32, + /// Per-user terminal theme overrides layered on top of the selected theme. + #[serde(default)] + pub theme_overrides: TerminalThemeOverrides, +} + +/// Default monospace terminal font family for the current platform. +pub fn default_terminal_font_family() -> &'static str { + #[cfg(target_os = "windows")] + { + "Consolas" + } + #[cfg(target_os = "macos")] + { + "Menlo" + } + #[cfg(all(unix, not(target_os = "macos")))] + { + "DejaVu Sans Mono" + } + #[cfg(not(any(windows, unix)))] + { + "monospace" + } } impl Default for TerminalSettings { fn default() -> Self { Self { - font_family: "JetBrains Mono".to_string(), + font_family: default_terminal_font_family().to_string(), font_size: 13.0, cursor_style: "block".to_string(), line_height: 1.0, + theme_overrides: TerminalThemeOverrides::default(), } } } @@ -286,6 +394,9 @@ pub struct UserSettings { /// User-saved custom layout profiles. #[serde(default)] pub saved_layouts: Vec, + /// Internal migration markers for one-time settings rewrites. + #[serde(default)] + pub migrations: UserSettingsMigrations, } impl Default for UserSettings { @@ -298,6 +409,7 @@ impl Default for UserSettings { modules: ModuleSettings::default(), keybindings: Self::default_keybindings(), saved_layouts: Vec::new(), + migrations: UserSettingsMigrations::default(), } } } @@ -323,19 +435,20 @@ impl UserSettings { 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("toggle_layout".to_string(), format!("{m}+\\")); - bindings.insert("toggle_sidebar".to_string(), format!("{m}+B")); - bindings.insert("toggle_task_board".to_string(), format!("{m}+T")); + bindings.insert("new_session".to_string(), format!("{m}+Shift+N")); + bindings.insert("close_session".to_string(), format!("{m}+Alt+W")); + bindings.insert("toggle_layout".to_string(), format!("{m}+Shift+L")); + bindings.insert("toggle_sidebar".to_string(), format!("{m}+Shift+E")); + bindings.insert("toggle_task_board".to_string(), format!("{m}+Shift+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.insert("search_terminal".to_string(), format!("{m}+Shift+F")); + bindings.insert("split_horizontal".to_string(), format!("{m}+Alt+Shift+H")); + bindings.insert("split_vertical".to_string(), format!("{m}+Alt+Shift+V")); + bindings.insert("close_pane".to_string(), format!("{m}+Alt+Shift+W")); + bindings.insert("quick_switch".to_string(), format!("{m}+Shift+K")); bindings } } @@ -788,6 +901,14 @@ mod tests { assert!(!settings.keybindings.is_empty()); } + #[test] + fn test_terminal_settings_default_font_family_is_platform_specific() { + let settings = TerminalSettings::default(); + assert_eq!(settings.font_family, default_terminal_font_family()); + assert!(settings.theme_overrides.background.is_empty()); + assert!(settings.theme_overrides.palette.bright_white.is_empty()); + } + #[test] fn test_user_settings_serialization() { let settings = UserSettings::default(); @@ -802,22 +923,56 @@ mod tests { let bindings = UserSettings::default_keybindings(); #[cfg(target_os = "macos")] { - 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("toggle_sidebar"), Some(&"Cmd+B".to_string())); + assert_eq!( + bindings.get("new_session"), + Some(&"Cmd+Shift+N".to_string()) + ); + assert_eq!( + bindings.get("close_session"), + Some(&"Cmd+Alt+W".to_string()) + ); + assert_eq!( + bindings.get("toggle_sidebar"), + Some(&"Cmd+Shift+E".to_string()) + ); assert_eq!( bindings.get("toggle_task_board"), - Some(&"Cmd+T".to_string()) + Some(&"Cmd+Shift+T".to_string()) + ); + assert_eq!( + bindings.get("toggle_layout"), + Some(&"Cmd+Shift+L".to_string()) + ); + assert_eq!( + bindings.get("search_terminal"), + Some(&"Cmd+Shift+F".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("toggle_sidebar"), Some(&"Ctrl+B".to_string())); + assert_eq!( + bindings.get("new_session"), + Some(&"Ctrl+Shift+N".to_string()) + ); + assert_eq!( + bindings.get("close_session"), + Some(&"Ctrl+Alt+W".to_string()) + ); + assert_eq!( + bindings.get("toggle_sidebar"), + Some(&"Ctrl+Shift+E".to_string()) + ); assert_eq!( bindings.get("toggle_task_board"), - Some(&"Ctrl+T".to_string()) + Some(&"Ctrl+Shift+T".to_string()) + ); + assert_eq!( + bindings.get("toggle_layout"), + Some(&"Ctrl+Shift+L".to_string()) + ); + assert_eq!( + bindings.get("search_terminal"), + Some(&"Ctrl+Shift+F".to_string()) ); } assert!(bindings.contains_key("quick_switch")); diff --git a/crates/codirigent-core/src/config_service.rs b/crates/codirigent-core/src/config_service.rs index 6fbb39b..714b21d 100644 --- a/crates/codirigent-core/src/config_service.rs +++ b/crates/codirigent-core/src/config_service.rs @@ -151,6 +151,7 @@ pub struct EffectiveConfig { /// /// Emitted when a configuration file is modified and reloaded. #[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] pub enum ConfigChange { /// Project configuration was changed. ProjectConfigChanged(ProjectConfig), diff --git a/crates/codirigent-core/src/hook_installer.rs b/crates/codirigent-core/src/hook_installer.rs index b5a83b1..08e19a8 100644 --- a/crates/codirigent-core/src/hook_installer.rs +++ b/crates/codirigent-core/src/hook_installer.rs @@ -261,15 +261,49 @@ fn merge_codex_notify(settings: &mut TomlValue, command: &str) -> Result { match notify_value { TomlValue::Array(arr) => { - if arr.iter().any(|value| value.as_str() == Some(command)) { + let mut normalized = Vec::with_capacity(arr.len().max(1)); + let mut inserted_codirigent = false; + let mut modified = false; + + for value in arr.iter() { + match value.as_str() { + Some(existing) if existing == command => { + if inserted_codirigent { + modified = true; + continue; + } + normalized.push(TomlValue::String(command.to_owned())); + inserted_codirigent = true; + } + Some(existing) if existing.contains(HOOK_MARKER) => { + if !inserted_codirigent { + normalized.push(TomlValue::String(command.to_owned())); + inserted_codirigent = true; + } + modified = true; + } + _ => normalized.push(value.clone()), + } + } + + if !inserted_codirigent { + normalized.push(TomlValue::String(command.to_owned())); + modified = true; + } + + if !modified && normalized.len() == arr.len() { return Ok(false); } - arr.push(TomlValue::String(command.to_owned())); + + *arr = normalized; Ok(true) } TomlValue::String(existing) => { if existing == command { Ok(false) + } else if existing.contains(HOOK_MARKER) { + *existing = command.to_owned(); + Ok(true) } else { *notify_value = TomlValue::Array(vec![ TomlValue::String(existing.clone()), @@ -654,6 +688,14 @@ mod tests { .len(), 2 ); + assert_eq!( + settings["notify"] + .as_array() + .expect("hook installer test should succeed")[0] + .as_str() + .expect("hook installer test should succeed"), + "/usr/local/bin/codirigent-hook" + ); } #[test] @@ -681,6 +723,51 @@ mod tests { ); } + #[test] + fn codex_config_replaces_stale_codirigent_paths_in_notify_array() { + let mut settings: TomlValue = toml::from_str( + r#" + notify = [ + "/Volumes/Codirigent 3/Codirigent.app/Contents/MacOS/codirigent-hook", + "/Applications/Codirigent.app/Contents/MacOS/codirigent-hook", + "notify-send" + ] + "#, + ) + .expect("hook installer test should succeed"); + let modified = + merge_codex_notify(&mut settings, CMD).expect("hook installer test should succeed"); + + assert!(modified); + assert_eq!( + settings["notify"] + .as_array() + .expect("hook installer test should succeed"), + &vec![ + TomlValue::String(CMD.to_string()), + TomlValue::String("notify-send".to_string()) + ] + ); + } + + #[test] + fn codex_config_upgrades_legacy_string_notify_in_place() { + let mut settings: TomlValue = toml::from_str( + r#"notify = "/Volumes/Codirigent 3/Codirigent.app/Contents/MacOS/codirigent-hook""#, + ) + .expect("hook installer test should succeed"); + let modified = + merge_codex_notify(&mut settings, CMD).expect("hook installer test should succeed"); + + assert!(modified); + assert_eq!( + settings["notify"] + .as_str() + .expect("hook installer test should succeed"), + CMD + ); + } + #[test] fn fresh_gemini_install_adds_expected_hooks() { let mut settings = json!({}); diff --git a/crates/codirigent-hook/src/main.rs b/crates/codirigent-hook/src/main.rs index 172ce83..6618e0c 100644 --- a/crates/codirigent-hook/src/main.rs +++ b/crates/codirigent-hook/src/main.rs @@ -79,17 +79,25 @@ fn handle_payload(payload: HookPayload) { .filter(|id| is_safe_filename(id)) .map(str::to_owned); + // Prefer the CLI's own session ID (e.g. Claude's UUID), then + // CODIRIGENT_SESSION_UUID (stable across restarts), then the legacy + // integer CODIRIGENT_SESSION_ID as a last resort. let filename_session_id = payload .session_id .as_deref() .filter(|id| is_safe_filename(id)) + .or_else(|| { + codirigent_session_uuid + .as_deref() + .filter(|id| is_safe_filename(id)) + }) .or_else(|| { codirigent_session_id .as_deref() .filter(|id| is_safe_filename(id)) }) .unwrap_or_default() - .to_owned(); // owned String releases the borrow on codirigent_session_id + .to_owned(); if filename_session_id.is_empty() { return; } @@ -274,6 +282,7 @@ fn map_codex_status(event_type: Option<&str>) -> &'static str { let event_type = event_type.unwrap_or("").to_ascii_lowercase(); if event_type == "agent-turn-complete" + || event_type == "task_complete" || event_type == "response.completed" || event_type == "response.done" || event_type == "turn_complete" @@ -281,6 +290,10 @@ fn map_codex_status(event_type: Option<&str>) -> &'static str { return "response_ready"; } + if event_type == "task_started" { + return "working"; + } + if event_type.contains("permission") || event_type.contains("approval") || event_type.contains("question") @@ -443,12 +456,14 @@ mod tests { map_codex_status(Some("agent-turn-complete")), "response_ready" ); + assert_eq!(map_codex_status(Some("task_complete")), "response_ready"); } #[test] fn map_codex_status_start_events_are_working() { assert_eq!(map_codex_status(Some("agent-turn-start")), "working"); assert_eq!(map_codex_status(Some("turn_start")), "working"); + assert_eq!(map_codex_status(Some("task_started")), "working"); } #[test] diff --git a/crates/codirigent-ui/src/actions.rs b/crates/codirigent-ui/src/actions.rs index e33243e..f718c83 100644 --- a/crates/codirigent-ui/src/actions.rs +++ b/crates/codirigent-ui/src/actions.rs @@ -263,63 +263,6 @@ pub struct OpenCommandPalette; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ReloadConfig; -/// Keybinding configuration. -#[derive(Debug, Clone)] -pub struct KeyBinding { - /// Key identifier (e.g., "Ctrl+L", "Alt+1"). - pub key: String, - /// Action name. - pub action: String, -} - -impl KeyBinding { - /// Create a new keybinding. - pub fn new(key: impl Into, action: impl Into) -> Self { - Self { - key: key.into(), - action: action.into(), - } - } -} - -/// Default keybindings for Codirigent. -pub fn default_keybindings() -> Vec { - vec![ - // Layout - KeyBinding::new("Ctrl+L", "next_layout"), - KeyBinding::new("Ctrl+Shift+L", "previous_layout"), - KeyBinding::new("Ctrl+B", "toggle_sidebar"), - // Focus by number - KeyBinding::new("Alt+1", "focus_session_1"), - KeyBinding::new("Alt+2", "focus_session_2"), - KeyBinding::new("Alt+3", "focus_session_3"), - KeyBinding::new("Alt+4", "focus_session_4"), - KeyBinding::new("Alt+5", "focus_session_5"), - KeyBinding::new("Alt+6", "focus_session_6"), - KeyBinding::new("Alt+7", "focus_session_7"), - KeyBinding::new("Alt+8", "focus_session_8"), - KeyBinding::new("Alt+9", "focus_session_9"), - // Focus navigation - KeyBinding::new("Ctrl+Tab", "focus_next"), - KeyBinding::new("Ctrl+Shift+Tab", "focus_previous"), - KeyBinding::new("Ctrl+Up", "focus_up"), - KeyBinding::new("Ctrl+Down", "focus_down"), - KeyBinding::new("Ctrl+Left", "focus_left"), - KeyBinding::new("Ctrl+Right", "focus_right"), - // Session management - KeyBinding::new("Ctrl+N", "create_session"), - KeyBinding::new("Ctrl+W", "close_session"), - // Pane splitting - KeyBinding::new("Ctrl+D", "split_horizontal"), - KeyBinding::new("Ctrl+Shift+D", "split_vertical"), - KeyBinding::new("Ctrl+Shift+W", "close_pane"), - // Application - KeyBinding::new("Ctrl+Q", "quit"), - KeyBinding::new("Ctrl+,", "open_settings"), - KeyBinding::new("Ctrl+Shift+P", "command_palette"), - ] -} - #[cfg(test)] mod tests { use super::*; @@ -452,45 +395,4 @@ mod tests { assert_eq!(palette, OpenCommandPalette); assert_eq!(reload, ReloadConfig); } - - #[test] - fn test_keybinding_new() { - let kb = KeyBinding::new("Ctrl+L", "next_layout"); - assert_eq!(kb.key, "Ctrl+L"); - assert_eq!(kb.action, "next_layout"); - } - - #[test] - fn test_default_keybindings() { - let bindings = default_keybindings(); - - // Should have layout bindings - assert!(bindings.iter().any(|b| b.action == "next_layout")); - assert!(bindings.iter().any(|b| b.action == "toggle_sidebar")); - - // Should have session focus bindings - assert!(bindings.iter().any(|b| b.action == "focus_session_1")); - assert!(bindings.iter().any(|b| b.action == "focus_session_9")); - - // Should have navigation bindings - assert!(bindings.iter().any(|b| b.action == "focus_next")); - assert!(bindings.iter().any(|b| b.action == "focus_up")); - - // Should have pane splitting bindings - assert!(bindings.iter().any(|b| b.action == "split_horizontal")); - assert!(bindings.iter().any(|b| b.action == "split_vertical")); - assert!(bindings.iter().any(|b| b.action == "close_pane")); - - // Should have app bindings - assert!(bindings.iter().any(|b| b.action == "quit")); - assert!(bindings.iter().any(|b| b.action == "create_session")); - } - - #[test] - fn test_keybinding_clone() { - let kb = KeyBinding::new("Ctrl+Q", "quit"); - let cloned = kb.clone(); - assert_eq!(kb.key, cloned.key); - assert_eq!(kb.action, cloned.action); - } } diff --git a/crates/codirigent-ui/src/app.rs b/crates/codirigent-ui/src/app.rs index 1866c7a..61c6eeb 100644 --- a/crates/codirigent-ui/src/app.rs +++ b/crates/codirigent-ui/src/app.rs @@ -54,6 +54,7 @@ mod actions_impl { ClosePane, Paste, Copy, + SearchTerminal, OpenSettings, Quit, ] @@ -71,21 +72,22 @@ pub use actions_impl::*; /// 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-shift-n", NewSession, None), + KeyBinding::new("secondary-alt-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-shift-l", NextLayout, None), + // Ctrl/Cmd+Shift+E — toggle sidebar (repo drawer) + KeyBinding::new("secondary-shift-e", ToggleSidebar, None), + // Ctrl/Cmd+Shift+T — toggle task board + KeyBinding::new("secondary-shift-t", ToggleTaskBoard, None), + // Ctrl/Cmd+Shift+K — quick switch + KeyBinding::new("secondary-shift-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-shift-f", SearchTerminal, None), + KeyBinding::new("secondary-alt-shift-h", SplitHorizontal, None), + KeyBinding::new("secondary-alt-shift-v", SplitVertical, None), + KeyBinding::new("secondary-alt-shift-w", ClosePane, None), KeyBinding::new("secondary-,", OpenSettings, None), KeyBinding::new("secondary-1", FocusSession1, None), KeyBinding::new("secondary-2", FocusSession2, None), @@ -583,6 +585,10 @@ impl CodirigentApp { info!("ToggleTaskBoard action triggered (global fallback)"); }); + cx.on_action(|_: &SearchTerminal, _cx| { + info!("SearchTerminal action triggered (global fallback)"); + }); + cx.on_action(|_: &QuickSwitch, _cx| { info!("QuickSwitch action triggered (global fallback)"); }); @@ -671,9 +677,9 @@ mod tests { // "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-shift-n").is_ok()); + assert!(Keystroke::parse("secondary-alt-shift-h").is_ok()); assert!(Keystroke::parse("secondary-,").is_ok()); - assert!(Keystroke::parse("secondary-\\").is_ok()); + assert!(Keystroke::parse("secondary-shift-l").is_ok()); } } diff --git a/crates/codirigent-ui/src/keybindings.rs b/crates/codirigent-ui/src/keybindings.rs index ef1046c..5570e43 100644 --- a/crates/codirigent-ui/src/keybindings.rs +++ b/crates/codirigent-ui/src/keybindings.rs @@ -187,6 +187,8 @@ pub enum Action { Copy, /// Paste from clipboard. Paste, + /// Search inside the active terminal. + SearchTerminal, /// Copy entire session output. CopySessionOutput, @@ -236,9 +238,9 @@ impl KeybindingManager { /// let manager = KeybindingManager::with_defaults(); /// // Platform modifier key: Cmd on macOS, Ctrl elsewhere. /// #[cfg(target_os = "macos")] - /// let binding = KeybindingManager::parse_binding("Cmd+N").unwrap(); + /// let binding = KeybindingManager::parse_binding("Cmd+Shift+N").unwrap(); /// #[cfg(not(target_os = "macos"))] - /// let binding = KeybindingManager::parse_binding("Ctrl+N").unwrap(); + /// let binding = KeybindingManager::parse_binding("Ctrl+Shift+N").unwrap(); /// assert_eq!(manager.get_action(&binding), Some(&Action::NewSession)); /// ``` pub fn with_defaults() -> Self { @@ -258,15 +260,15 @@ impl KeybindingManager { } // Session management - if let Ok(binding) = Self::parse_binding(&format!("{m}+N")) { + if let Ok(binding) = Self::parse_binding(&format!("{m}+Shift+N")) { manager.set_binding(binding, Action::NewSession); } - if let Ok(binding) = Self::parse_binding(&format!("{m}+W")) { + if let Ok(binding) = Self::parse_binding(&format!("{m}+Alt+W")) { manager.set_binding(binding, Action::CloseSession); } // Navigation - if let Ok(binding) = Self::parse_binding(&format!("{m}+K")) { + if let Ok(binding) = Self::parse_binding(&format!("{m}+Shift+K")) { manager.set_binding(binding, Action::QuickSwitch); } if let Ok(binding) = Self::parse_binding(&format!("{m}+Shift+P")) { @@ -280,18 +282,21 @@ impl KeybindingManager { } // Layout - if let Ok(binding) = Self::parse_binding(&format!("{m}+\\")) { + if let Ok(binding) = Self::parse_binding(&format!("{m}+Shift+L")) { manager.set_binding(binding, Action::ToggleLayout); } - if let Ok(binding) = Self::parse_binding(&format!("{m}+B")) { + if let Ok(binding) = Self::parse_binding(&format!("{m}+Shift+E")) { 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}+T")) { + if let Ok(binding) = Self::parse_binding(&format!("{m}+Shift+T")) { manager.set_binding(binding, Action::ToggleTaskBoard); } + if let Ok(binding) = Self::parse_binding(&format!("{m}+Shift+F")) { + manager.set_binding(binding, Action::SearchTerminal); + } // Clipboard if let Ok(binding) = Self::parse_binding(&format!("{m}+C")) { @@ -327,7 +332,7 @@ impl KeybindingManager { /// use std::collections::HashMap; /// /// let mut config = HashMap::new(); - /// config.insert("new_session".to_string(), "Ctrl+N".to_string()); + /// config.insert("new_session".to_string(), "Ctrl+Shift+N".to_string()); /// let manager = KeybindingManager::from_config(&config); /// ``` pub fn from_config(config: &HashMap) -> Self { @@ -546,6 +551,7 @@ impl KeybindingManager { "broadcast" => Some(Action::Broadcast), "copy" => Some(Action::Copy), "paste" => Some(Action::Paste), + "search_terminal" => Some(Action::SearchTerminal), "copy_session_output" => Some(Action::CopySessionOutput), "quit" => Some(Action::Quit), "open_settings" => Some(Action::OpenSettings), @@ -590,6 +596,7 @@ impl KeybindingManager { Action::CommandPalette => "command_palette".to_string(), Action::Copy => "copy".to_string(), Action::Paste => "paste".to_string(), + Action::SearchTerminal => "search_terminal".to_string(), Action::CopySessionOutput => "copy_session_output".to_string(), Action::Quit => "quit".to_string(), Action::OpenSettings => "open_settings".to_string(), @@ -836,9 +843,9 @@ mod tests { // Check some default bindings (platform modifier: Cmd on macOS, Ctrl elsewhere) #[cfg(target_os = "macos")] - let (new_key, close_key) = ("Cmd+N", "Cmd+W"); + let (new_key, close_key) = ("Cmd+Shift+N", "Cmd+Alt+W"); #[cfg(not(target_os = "macos"))] - let (new_key, close_key) = ("Ctrl+N", "Ctrl+W"); + let (new_key, close_key) = ("Ctrl+Shift+N", "Ctrl+Alt+W"); let binding = KeybindingManager::parse_binding(new_key).unwrap(); assert_eq!(manager.get_action(&binding), Some(&Action::NewSession)); @@ -851,9 +858,9 @@ mod tests { fn test_default_bindings() { let manager = KeybindingManager::with_defaults(); #[cfg(target_os = "macos")] - let key = "Cmd+N"; + let key = "Cmd+Shift+N"; #[cfg(not(target_os = "macos"))] - let key = "Ctrl+N"; + let key = "Ctrl+Shift+N"; let binding = KeybindingManager::parse_binding(key).unwrap(); assert_eq!(manager.get_action(&binding), Some(&Action::NewSession)); } @@ -908,10 +915,10 @@ mod tests { #[test] fn test_from_config() { let mut config = HashMap::new(); - config.insert("new_session".to_string(), "Ctrl+N".to_string()); + config.insert("new_session".to_string(), "Ctrl+Shift+N".to_string()); let manager = KeybindingManager::from_config(&config); - let binding = KeybindingManager::parse_binding("Ctrl+N").unwrap(); + let binding = KeybindingManager::parse_binding("Ctrl+Shift+N").unwrap(); assert_eq!(manager.get_action(&binding), Some(&Action::NewSession)); } @@ -922,9 +929,9 @@ mod tests { let manager = KeybindingManager::from_config(&config); // Should still have defaults (platform modifier: Cmd on macOS, Ctrl elsewhere) #[cfg(target_os = "macos")] - let key = "Cmd+N"; + let key = "Cmd+Shift+N"; #[cfg(not(target_os = "macos"))] - let key = "Ctrl+N"; + let key = "Ctrl+Shift+N"; let binding = KeybindingManager::parse_binding(key).unwrap(); assert_eq!(manager.get_action(&binding), Some(&Action::NewSession)); } @@ -993,9 +1000,9 @@ mod tests { fn test_keybinding_manager_default() { let manager = KeybindingManager::default(); #[cfg(target_os = "macos")] - let key = "Cmd+N"; + let key = "Cmd+Shift+N"; #[cfg(not(target_os = "macos"))] - let key = "Ctrl+N"; + let key = "Ctrl+Shift+N"; let binding = KeybindingManager::parse_binding(key).unwrap(); assert_eq!(manager.get_action(&binding), Some(&Action::NewSession)); } @@ -1005,9 +1012,9 @@ mod tests { let manager = KeybindingManager::with_defaults(); let cloned = manager.clone(); #[cfg(target_os = "macos")] - let key = "Cmd+N"; + let key = "Cmd+Shift+N"; #[cfg(not(target_os = "macos"))] - let key = "Ctrl+N"; + let key = "Ctrl+Shift+N"; let binding = KeybindingManager::parse_binding(key).unwrap(); assert_eq!(cloned.get_action(&binding), Some(&Action::NewSession)); } diff --git a/crates/codirigent-ui/src/layout_profile.rs b/crates/codirigent-ui/src/layout_profile.rs index 8de02bb..abfe946 100644 --- a/crates/codirigent-ui/src/layout_profile.rs +++ b/crates/codirigent-ui/src/layout_profile.rs @@ -238,6 +238,11 @@ impl LayoutProfileManager { } } + /// Clear the active profile selection. + pub fn clear_active(&mut self) { + self.active_profile = None; + } + /// Get the currently active profile. /// /// # Returns @@ -279,13 +284,12 @@ impl LayoutProfileManager { return None; } - let current_idx = self + let next_idx = self .active_profile .as_ref() .and_then(|id| self.profiles.iter().position(|p| &p.id == id)) + .map(|current_idx| (current_idx + 1) % self.profiles.len()) .unwrap_or(0); - - let next_idx = (current_idx + 1) % self.profiles.len(); self.active_profile = Some(self.profiles[next_idx].id.clone()); self.active() } @@ -537,6 +541,15 @@ mod tests { assert_eq!(second, "1x4"); } + #[test] + fn test_next_profile_from_no_active_selects_first_profile() { + let mut manager = LayoutProfileManager::with_defaults(); + manager.clear_active(); + + let next = manager.next_profile().unwrap(); + assert_eq!(next.id, "2x2"); + } + #[test] fn test_next_profile_wraps() { let mut manager = LayoutProfileManager::with_defaults(); diff --git a/crates/codirigent-ui/src/lib.rs b/crates/codirigent-ui/src/lib.rs index 5ad6f5a..c76554e 100644 --- a/crates/codirigent-ui/src/lib.rs +++ b/crates/codirigent-ui/src/lib.rs @@ -125,6 +125,8 @@ pub mod terminal_colors; #[cfg(all(feature = "gpui-full", feature = "terminal"))] pub(crate) mod terminal_runtime; #[cfg(all(feature = "gpui-full", feature = "terminal"))] +pub(crate) mod terminal_search; +#[cfg(all(feature = "gpui-full", feature = "terminal"))] pub mod terminal_view; // Re-export commonly used items diff --git a/crates/codirigent-ui/src/settings/mod.rs b/crates/codirigent-ui/src/settings/mod.rs index 6d9cc6c..ae98aee 100644 --- a/crates/codirigent-ui/src/settings/mod.rs +++ b/crates/codirigent-ui/src/settings/mod.rs @@ -6,4 +6,4 @@ pub(crate) mod controls; mod page; -pub use page::{SettingsCategory, SettingsPage}; +pub use page::{SettingsCategory, SettingsPage, TerminalStyleField}; diff --git a/crates/codirigent-ui/src/settings/page.rs b/crates/codirigent-ui/src/settings/page.rs index 098c320..eb589b1 100644 --- a/crates/codirigent-ui/src/settings/page.rs +++ b/crates/codirigent-ui/src/settings/page.rs @@ -4,7 +4,245 @@ //! user and project settings, tracks the active category, and manages //! dirty detection and reset. -use codirigent_core::config::{ProjectConfig, UserSettings}; +use crate::theme::{CodirigentTheme, Rgba}; +use crate::theme_config::Theme; +use codirigent_core::config::{ProjectConfig, TerminalThemeOverrides, UserSettings}; + +/// Editable terminal style fields exposed in the settings UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalStyleField { + /// Terminal background color. + Background, + /// Default terminal foreground/text color. + Foreground, + /// Terminal cursor color. + Cursor, + /// Terminal selection background color. + SelectionBackground, + /// Terminal selection foreground color. + SelectionForeground, + /// ANSI black palette slot. + Black, + /// ANSI red palette slot. + Red, + /// ANSI green palette slot. + Green, + /// ANSI yellow palette slot. + Yellow, + /// ANSI blue palette slot. + Blue, + /// ANSI magenta palette slot. + Magenta, + /// ANSI cyan palette slot. + Cyan, + /// ANSI white palette slot. + White, + /// ANSI bright black palette slot. + BrightBlack, + /// ANSI bright red palette slot. + BrightRed, + /// ANSI bright green palette slot. + BrightGreen, + /// ANSI bright yellow palette slot. + BrightYellow, + /// ANSI bright blue palette slot. + BrightBlue, + /// ANSI bright magenta palette slot. + BrightMagenta, + /// ANSI bright cyan palette slot. + BrightCyan, + /// ANSI bright white palette slot. + BrightWhite, +} + +impl TerminalStyleField { + /// Primary terminal surface colors. + pub const BASE: [Self; 5] = [ + Self::Background, + Self::Foreground, + Self::Cursor, + Self::SelectionBackground, + Self::SelectionForeground, + ]; + + /// ANSI 16-color palette overrides. + pub const ANSI: [Self; 16] = [ + Self::Black, + Self::Red, + Self::Green, + Self::Yellow, + Self::Blue, + Self::Magenta, + Self::Cyan, + Self::White, + Self::BrightBlack, + Self::BrightRed, + Self::BrightGreen, + Self::BrightYellow, + Self::BrightBlue, + Self::BrightMagenta, + Self::BrightCyan, + Self::BrightWhite, + ]; + + /// All terminal style fields in stable display order. + pub const ALL: [Self; 21] = [ + Self::Background, + Self::Foreground, + Self::Cursor, + Self::SelectionBackground, + Self::SelectionForeground, + Self::Black, + Self::Red, + Self::Green, + Self::Yellow, + Self::Blue, + Self::Magenta, + Self::Cyan, + Self::White, + Self::BrightBlack, + Self::BrightRed, + Self::BrightGreen, + Self::BrightYellow, + Self::BrightBlue, + Self::BrightMagenta, + Self::BrightCyan, + Self::BrightWhite, + ]; + + /// Stable field identifier for focus state and DOM IDs. + pub fn id(self) -> &'static str { + match self { + Self::Background => "background", + Self::Foreground => "foreground", + Self::Cursor => "cursor", + Self::SelectionBackground => "selection_background", + Self::SelectionForeground => "selection_foreground", + Self::Black => "palette_black", + Self::Red => "palette_red", + Self::Green => "palette_green", + Self::Yellow => "palette_yellow", + Self::Blue => "palette_blue", + Self::Magenta => "palette_magenta", + Self::Cyan => "palette_cyan", + Self::White => "palette_white", + Self::BrightBlack => "palette_bright_black", + Self::BrightRed => "palette_bright_red", + Self::BrightGreen => "palette_bright_green", + Self::BrightYellow => "palette_bright_yellow", + Self::BrightBlue => "palette_bright_blue", + Self::BrightMagenta => "palette_bright_magenta", + Self::BrightCyan => "palette_bright_cyan", + Self::BrightWhite => "palette_bright_white", + } + } + + pub(crate) fn get(self, overrides: &TerminalThemeOverrides) -> &str { + match self { + Self::Background => &overrides.background, + Self::Foreground => &overrides.foreground, + Self::Cursor => &overrides.cursor, + Self::SelectionBackground => &overrides.selection_background, + Self::SelectionForeground => &overrides.selection_foreground, + Self::Black => &overrides.palette.black, + Self::Red => &overrides.palette.red, + Self::Green => &overrides.palette.green, + Self::Yellow => &overrides.palette.yellow, + Self::Blue => &overrides.palette.blue, + Self::Magenta => &overrides.palette.magenta, + Self::Cyan => &overrides.palette.cyan, + Self::White => &overrides.palette.white, + Self::BrightBlack => &overrides.palette.bright_black, + Self::BrightRed => &overrides.palette.bright_red, + Self::BrightGreen => &overrides.palette.bright_green, + Self::BrightYellow => &overrides.palette.bright_yellow, + Self::BrightBlue => &overrides.palette.bright_blue, + Self::BrightMagenta => &overrides.palette.bright_magenta, + Self::BrightCyan => &overrides.palette.bright_cyan, + Self::BrightWhite => &overrides.palette.bright_white, + } + } + + pub(crate) fn get_mut(self, overrides: &mut TerminalThemeOverrides) -> &mut String { + match self { + Self::Background => &mut overrides.background, + Self::Foreground => &mut overrides.foreground, + Self::Cursor => &mut overrides.cursor, + Self::SelectionBackground => &mut overrides.selection_background, + Self::SelectionForeground => &mut overrides.selection_foreground, + Self::Black => &mut overrides.palette.black, + Self::Red => &mut overrides.palette.red, + Self::Green => &mut overrides.palette.green, + Self::Yellow => &mut overrides.palette.yellow, + Self::Blue => &mut overrides.palette.blue, + Self::Magenta => &mut overrides.palette.magenta, + Self::Cyan => &mut overrides.palette.cyan, + Self::White => &mut overrides.palette.white, + Self::BrightBlack => &mut overrides.palette.bright_black, + Self::BrightRed => &mut overrides.palette.bright_red, + Self::BrightGreen => &mut overrides.palette.bright_green, + Self::BrightYellow => &mut overrides.palette.bright_yellow, + Self::BrightBlue => &mut overrides.palette.bright_blue, + Self::BrightMagenta => &mut overrides.palette.bright_magenta, + Self::BrightCyan => &mut overrides.palette.bright_cyan, + Self::BrightWhite => &mut overrides.palette.bright_white, + } + } + + /// Read the effective runtime theme color for this field. + pub fn theme_color(self, theme: &CodirigentTheme) -> Rgba { + match self { + Self::Background => theme.terminal_background, + Self::Foreground => theme.terminal_foreground, + Self::Cursor => theme.terminal_cursor, + Self::SelectionBackground => theme.terminal_selection_bg, + Self::SelectionForeground => theme.terminal_selection_fg, + Self::Black => theme.ansi.colors[0], + Self::Red => theme.ansi.colors[1], + Self::Green => theme.ansi.colors[2], + Self::Yellow => theme.ansi.colors[3], + Self::Blue => theme.ansi.colors[4], + Self::Magenta => theme.ansi.colors[5], + Self::Cyan => theme.ansi.colors[6], + Self::White => theme.ansi.colors[7], + Self::BrightBlack => theme.ansi.colors[8], + Self::BrightRed => theme.ansi.colors[9], + Self::BrightGreen => theme.ansi.colors[10], + Self::BrightYellow => theme.ansi.colors[11], + Self::BrightBlue => theme.ansi.colors[12], + Self::BrightMagenta => theme.ansi.colors[13], + Self::BrightCyan => theme.ansi.colors[14], + Self::BrightWhite => theme.ansi.colors[15], + } + } + + /// Apply this field to the serializable theme config. + pub fn set_theme_config_value(self, theme: &mut Theme, value: String) { + match self { + Self::Background => theme.colors.terminal.background = value, + Self::Foreground => theme.colors.terminal.foreground = value, + Self::Cursor => theme.colors.terminal.cursor = value, + Self::SelectionBackground => theme.colors.terminal.selection_background = value, + Self::SelectionForeground => theme.colors.terminal.selection_foreground = value, + Self::Black => theme.colors.terminal.palette.black = value, + Self::Red => theme.colors.terminal.palette.red = value, + Self::Green => theme.colors.terminal.palette.green = value, + Self::Yellow => theme.colors.terminal.palette.yellow = value, + Self::Blue => theme.colors.terminal.palette.blue = value, + Self::Magenta => theme.colors.terminal.palette.magenta = value, + Self::Cyan => theme.colors.terminal.palette.cyan = value, + Self::White => theme.colors.terminal.palette.white = value, + Self::BrightBlack => theme.colors.terminal.palette.bright_black = value, + Self::BrightRed => theme.colors.terminal.palette.bright_red = value, + Self::BrightGreen => theme.colors.terminal.palette.bright_green = value, + Self::BrightYellow => theme.colors.terminal.palette.bright_yellow = value, + Self::BrightBlue => theme.colors.terminal.palette.bright_blue = value, + Self::BrightMagenta => theme.colors.terminal.palette.bright_magenta = value, + Self::BrightCyan => theme.colors.terminal.palette.bright_cyan = value, + Self::BrightWhite => theme.colors.terminal.palette.bright_white = value, + } + } +} /// Settings category for the sidebar navigation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -76,6 +314,8 @@ pub struct SettingsPage { pub recording_shortcut: Option, /// Which shortcut row currently has keyboard focus (action name). pub focused_shortcut_row: Option, + /// Which terminal style field currently has keyboard focus. + pub focused_terminal_style_field: Option, /// Which dropdown is currently open (by ID string). pub open_dropdown: Option, /// Click position (window coordinates) where the dropdown was triggered. @@ -90,6 +330,10 @@ pub struct SettingsPage { pub detected_shells: Vec, /// Monospace fonts detected on the system. pub detected_fonts: Vec, + /// Theme colors captured when the terminal editor was opened/reset. + pub terminal_style_base: TerminalThemeOverrides, + /// In-progress terminal style editor draft values. + pub terminal_style_draft: TerminalThemeOverrides, /// Pre-sorted list of keybinding action names for the Keyboard Shortcuts panel. /// Rebuilt whenever `user_settings.keybindings` changes. pub sorted_shortcut_keys: Vec, @@ -103,10 +347,13 @@ impl SettingsPage { detected_editors: Vec, detected_shells: Vec, detected_fonts: Vec, + terminal_style_values: TerminalThemeOverrides, ) -> Self { let mut sorted_shortcut_keys: Vec = user_settings.keybindings.keys().cloned().collect(); sorted_shortcut_keys.sort(); + let terminal_style_base = terminal_style_values.clone(); + let terminal_style_draft = terminal_style_values; Self { active_category: SettingsCategory::General, original_user: user_settings.clone(), @@ -115,6 +362,7 @@ impl SettingsPage { project_config, recording_shortcut: None, focused_shortcut_row: None, + focused_terminal_style_field: None, open_dropdown: None, dropdown_click_pos: (0.0, 0.0), user_save_pending: false, @@ -122,6 +370,8 @@ impl SettingsPage { detected_editors, detected_shells, detected_fonts, + terminal_style_base, + terminal_style_draft, sorted_shortcut_keys, } } @@ -169,6 +419,8 @@ impl SettingsPage { } SettingsCategory::Terminal => { self.user_settings.terminal = defaults.terminal; + self.terminal_style_draft = self.terminal_style_base.clone(); + self.focused_terminal_style_field = None; } SettingsCategory::KeyboardShortcuts => { self.user_settings.keybindings = defaults.keybindings; @@ -207,6 +459,27 @@ impl SettingsPage { self.original_project = self.project_config.clone(); self.project_save_pending = false; } + + /// Return the current draft value for a terminal style field. + pub fn terminal_style_draft_value(&self, field: TerminalStyleField) -> &str { + field.get(&self.terminal_style_draft) + } + + /// Return the base theme value for a terminal style field. + pub fn terminal_style_base_value(&self, field: TerminalStyleField) -> &str { + field.get(&self.terminal_style_base) + } + + /// Update the draft value for a terminal style field. + pub fn set_terminal_style_draft_value(&mut self, field: TerminalStyleField, value: String) { + *field.get_mut(&mut self.terminal_style_draft) = value; + } + + /// Reset one field back to the base theme value captured for the editor. + pub fn reset_terminal_style_field_to_base(&mut self, field: TerminalStyleField) { + let value = field.get(&self.terminal_style_base).to_string(); + *field.get_mut(&mut self.terminal_style_draft) = value; + } } #[cfg(test)] @@ -244,8 +517,10 @@ mod tests { vec![], vec![], vec![], + TerminalThemeOverrides::default(), ); assert!(page.focused_shortcut_row.is_none()); + assert!(page.focused_terminal_style_field.is_none()); } #[test] @@ -256,11 +531,56 @@ mod tests { vec!["code".to_string()], vec!["bash".to_string(), "zsh".to_string()], vec!["Menlo".to_string(), "Courier New".to_string()], + TerminalThemeOverrides::default(), ); assert_eq!(page.active_category(), SettingsCategory::General); assert!(!page.is_user_dirty()); assert!(!page.is_project_dirty()); assert!(page.recording_shortcut.is_none()); + assert!(page.terminal_style_draft.background.is_empty()); + } + + #[test] + fn test_terminal_style_draft_tracks_editor_value() { + let mut page = SettingsPage::new( + UserSettings::default(), + ProjectConfig::default(), + vec![], + vec![], + vec![], + TerminalThemeOverrides::default(), + ); + + page.set_terminal_style_draft_value(TerminalStyleField::Background, "#123456".to_string()); + + assert_eq!( + page.terminal_style_draft_value(TerminalStyleField::Background), + "#123456" + ); + } + + #[test] + fn test_reset_terminal_style_field_to_base() { + let base = TerminalThemeOverrides { + background: "#111111".to_string(), + ..TerminalThemeOverrides::default() + }; + let mut page = SettingsPage::new( + UserSettings::default(), + ProjectConfig::default(), + vec![], + vec![], + vec![], + base, + ); + + page.set_terminal_style_draft_value(TerminalStyleField::Background, "#123456".to_string()); + page.reset_terminal_style_field_to_base(TerminalStyleField::Background); + + assert_eq!( + page.terminal_style_draft_value(TerminalStyleField::Background), + "#111111" + ); } #[test] @@ -271,6 +591,7 @@ mod tests { vec!["code".to_string()], vec!["bash".to_string(), "zsh".to_string()], vec!["Menlo".to_string(), "Courier New".to_string()], + TerminalThemeOverrides::default(), ); page.set_category(SettingsCategory::Terminal); assert_eq!(page.active_category(), SettingsCategory::Terminal); @@ -284,6 +605,7 @@ mod tests { vec!["code".to_string()], vec!["bash".to_string(), "zsh".to_string()], vec!["Menlo".to_string(), "Courier New".to_string()], + TerminalThemeOverrides::default(), ); page.set_category(SettingsCategory::Sessions); assert_eq!(page.active_category(), SettingsCategory::General); @@ -300,6 +622,7 @@ mod tests { vec!["code".to_string()], vec!["bash".to_string(), "zsh".to_string()], vec!["Menlo".to_string(), "Courier New".to_string()], + TerminalThemeOverrides::default(), ); assert!(!page.is_user_dirty()); page.user_settings.general.editor_command = "vim".to_string(); @@ -314,6 +637,7 @@ mod tests { vec!["code".to_string()], vec!["bash".to_string(), "zsh".to_string()], vec!["Menlo".to_string(), "Courier New".to_string()], + TerminalThemeOverrides::default(), ); assert!(!page.is_project_dirty()); page.project_config.sessions.max_concurrent = 16; @@ -328,6 +652,7 @@ mod tests { vec!["code".to_string()], vec!["bash".to_string(), "zsh".to_string()], vec!["Menlo".to_string(), "Courier New".to_string()], + TerminalThemeOverrides::default(), ); page.user_settings.general.editor_command = "vim".to_string(); assert!(page.is_user_dirty()); @@ -345,6 +670,7 @@ mod tests { vec!["code".to_string()], vec!["bash".to_string(), "zsh".to_string()], vec!["Menlo".to_string(), "Courier New".to_string()], + TerminalThemeOverrides::default(), ); page.project_config.sessions.max_concurrent = 16; page.set_category(SettingsCategory::Sessions); @@ -361,6 +687,7 @@ mod tests { vec!["code".to_string()], vec!["bash".to_string(), "zsh".to_string()], vec!["Menlo".to_string(), "Courier New".to_string()], + TerminalThemeOverrides::default(), ); page.user_settings.general.editor_command = "vim".to_string(); page.user_save_pending = true; @@ -377,6 +704,7 @@ mod tests { vec![], vec![], vec![], + TerminalThemeOverrides::default(), ); // Mutate notification fields page.user_settings.notifications.desktop = false; diff --git a/crates/codirigent-ui/src/terminal_runtime.rs b/crates/codirigent-ui/src/terminal_runtime.rs index 9d68072..6e349c8 100644 --- a/crates/codirigent-ui/src/terminal_runtime.rs +++ b/crates/codirigent-ui/src/terminal_runtime.rs @@ -1,9 +1,10 @@ use crate::clipboard; use crate::terminal::{Terminal, TerminalSize}; use crate::terminal_colors::{convert_color, dim_color}; +use crate::terminal_search::{self, SearchMatch}; use crate::terminal_view::{CachedTerminalRow, Selection, TextRunSegment}; use crate::theme::{CodirigentTheme, Rgba}; -use alacritty_terminal::grid::Scroll; +use alacritty_terminal::grid::{Dimensions, Scroll}; use alacritty_terminal::index::{Column, Line}; use alacritty_terminal::term::cell::Flags as CellFlags; use alacritty_terminal::term::TermMode; @@ -16,6 +17,7 @@ pub(crate) struct TerminalRenderSnapshot { pub(crate) rows: u16, pub(crate) cols: u16, pub(crate) mode: TermMode, + pub(crate) history_size: usize, pub(crate) display_offset: usize, pub(crate) cached_rows: Vec, pub(crate) dirty_rows: Option>, @@ -27,6 +29,7 @@ struct TerminalRuntime { theme: CodirigentTheme, generation: u64, cached_rows: Option>, + cached_search_snapshot: Option>, } #[derive(Clone)] @@ -46,6 +49,7 @@ impl TerminalRuntimeHandle { theme, generation: 0, cached_rows: None, + cached_search_snapshot: None, }; let snapshot = runtime.snapshot_full(); ( @@ -76,6 +80,10 @@ impl TerminalRuntimeHandle { self.with_runtime_mut(|runtime| runtime.scroll_display(Scroll::Bottom)) } + pub(crate) fn scroll_to_offset(&self, target: usize) -> Option { + self.with_runtime_mut(|runtime| runtime.scroll_to_offset(target)) + } + pub(crate) fn clear(&self) -> Option { self.with_runtime_mut(TerminalRuntime::clear) } @@ -97,6 +105,20 @@ impl TerminalRuntimeHandle { .flatten() } + pub(crate) fn search(&self, query: &str) -> Vec { + let snapshot = self + .with_runtime_mut(TerminalRuntime::cached_search_snapshot) + .unwrap_or_default(); + terminal_search::search_snapshot(&snapshot, query) + } + + pub(crate) fn match_still_matches(&self, query: &str, search_match: &SearchMatch) -> bool { + self.with_runtime(|runtime| { + terminal_search::match_still_matches(runtime.terminal.term(), query, search_match) + }) + .unwrap_or(false) + } + #[cfg(test)] pub(crate) fn snapshot(&self) -> Option { self.with_runtime_mut(|runtime| runtime.snapshot_full()) @@ -118,27 +140,53 @@ impl TerminalRuntimeHandle { } impl TerminalRuntime { + fn invalidate_search_snapshot(&mut self) { + self.cached_search_snapshot = None; + } + + fn cached_search_snapshot(&mut self) -> Arc { + if let Some(snapshot) = &self.cached_search_snapshot { + return Arc::clone(snapshot); + } + + let snapshot = Arc::new(terminal_search::snapshot(self.terminal.term())); + self.cached_search_snapshot = Some(Arc::clone(&snapshot)); + snapshot + } + fn apply_output(&mut self, data: &[u8]) -> TerminalRenderSnapshot { self.terminal.process_output(data); self.generation += 1; + self.invalidate_search_snapshot(); self.snapshot_from_damage() } fn resize_with_cells(&mut self, size: TerminalSize) -> TerminalRenderSnapshot { self.terminal.resize_with_cells(size); self.generation += 1; + self.invalidate_search_snapshot(); self.snapshot_full() } fn scroll_display(&mut self, scroll: Scroll) -> TerminalRenderSnapshot { self.terminal.term_mut().scroll_display(scroll); self.generation += 1; + // Scrolling changes the viewport, not the underlying terminal content, + // so the cached search snapshot remains valid. self.snapshot_full() } + fn scroll_to_offset(&mut self, target: usize) -> TerminalRenderSnapshot { + let current = self.terminal.term().grid().display_offset(); + let delta = + ((target as i64) - (current as i64)).clamp(i32::MIN as i64, i32::MAX as i64) as i32; + self.scroll_display(Scroll::Delta(delta)) + } + fn clear(&mut self) -> TerminalRenderSnapshot { self.terminal.clear(); self.generation += 1; + self.invalidate_search_snapshot(); self.snapshot_full() } @@ -215,8 +263,10 @@ impl TerminalRuntime { let rows = self.terminal.rows(); let cols = self.terminal.cols(); let mode = self.terminal.mode(); - let content = self.terminal.term().renderable_content(); + let term = self.terminal.term(); + let content = term.renderable_content(); let display_offset = content.display_offset; + let history_size = term.topmost_line().0.unsigned_abs() as usize; let viewport_line = content.cursor.point.line.0 + display_offset as i32; let cursor_viewport_cell = if viewport_line >= 0 && (viewport_line as usize) < rows as usize { @@ -231,6 +281,7 @@ impl TerminalRuntime { rows, cols, mode, + history_size, display_offset, cached_rows: self.cached_rows.clone().unwrap_or_default(), dirty_rows, @@ -417,4 +468,45 @@ mod tests { Some("hello".to_string()) ); } + + #[test] + fn runtime_search_reuses_cached_snapshot_until_content_changes() { + let runtime = create_runtime(); + let _ = runtime.apply_output(b"alpha beta gamma"); + + assert!(!runtime + .with_runtime(|runtime| runtime.cached_search_snapshot.is_some()) + .unwrap_or(false)); + + let first = runtime.search("alpha"); + assert_eq!(first.len(), 1); + assert!(runtime + .with_runtime(|runtime| runtime.cached_search_snapshot.is_some()) + .unwrap_or(false)); + + let second = runtime.search("beta"); + assert_eq!(second.len(), 1); + assert!(runtime + .with_runtime(|runtime| runtime.cached_search_snapshot.is_some()) + .unwrap_or(false)); + + let _ = runtime.apply_output(b"\ndelta"); + assert!(!runtime + .with_runtime(|runtime| runtime.cached_search_snapshot.is_some()) + .unwrap_or(false)); + + let third = runtime.search("delta"); + assert_eq!(third.len(), 1); + } + + #[test] + fn runtime_scroll_to_offset_clamps_large_deltas() { + let runtime = create_runtime(); + + let snapshot = runtime + .scroll_to_offset(usize::MAX) + .expect("runtime scroll snapshot"); + + assert_eq!(snapshot.display_offset, snapshot.history_size); + } } diff --git a/crates/codirigent-ui/src/terminal_search.rs b/crates/codirigent-ui/src/terminal_search.rs new file mode 100644 index 0000000..66a64de --- /dev/null +++ b/crates/codirigent-ui/src/terminal_search.rs @@ -0,0 +1,345 @@ +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line, Point}; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::term::Term; + +const WIDE_SPACER_FLAGS: Flags = Flags::WIDE_CHAR_SPACER.union(Flags::LEADING_WIDE_CHAR_SPACER); + +/// Literal search hit inside terminal grid coordinates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchMatch { + /// Starting grid line for the match. + pub grid_line: i32, + /// Starting column (inclusive) on `grid_line`. + pub start_col: usize, + /// Ending grid line for the match. + pub end_grid_line: i32, + /// Ending column (exclusive) on `end_grid_line`. + pub end_col: usize, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct SearchSnapshot { + blocks: Vec, +} + +#[derive(Debug, Clone, Default)] +struct SearchBlock { + cells: Vec, + lowered: Vec, + lowered_to_cell: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct SearchCell { + ch: char, + grid_line: i32, + start_col: usize, + end_grid_line: i32, + end_col: usize, +} + +impl SearchMatch { + /// Starting point of the match. + pub fn start_point(&self) -> Point { + Point::new(Line(self.grid_line), Column(self.start_col)) + } + + /// Exclusive end point of the match. + pub fn end_point(&self) -> Point { + Point::new(Line(self.end_grid_line), Column(self.end_col)) + } +} + +pub(crate) fn snapshot(term: &Term) -> SearchSnapshot { + let cols = term.columns(); + if cols == 0 { + return SearchSnapshot::default(); + } + + let mut blocks = Vec::new(); + let mut cells = Vec::new(); + let mut lowered = Vec::new(); + let mut lowered_to_cell = Vec::new(); + let last_column = Column(cols - 1); + let top = term.topmost_line().0; + let bottom = term.bottommost_line().0; + + for grid_line in top..=bottom { + let line = Line(grid_line); + for col in 0..cols { + let cell = &term.grid()[line][Column(col)]; + if cell.flags.intersects(WIDE_SPACER_FLAGS) { + continue; + } + + let width = cell_width(cell.flags); + let (end_grid_line, end_col) = exclusive_end_from_cell(grid_line, col, width, cols); + let cell_index = cells.len(); + let search_cell = SearchCell { + ch: cell.c, + grid_line, + start_col: col, + end_grid_line, + end_col, + }; + for ch in search_cell.ch.to_lowercase() { + lowered.push(ch); + lowered_to_cell.push(cell_index); + } + cells.push(search_cell); + } + + if !term.grid()[line][last_column] + .flags + .contains(Flags::WRAPLINE) + && !cells.is_empty() + { + blocks.push(SearchBlock { + cells: std::mem::take(&mut cells), + lowered: std::mem::take(&mut lowered), + lowered_to_cell: std::mem::take(&mut lowered_to_cell), + }); + } + } + + if !cells.is_empty() { + blocks.push(SearchBlock { + cells, + lowered, + lowered_to_cell, + }); + } + + SearchSnapshot { blocks } +} + +/// Search the terminal grid for literal, case-insensitive matches. +#[cfg(test)] +pub fn search(term: &Term, query: &str) -> Vec { + search_snapshot(&snapshot(term), query) +} + +pub(crate) fn search_snapshot(snapshot: &SearchSnapshot, query: &str) -> Vec { + if query.is_empty() { + return Vec::new(); + } + + let query_chars: Vec = query.chars().flat_map(|ch| ch.to_lowercase()).collect(); + if query_chars.is_empty() { + return Vec::new(); + } + + let mut matches = Vec::new(); + for block in &snapshot.blocks { + matches.extend(search_block(block, &query_chars)); + } + + matches.reverse(); + matches +} + +/// Verify that a stale search hit still contains the current query. +pub fn match_still_matches(term: &Term, query: &str, search_match: &SearchMatch) -> bool { + if query.is_empty() { + return false; + } + + extract_match_text(term, search_match).to_lowercase() == query.to_lowercase() +} + +fn extract_match_text(term: &Term, search_match: &SearchMatch) -> String { + let mut point = search_match.start_point(); + let end = search_match.end_point(); + let mut text = String::new(); + + while point < end { + let cell = &term.grid()[point.line][point.column]; + if !cell.flags.intersects(WIDE_SPACER_FLAGS) { + text.push(cell.c); + } + + let step = cell_width(cell.flags); + point = advance_point(term, point, step); + } + + text +} + +fn search_block(block: &SearchBlock, query_chars: &[char]) -> Vec { + if block.lowered.len() < query_chars.len() { + return Vec::new(); + } + + let mut matches = Vec::new(); + for start in find_match_starts(&block.lowered, query_chars) { + let start_cell = block.cells[block.lowered_to_cell[start]]; + let end_cell = block.cells[block.lowered_to_cell[start + query_chars.len() - 1]]; + matches.push(SearchMatch { + grid_line: start_cell.grid_line, + start_col: start_cell.start_col, + end_grid_line: end_cell.end_grid_line, + end_col: end_cell.end_col, + }); + } + + matches +} + +fn find_match_starts(haystack: &[char], needle: &[char]) -> Vec { + if needle.is_empty() || haystack.len() < needle.len() { + return Vec::new(); + } + + let mut lps = vec![0usize; needle.len()]; + let mut prefix_len = 0usize; + let mut index = 1usize; + while index < needle.len() { + if needle[index] == needle[prefix_len] { + prefix_len += 1; + lps[index] = prefix_len; + index += 1; + } else if prefix_len != 0 { + prefix_len = lps[prefix_len - 1]; + } else { + lps[index] = 0; + index += 1; + } + } + + let mut starts = Vec::new(); + let mut haystack_index = 0usize; + let mut needle_index = 0usize; + while haystack_index < haystack.len() { + if haystack[haystack_index] == needle[needle_index] { + haystack_index += 1; + needle_index += 1; + if needle_index == needle.len() { + starts.push(haystack_index - needle_index); + needle_index = lps[needle_index - 1]; + } + } else if needle_index != 0 { + needle_index = lps[needle_index - 1]; + } else { + haystack_index += 1; + } + } + + starts +} + +fn exclusive_end_from_cell( + grid_line: i32, + start_col: usize, + width: usize, + cols: usize, +) -> (i32, usize) { + let mut line = grid_line; + let mut col = start_col + width; + if col > cols { + line += (col / cols) as i32; + col %= cols; + } + + if col == cols { + line += 1; + col = 0; + } + + (line, col) +} + +fn advance_point(term: &Term, point: Point, step: usize) -> Point { + let cols = term.columns(); + if cols == 0 { + return point; + } + + let mut line = point.line.0; + let mut col = point.column.0 + step; + + while col >= cols { + col -= cols; + line += 1; + } + + Point::new(Line(line), Column(col)) +} + +fn cell_width(flags: Flags) -> usize { + if flags.contains(Flags::WIDE_CHAR) { + 2 + } else { + 1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::Terminal; + use codirigent_core::SessionId; + + fn create_term(rows: u16, cols: u16) -> Terminal { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + Terminal::new(rows, cols, SessionId(1), tx) + } + + #[test] + fn finds_case_insensitive_matches() { + let mut terminal = create_term(4, 20); + terminal.process_output(b"Alpha\nbeta\nALPHA\n"); + + let matches = search(terminal.term(), "alpha"); + + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].grid_line, 2); + assert_eq!(matches[1].grid_line, 0); + } + + #[test] + fn validates_stale_matches_against_current_grid_text() { + let mut terminal = create_term(4, 20); + terminal.process_output(b"hello world"); + + let search_match = search(terminal.term(), "hello").pop().unwrap(); + assert!(match_still_matches(terminal.term(), "hello", &search_match)); + assert!(!match_still_matches( + terminal.term(), + "world", + &search_match + )); + } + + #[test] + fn does_not_match_across_hard_line_breaks() { + let mut terminal = create_term(4, 20); + terminal.process_output(b"ab\ncd\n"); + + let matches = search(terminal.term(), "bc"); + + assert!(matches.is_empty()); + } + + #[test] + fn matches_across_wrapped_lines() { + let mut terminal = create_term(4, 4); + terminal.process_output(b"abcd"); + + let matches = search(terminal.term(), "cd"); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].grid_line, 0); + assert_eq!(matches[0].start_col, 2); + assert_eq!(matches[0].end_grid_line, 1); + assert_eq!(matches[0].end_col, 0); + } + + #[test] + fn finds_overlapping_match_starts() { + let haystack: Vec = "ababa".chars().collect(); + let needle: Vec = "aba".chars().collect(); + + assert_eq!(find_match_starts(&haystack, &needle), vec![0, 2]); + } +} diff --git a/crates/codirigent-ui/src/terminal_view.rs b/crates/codirigent-ui/src/terminal_view.rs index eb25df2..d61e7ae 100644 --- a/crates/codirigent-ui/src/terminal_view.rs +++ b/crates/codirigent-ui/src/terminal_view.rs @@ -35,6 +35,7 @@ //! ``` use std::sync::Arc; +use std::time::Instant; /// Ratio of font_size used as a conservative initial cell width estimate /// before real font metrics arrive from `compute_cell_dimensions`. @@ -50,6 +51,7 @@ const MIN_CELL_WIDTH_PX: f32 = 7.0; use crate::terminal::Terminal; use crate::terminal::TerminalSize; use crate::terminal_runtime::{TerminalRenderSnapshot, TerminalRuntimeHandle}; +use crate::terminal_search::SearchMatch; use crate::theme::{CodirigentTheme, Rgba}; use alacritty_terminal::term::TermMode; use codirigent_core::SessionId; @@ -193,6 +195,76 @@ impl Selection { } } +/// Scrollbar interaction state for a terminal pane. +#[derive(Debug, Clone)] +pub struct ScrollbarState { + /// Current scrollbar opacity. + pub opacity: f32, + /// Whether the pointer is over the scrollbar. + pub hovered: bool, + /// Thumb drag offset from the thumb top in pixels. + pub dragging: Option, + /// Last time scroll activity occurred. + pub last_scroll_activity: Instant, +} + +impl Default for ScrollbarState { + fn default() -> Self { + Self { + opacity: 0.0, + hovered: false, + dragging: None, + last_scroll_activity: Instant::now(), + } + } +} + +/// Keyboard-focused control within the terminal search overlay. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SearchFocusControl { + /// Search query input. + #[default] + Input, + /// Previous match button. + Previous, + /// Next match button. + Next, + /// Close search button. + Close, +} + +impl SearchFocusControl { + fn cycle(self, reverse: bool) -> Self { + match (self, reverse) { + (Self::Input, false) => Self::Previous, + (Self::Previous, false) => Self::Next, + (Self::Next, false) => Self::Close, + (Self::Close, false) => Self::Input, + (Self::Input, true) => Self::Close, + (Self::Previous, true) => Self::Input, + (Self::Next, true) => Self::Previous, + (Self::Close, true) => Self::Next, + } + } +} + +/// Terminal search overlay state. +#[derive(Debug, Clone, Default)] +pub struct SearchState { + /// Whether the search overlay is currently open. + pub active: bool, + /// Current search query. + pub query: String, + /// Cached matches for the current query. + pub matches: Vec, + /// Focused match index. + pub current_match: Option, + /// Monotonic debounce generation. + pub generation: u64, + /// Keyboard-focused control inside the search overlay. + pub focus_control: SearchFocusControl, +} + /// Terminal view component. /// /// Renders terminal content to the screen, handling cells, cursor, @@ -224,6 +296,8 @@ pub struct TerminalView { cols: u16, /// Cached terminal mode flags from the latest runtime snapshot. mode: TermMode, + /// Cached scrollback history size from the latest runtime snapshot. + history_size: usize, /// Cached viewport display offset from the latest runtime snapshot. display_offset: usize, /// Generation of the latest applied runtime snapshot. @@ -252,6 +326,10 @@ pub struct TerminalView { /// Updated alongside row caches in `ensure_row_caches()` so the render /// pass never calls `renderable_content()` for cursor/IME positioning. cached_cursor_viewport_pos: Option<(f32, f32)>, + /// Scrollbar interaction state. + scrollbar: ScrollbarState, + /// Search overlay state. + search: SearchState, } impl TerminalView { @@ -287,6 +365,7 @@ impl TerminalView { rows: 0, cols: 0, mode: TermMode::empty(), + history_size: 0, display_offset: 0, snapshot_generation: 0, cached_content: None, @@ -300,6 +379,8 @@ impl TerminalView { cached_terminal_bg, cached_terminal_fg, cached_cursor_viewport_pos: None, + scrollbar: ScrollbarState::default(), + search: SearchState::default(), }; let _ = view.apply_snapshot(snapshot); view @@ -325,15 +406,21 @@ impl TerminalView { || self.cols != snapshot.cols || self.cached_rows.len() != snapshot.cached_rows.len(); + let display_offset_changed = self.display_offset != snapshot.display_offset; self.rows = snapshot.rows; self.cols = snapshot.cols; self.mode = snapshot.mode; + self.history_size = snapshot.history_size; self.display_offset = snapshot.display_offset; self.snapshot_generation = snapshot.generation; self.cached_rows = snapshot.cached_rows; self.cached_content = None; self.refresh_cursor_cache(snapshot.cursor_viewport_cell); + if display_offset_changed { + self.note_scroll_activity(); + } + if requires_full_shaped_rebuild { self.cached_shaped_font_family = None; self.cached_shaped_font_size = None; @@ -469,6 +556,11 @@ impl TerminalView { self.rows } + /// Get the total number of scrollback lines currently retained. + pub fn total_scrollback_lines(&self) -> usize { + self.history_size + } + /// Get the current terminal column count. pub fn cols(&self) -> u16 { self.cols @@ -509,6 +601,17 @@ impl TerminalView { let _ = self.scroll_to_bottom_if_needed(); } + /// Scroll to an absolute scrollback offset. + pub fn scroll_to_offset(&mut self, target: usize) { + let target = target.min(self.history_size); + if target == self.display_offset { + return; + } + if let Some(snapshot) = self.runtime.scroll_to_offset(target) { + let _ = self.apply_snapshot(snapshot); + } + } + /// Scroll to the bottom only when the viewport is currently in scrollback. /// /// Returns `true` if the viewport changed. @@ -878,6 +981,289 @@ impl TerminalView { rects } + /// Get scrollbar interaction state. + pub fn scrollbar(&self) -> &ScrollbarState { + &self.scrollbar + } + + /// Mark recent scrollbar activity and show it immediately. + pub fn note_scroll_activity(&mut self) { + self.scrollbar.opacity = 1.0; + self.scrollbar.last_scroll_activity = Instant::now(); + } + + /// Update scrollbar hover state. + pub fn set_scrollbar_hovered(&mut self, hovered: bool) { + self.scrollbar.hovered = hovered; + if hovered { + self.note_scroll_activity(); + } + } + + /// Begin thumb dragging. + pub fn start_scrollbar_drag(&mut self, thumb_offset: f32) { + self.scrollbar.dragging = Some(thumb_offset.max(0.0)); + self.note_scroll_activity(); + } + + /// End thumb dragging. + pub fn stop_scrollbar_drag(&mut self) { + self.scrollbar.dragging = None; + } + + /// Current thumb drag offset, if any. + pub fn scrollbar_drag_offset(&self) -> Option { + self.scrollbar.dragging + } + + /// Update the scrollbar opacity when inactivity has elapsed. + pub fn fade_scrollbar_if_idle(&mut self, now: Instant) -> bool { + if self.scrollbar.hovered || self.scrollbar.dragging.is_some() { + if self.scrollbar.opacity != 1.0 { + self.scrollbar.opacity = 1.0; + return true; + } + return false; + } + + if now + .duration_since(self.scrollbar.last_scroll_activity) + .as_millis() + >= 1500 + && self.scrollbar.opacity != 0.0 + { + self.scrollbar.opacity = 0.0; + return true; + } + + false + } + + /// Compute scrollbar thumb height and top offset for a given track height. + pub fn scrollbar_thumb_metrics(&self, track_height: f32) -> (f32, f32) { + if track_height <= 0.0 { + return (0.0, 0.0); + } + + let total_lines = (self.history_size + self.rows as usize).max(1) as f32; + let thumb_height = (track_height * (self.rows as f32 / total_lines)) + .max(30.0) + .min(track_height); + let max_thumb_top = (track_height - thumb_height).max(0.0); + let thumb_top = if self.history_size == 0 || max_thumb_top == 0.0 { + max_thumb_top + } else { + max_thumb_top * (1.0 - (self.display_offset as f32 / self.history_size as f32)) + }; + + (thumb_height, thumb_top) + } + + /// Convert a track-relative Y position into a scrollback offset. + pub fn scrollbar_offset_for_pointer( + &self, + pointer_y: f32, + track_height: f32, + drag_offset: Option, + ) -> usize { + if self.history_size == 0 || track_height <= 0.0 { + return 0; + } + + if drag_offset.is_none() { + let fraction = (pointer_y.clamp(0.0, track_height) / track_height).clamp(0.0, 1.0); + ((1.0 - fraction) * self.history_size as f32).round() as usize + } else { + let (thumb_height, _) = self.scrollbar_thumb_metrics(track_height); + let max_thumb_top = (track_height - thumb_height).max(0.0); + let thumb_top = (pointer_y - drag_offset.unwrap_or(0.0)).clamp(0.0, max_thumb_top); + + if max_thumb_top == 0.0 { + 0 + } else { + ((1.0 - (thumb_top / max_thumb_top)) * self.history_size as f32).round() as usize + } + } + } + + /// Get search overlay state. + pub fn search(&self) -> &SearchState { + &self.search + } + + /// Open search for this terminal. + pub fn open_search(&mut self) { + self.search.active = true; + self.search.focus_control = SearchFocusControl::Input; + if self.search.current_match.is_none() && !self.search.matches.is_empty() { + self.search.current_match = Some(0); + } + } + + /// Close search and clear its matches. + pub fn close_search(&mut self) { + self.search = SearchState::default(); + } + + /// Replace the current search query. + pub fn set_search_query(&mut self, query: String) { + self.search.query = query; + self.search.generation = self.search.generation.saturating_add(1); + if self.search.query.is_empty() { + self.clear_search_matches(); + } else { + self.search.matches.clear(); + self.search.current_match = None; + } + } + + /// Append committed text to the search query. + pub fn append_search_text(&mut self, text: &str) { + if text.is_empty() { + return; + } + + self.search.query.push_str(text); + self.search.generation = self.search.generation.saturating_add(1); + self.search.matches.clear(); + self.search.current_match = None; + } + + /// Remove the last search character. + pub fn pop_search_char(&mut self) { + if self.search.query.pop().is_some() { + self.search.generation = self.search.generation.saturating_add(1); + if self.search.query.is_empty() { + self.clear_search_matches(); + } else { + self.search.matches.clear(); + self.search.current_match = None; + } + } + } + + /// Current search debounce generation. + pub fn search_generation(&self) -> u64 { + self.search.generation + } + + /// Current search query text. + pub fn search_query(&self) -> &str { + &self.search.query + } + + /// Keyboard-focused control for the search overlay. + pub fn search_focus_control(&self) -> SearchFocusControl { + self.search.focus_control + } + + /// Update the keyboard-focused control for the search overlay. + pub fn set_search_focus_control(&mut self, control: SearchFocusControl) { + self.search.focus_control = control; + } + + /// Move keyboard focus to the next or previous search control. + pub fn cycle_search_focus_control(&mut self, reverse: bool) { + self.search.focus_control = self.search.focus_control.cycle(reverse); + } + + /// Apply search results for the current query. + pub fn set_search_matches(&mut self, matches: Vec) { + self.search.matches = matches; + self.search.current_match = if self.search.matches.is_empty() { + None + } else { + Some(0) + }; + } + + /// Clear cached search matches. + pub fn clear_search_matches(&mut self) { + self.search.matches.clear(); + self.search.current_match = None; + } + + /// Update the active search match index. + pub fn set_current_search_match(&mut self, index: Option) { + self.search.current_match = index; + } + + /// Search highlight rects for the current viewport. + pub(crate) fn search_highlight_rects_hsla(&self) -> Vec<(usize, usize, usize, gpui::Hsla)> { + if !self.search.active || self.search.matches.is_empty() { + return Vec::new(); + } + + let viewport_start_line = -(self.display_offset as i32); + let viewport_end_line = viewport_start_line + self.rows as i32 - 1; + let cols = self.cols as usize; + let inactive: gpui::Hsla = self.theme.primary.into(); + let active: gpui::Hsla = self.theme.orange.into(); + let mut rects = Vec::new(); + + for (index, search_match) in self.search.matches.iter().enumerate() { + let visible_start = search_match.grid_line.max(viewport_start_line); + let visible_end = search_match.end_grid_line.min(viewport_end_line); + if visible_start > visible_end { + continue; + } + + let color = if self.search.current_match == Some(index) { + active.opacity(0.45) + } else { + inactive.opacity(0.28) + }; + + for line in visible_start..=visible_end { + let row = (line + self.display_offset as i32) as usize; + let start = if line == search_match.grid_line { + search_match.start_col.min(cols) + } else { + 0 + }; + let end = if line == search_match.end_grid_line { + search_match.end_col.min(cols) + } else { + cols + }; + if start < end { + rects.push((row, start, end, color)); + } + } + } + + rects + } + + /// Proportional scrollbar marker positions for current search matches. + pub fn search_marker_fractions(&self) -> Vec { + if !self.search.active || self.search.matches.is_empty() { + return Vec::new(); + } + + let total_lines = (self.history_size + self.rows as usize).max(1) as f32; + self.search + .matches + .iter() + .map(|search_match| { + ((self.history_size as i32 + search_match.grid_line) as f32 / total_lines) + .clamp(0.0, 1.0) + }) + .collect() + } + + /// Scroll the viewport so the focused match is centered when possible. + pub fn scroll_to_search_match(&mut self, index: usize) { + let Some(search_match) = self.search.matches.get(index) else { + return; + }; + + let center_row = self.rows as i32 / 2; + let target = (center_row - search_match.grid_line).max(0) as usize; + self.scroll_to_offset(target.min(self.history_size)); + self.search.current_match = Some(index); + } + 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); @@ -1115,14 +1501,15 @@ pub fn compute_cell_dimensions( let cell_width = text_system .advance(font_id, font_size_px, 'm') .map(|adv| f32::from(adv.width)) - .unwrap_or(font_size * FALLBACK_CELL_WIDTH_RATIO); + .unwrap_or(font_size * FALLBACK_CELL_WIDTH_RATIO) + .max(MIN_CELL_WIDTH_PX); // GPUI's ascent already includes room for accented characters, so natural // ascent + |descent| gives correct terminal row height without extra leading. // (The old 1.3x factor on font_size caused visible double-spacing.) let ascent: f32 = text_system.ascent(font_id, font_size_px).into(); let descent: f32 = text_system.descent(font_id, font_size_px).into(); - let cell_height = (ascent + descent.abs()) * line_height.max(1.0); + let cell_height = (ascent + descent.abs()).max(font_size) * line_height.max(1.0); (cell_width, cell_height) } @@ -1337,6 +1724,75 @@ mod tests { assert!(!view.is_focused()); } + #[test] + fn test_open_search_focuses_input() { + let mut view = create_test_view(); + view.open_search(); + assert_eq!(view.search_focus_control(), SearchFocusControl::Input); + } + + #[test] + fn test_cycle_search_focus_control_wraps() { + let mut view = create_test_view(); + view.open_search(); + + view.cycle_search_focus_control(false); + assert_eq!(view.search_focus_control(), SearchFocusControl::Previous); + + view.cycle_search_focus_control(false); + assert_eq!(view.search_focus_control(), SearchFocusControl::Next); + + view.cycle_search_focus_control(false); + assert_eq!(view.search_focus_control(), SearchFocusControl::Close); + + view.cycle_search_focus_control(false); + assert_eq!(view.search_focus_control(), SearchFocusControl::Input); + + view.cycle_search_focus_control(true); + assert_eq!(view.search_focus_control(), SearchFocusControl::Close); + } + + #[test] + fn test_search_query_edits_clear_stale_matches() { + let mut view = create_test_view(); + view.open_search(); + view.set_search_matches(vec![SearchMatch { + grid_line: 0, + start_col: 0, + end_grid_line: 0, + end_col: 1, + }]); + + view.append_search_text("a"); + + assert!(view.search().matches.is_empty()); + assert_eq!(view.search().current_match, None); + } + + #[test] + fn test_scrollbar_live_view_renders_at_bottom() { + let mut view = create_test_view(); + view.history_size = 100; + view.display_offset = 0; + + let (_, thumb_top) = view.scrollbar_thumb_metrics(200.0); + + assert!(thumb_top > 0.0); + } + + #[test] + fn test_scrollbar_pointer_mapping_uses_bottom_as_live_view() { + let mut view = create_test_view(); + view.history_size = 100; + view.display_offset = 0; + + let top_target = view.scrollbar_offset_for_pointer(0.0, 200.0, None); + let bottom_target = view.scrollbar_offset_for_pointer(200.0, 200.0, None); + + assert_eq!(top_target, 100); + assert_eq!(bottom_target, 0); + } + #[test] fn test_pixel_size() { let view = create_test_view(); @@ -1646,6 +2102,7 @@ mod tests { rows: 2, cols: 2, mode: TermMode::empty(), + history_size: 0, display_offset: 0, cached_rows: Vec::new(), dirty_rows: None, diff --git a/crates/codirigent-ui/src/theme.rs b/crates/codirigent-ui/src/theme.rs index b15f707..74ff00b 100644 --- a/crates/codirigent-ui/src/theme.rs +++ b/crates/codirigent-ui/src/theme.rs @@ -9,7 +9,7 @@ //! - [`Hsla`] - Hue-Saturation-Lightness-Alpha for UI elements (GPUI compatible) //! - [`Rgba`] - Red-Green-Blue-Alpha for terminal colors (alacritty_terminal compatible) -use codirigent_core::SessionStatus; +use codirigent_core::{config::default_terminal_font_family, SessionStatus}; #[cfg(feature = "gpui-full")] use gpui::Hsla as GpuiHsla; @@ -655,26 +655,6 @@ impl Default for CodirigentTheme { } } -/// Default monospace font family for terminals per platform. -pub fn default_terminal_font_family() -> &'static str { - #[cfg(target_os = "windows")] - { - "Consolas" - } - #[cfg(target_os = "macos")] - { - "Menlo" - } - #[cfg(all(unix, not(target_os = "macos")))] - { - "DejaVu Sans Mono" - } - #[cfg(not(any(windows, unix)))] - { - "monospace" - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/codirigent-ui/src/theme_config/builtin_themes/catppuccin-latte.json b/crates/codirigent-ui/src/theme_config/builtin_themes/catppuccin-latte.json new file mode 100644 index 0000000..75df8f2 --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/builtin_themes/catppuccin-latte.json @@ -0,0 +1,102 @@ +{ + "id": "catppuccin-latte", + "name": "Catppuccin Latte", + "is_dark": false, + "colors": { + "background": { + "app": "#eff1f5", + "panel": "#ffffff", + "header": "#dce0e8", + "sidebar": "#e6e9ef", + "icon_rail": "#e6e9ef", + "drawer": "#ffffff" + }, + "foreground": { + "primary": "#4c4f69", + "secondary": "#5c5f77", + "muted": "#8c8fa1" + }, + "border": { + "default": "#ccd0da", + "focused": "#1e66f5" + }, + "interaction": { + "hover": "#dce0e8", + "active": "#ccd0da", + "selection": "#1e66f52e" + }, + "accent": { + "primary": "#1e66f5", + "secondary": "#179299", + "purple": "#8839ef", + "orange": "#fe640b", + "selected_ring": "#1e66f5", + "broadcast": "#d20f39", + "ai_summary_background": "#1e66f514", + "ai_summary_text": "#1e66f5", + "input_required_background": "#d20f391f", + "input_required_accent": "#d20f39" + }, + "status": { + "idle": "#8c8fa1", + "working": "#df8e1d", + "needs_attention": "#d20f39", + "response_ready": "#40a02b", + "error": "#d20f39" + }, + "priority": { + "high": "#d20f39", + "medium": "#df8e1d", + "low": "#1e66f5" + }, + "session_groups": [ + "#1e66f5", + "#179299", + "#8839ef", + "#fe640b", + "#d20f39", + "#40a02b" + ], + "terminal": { + "background": "#eff1f5", + "foreground": "#4c4f69", + "cursor": "#1e66f5", + "selection_background": "#ccd0dacc", + "selection_foreground": "#4c4f69", + "palette": { + "black": "#5c5f77", + "red": "#d20f39", + "green": "#40a02b", + "yellow": "#df8e1d", + "blue": "#1e66f5", + "magenta": "#ea76cb", + "cyan": "#179299", + "white": "#acb0be", + "bright_black": "#6c6f85", + "bright_red": "#d20f39", + "bright_green": "#40a02b", + "bright_yellow": "#df8e1d", + "bright_blue": "#1e66f5", + "bright_magenta": "#ea76cb", + "bright_cyan": "#179299", + "bright_white": "#4c4f69" + } + } + }, + "typography": { + "ui_font_family": "Inter", + "terminal_font_family": "", + "base_font_size": 13.0, + "terminal_font_size": 13.0, + "line_height": 1.0 + }, + "spacing": { + "xs": 2.0, + "sm": 4.0, + "md": 8.0, + "lg": 16.0, + "xl": 24.0, + "grid_gap": 4.0, + "border_radius": 4.0 + } +} \ No newline at end of file diff --git a/crates/codirigent-ui/src/theme_config/builtin_themes/catppuccin-mocha.json b/crates/codirigent-ui/src/theme_config/builtin_themes/catppuccin-mocha.json new file mode 100644 index 0000000..8b479e7 --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/builtin_themes/catppuccin-mocha.json @@ -0,0 +1,102 @@ +{ + "id": "catppuccin-mocha", + "name": "Catppuccin Mocha", + "is_dark": true, + "colors": { + "background": { + "app": "#11111b", + "panel": "#181825", + "header": "#181825", + "sidebar": "#181825", + "icon_rail": "#181825", + "drawer": "#1e1e2e" + }, + "foreground": { + "primary": "#cdd6f4", + "secondary": "#a6adc8", + "muted": "#6c7086" + }, + "border": { + "default": "#313244", + "focused": "#89b4fa" + }, + "interaction": { + "hover": "#1e1e2e", + "active": "#313244", + "selection": "#89b4fa3d" + }, + "accent": { + "primary": "#89b4fa", + "secondary": "#74c7ec", + "purple": "#cba6f7", + "orange": "#fab387", + "selected_ring": "#89b4fa", + "broadcast": "#f38ba8", + "ai_summary_background": "#89b4fa14", + "ai_summary_text": "#b4befe", + "input_required_background": "#f38ba829", + "input_required_accent": "#f38ba8" + }, + "status": { + "idle": "#6c7086", + "working": "#fab387", + "needs_attention": "#f38ba8", + "response_ready": "#a6e3a1", + "error": "#f38ba8" + }, + "priority": { + "high": "#f38ba8", + "medium": "#fab387", + "low": "#89b4fa" + }, + "session_groups": [ + "#89b4fa", + "#74c7ec", + "#cba6f7", + "#fab387", + "#f38ba8", + "#a6e3a1" + ], + "terminal": { + "background": "#1e1e2e", + "foreground": "#cdd6f4", + "cursor": "#f5e0dc", + "selection_background": "#585b70cc", + "selection_foreground": "#cdd6f4", + "palette": { + "black": "#45475a", + "red": "#f38ba8", + "green": "#a6e3a1", + "yellow": "#f9e2af", + "blue": "#89b4fa", + "magenta": "#f5c2e7", + "cyan": "#94e2d5", + "white": "#bac2de", + "bright_black": "#585b70", + "bright_red": "#f38ba8", + "bright_green": "#a6e3a1", + "bright_yellow": "#f9e2af", + "bright_blue": "#89b4fa", + "bright_magenta": "#f5c2e7", + "bright_cyan": "#94e2d5", + "bright_white": "#a6adc8" + } + } + }, + "typography": { + "ui_font_family": "Inter", + "terminal_font_family": "", + "base_font_size": 13.0, + "terminal_font_size": 13.0, + "line_height": 1.0 + }, + "spacing": { + "xs": 2.0, + "sm": 4.0, + "md": 8.0, + "lg": 16.0, + "xl": 24.0, + "grid_gap": 4.0, + "border_radius": 4.0 + } +} \ No newline at end of file diff --git a/crates/codirigent-ui/src/theme_config/builtin_themes/dark.json b/crates/codirigent-ui/src/theme_config/builtin_themes/dark.json new file mode 100644 index 0000000..83c6933 --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/builtin_themes/dark.json @@ -0,0 +1,102 @@ +{ + "id": "dark", + "name": "Dark", + "is_dark": true, + "colors": { + "background": { + "app": "#050505", + "panel": "#0c0c0e", + "header": "#09090b", + "sidebar": "#0c0c0e", + "icon_rail": "#0c0c0e", + "drawer": "#121214" + }, + "foreground": { + "primary": "#e0e0e0", + "secondary": "#888888", + "muted": "#555555" + }, + "border": { + "default": "#1a1a1f", + "focused": "#6366f1" + }, + "interaction": { + "hover": "#151518", + "active": "#1a1a22", + "selection": "#6366f14d" + }, + "accent": { + "primary": "#6366f1", + "secondary": "#818cf8", + "purple": "#a78bfa", + "orange": "#f59e0b", + "selected_ring": "#6366f1", + "broadcast": "#f43f5e", + "ai_summary_background": "#6366f10d", + "ai_summary_text": "#c7d2fecc", + "input_required_background": "#4c051933", + "input_required_accent": "#f43f5e" + }, + "status": { + "idle": "#52525b", + "working": "#f59e0b", + "needs_attention": "#f43f5e", + "response_ready": "#22c55e", + "error": "#ef4444" + }, + "priority": { + "high": "#ff6b6b", + "medium": "#f59e0b", + "low": "#5b8def" + }, + "session_groups": [ + "#6366f1", + "#818cf8", + "#a78bfa", + "#f59e0b", + "#f43f5e", + "#10b981" + ], + "terminal": { + "background": "#050505", + "foreground": "#e0e0e0", + "cursor": "#6366f1", + "selection_background": "#6366f14d", + "selection_foreground": "#e0e0e0", + "palette": { + "black": "#000000", + "red": "#cc0000", + "green": "#00cc00", + "yellow": "#cccc00", + "blue": "#0000cc", + "magenta": "#cc00cc", + "cyan": "#00cccc", + "white": "#cccccc", + "bright_black": "#808080", + "bright_red": "#ff0000", + "bright_green": "#00ff00", + "bright_yellow": "#ffff00", + "bright_blue": "#0000ff", + "bright_magenta": "#ff00ff", + "bright_cyan": "#00ffff", + "bright_white": "#ffffff" + } + } + }, + "typography": { + "ui_font_family": "Inter", + "terminal_font_family": "", + "base_font_size": 13.0, + "terminal_font_size": 13.0, + "line_height": 1.0 + }, + "spacing": { + "xs": 2.0, + "sm": 4.0, + "md": 8.0, + "lg": 16.0, + "xl": 24.0, + "grid_gap": 4.0, + "border_radius": 4.0 + } +} \ No newline at end of file diff --git a/crates/codirigent-ui/src/theme_config/builtin_themes/github-light.json b/crates/codirigent-ui/src/theme_config/builtin_themes/github-light.json new file mode 100644 index 0000000..77ae27c --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/builtin_themes/github-light.json @@ -0,0 +1,102 @@ +{ + "id": "github-light", + "name": "GitHub Light", + "is_dark": false, + "colors": { + "background": { + "app": "#f6f8fa", + "panel": "#ffffff", + "header": "#f3f4f6", + "sidebar": "#f6f8fa", + "icon_rail": "#f6f8fa", + "drawer": "#ffffff" + }, + "foreground": { + "primary": "#24292f", + "secondary": "#57606a", + "muted": "#6e7781" + }, + "border": { + "default": "#d0d7de", + "focused": "#0969da" + }, + "interaction": { + "hover": "#eef2f6", + "active": "#d8dee4", + "selection": "#0969da29" + }, + "accent": { + "primary": "#0969da", + "secondary": "#1f883d", + "purple": "#8250df", + "orange": "#bc4c00", + "selected_ring": "#0969da", + "broadcast": "#cf222e", + "ai_summary_background": "#0969da14", + "ai_summary_text": "#0550ae", + "input_required_background": "#cf222e1f", + "input_required_accent": "#cf222e" + }, + "status": { + "idle": "#6e7781", + "working": "#bc4c00", + "needs_attention": "#cf222e", + "response_ready": "#1a7f37", + "error": "#cf222e" + }, + "priority": { + "high": "#cf222e", + "medium": "#bc4c00", + "low": "#0969da" + }, + "session_groups": [ + "#0969da", + "#1f883d", + "#8250df", + "#bc4c00", + "#cf222e", + "#1a7f37" + ], + "terminal": { + "background": "#ffffff", + "foreground": "#24292f", + "cursor": "#0969da", + "selection_background": "#d8dee4cc", + "selection_foreground": "#24292f", + "palette": { + "black": "#24292f", + "red": "#cf222e", + "green": "#1a7f37", + "yellow": "#9a6700", + "blue": "#0969da", + "magenta": "#8250df", + "cyan": "#0550ae", + "white": "#57606a", + "bright_black": "#6e7781", + "bright_red": "#a40e26", + "bright_green": "#1a7f37", + "bright_yellow": "#bf8700", + "bright_blue": "#218bff", + "bright_magenta": "#a475f9", + "bright_cyan": "#0969da", + "bright_white": "#24292f" + } + } + }, + "typography": { + "ui_font_family": "Inter", + "terminal_font_family": "", + "base_font_size": 13.0, + "terminal_font_size": 13.0, + "line_height": 1.0 + }, + "spacing": { + "xs": 2.0, + "sm": 4.0, + "md": 8.0, + "lg": 16.0, + "xl": 24.0, + "grid_gap": 4.0, + "border_radius": 4.0 + } +} \ No newline at end of file diff --git a/crates/codirigent-ui/src/theme_config/builtin_themes/gruvbox-dark.json b/crates/codirigent-ui/src/theme_config/builtin_themes/gruvbox-dark.json new file mode 100644 index 0000000..7693a59 --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/builtin_themes/gruvbox-dark.json @@ -0,0 +1,102 @@ +{ + "id": "gruvbox-dark", + "name": "Gruvbox Dark", + "is_dark": true, + "colors": { + "background": { + "app": "#1d2021", + "panel": "#282828", + "header": "#32302f", + "sidebar": "#282828", + "icon_rail": "#282828", + "drawer": "#32302f" + }, + "foreground": { + "primary": "#ebdbb2", + "secondary": "#d5c4a1", + "muted": "#928374" + }, + "border": { + "default": "#504945", + "focused": "#83a598" + }, + "interaction": { + "hover": "#3c3836", + "active": "#504945", + "selection": "#83a59838" + }, + "accent": { + "primary": "#83a598", + "secondary": "#8ec07c", + "purple": "#d3869b", + "orange": "#fe8019", + "selected_ring": "#83a598", + "broadcast": "#fb4934", + "ai_summary_background": "#83a59814", + "ai_summary_text": "#ebdbb2", + "input_required_background": "#fb493424", + "input_required_accent": "#fb4934" + }, + "status": { + "idle": "#928374", + "working": "#fabd2f", + "needs_attention": "#fb4934", + "response_ready": "#b8bb26", + "error": "#fb4934" + }, + "priority": { + "high": "#fb4934", + "medium": "#fe8019", + "low": "#83a598" + }, + "session_groups": [ + "#83a598", + "#8ec07c", + "#d3869b", + "#fe8019", + "#fb4934", + "#b8bb26" + ], + "terminal": { + "background": "#282828", + "foreground": "#ebdbb2", + "cursor": "#fabd2f", + "selection_background": "#504945cc", + "selection_foreground": "#ebdbb2", + "palette": { + "black": "#282828", + "red": "#cc241d", + "green": "#98971a", + "yellow": "#d79921", + "blue": "#458588", + "magenta": "#b16286", + "cyan": "#689d6a", + "white": "#a89984", + "bright_black": "#928374", + "bright_red": "#fb4934", + "bright_green": "#b8bb26", + "bright_yellow": "#fabd2f", + "bright_blue": "#83a598", + "bright_magenta": "#d3869b", + "bright_cyan": "#8ec07c", + "bright_white": "#ebdbb2" + } + } + }, + "typography": { + "ui_font_family": "Inter", + "terminal_font_family": "", + "base_font_size": 13.0, + "terminal_font_size": 13.0, + "line_height": 1.0 + }, + "spacing": { + "xs": 2.0, + "sm": 4.0, + "md": 8.0, + "lg": 16.0, + "xl": 24.0, + "grid_gap": 4.0, + "border_radius": 4.0 + } +} \ No newline at end of file diff --git a/crates/codirigent-ui/src/theme_config/builtin_themes/light.json b/crates/codirigent-ui/src/theme_config/builtin_themes/light.json new file mode 100644 index 0000000..d85880a --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/builtin_themes/light.json @@ -0,0 +1,102 @@ +{ + "id": "light", + "name": "Light", + "is_dark": false, + "colors": { + "background": { + "app": "#f5f5f7", + "panel": "#ffffff", + "header": "#e8e8ec", + "sidebar": "#f0f0f4", + "icon_rail": "#f0f0f4", + "drawer": "#ffffff" + }, + "foreground": { + "primary": "#1a1a1c", + "secondary": "#666666", + "muted": "#999999" + }, + "border": { + "default": "#d0d0d8", + "focused": "#4f46e5" + }, + "interaction": { + "hover": "#e8e8ec", + "active": "#d8d8e0", + "selection": "#4f46e533" + }, + "accent": { + "primary": "#4f46e5", + "secondary": "#6366f1", + "purple": "#8b6fd9", + "orange": "#d98a0b", + "selected_ring": "#4f46e5", + "broadcast": "#e11d48", + "ai_summary_background": "#4f46e514", + "ai_summary_text": "#3730a3", + "input_required_background": "#e11d481a", + "input_required_accent": "#e11d48" + }, + "status": { + "idle": "#71717a", + "working": "#d97706", + "needs_attention": "#e11d48", + "response_ready": "#16a34a", + "error": "#dc2626" + }, + "priority": { + "high": "#dc2626", + "medium": "#d98a0b", + "low": "#6366f1" + }, + "session_groups": [ + "#4f46e5", + "#6366f1", + "#8b6fd9", + "#d98a0b", + "#e11d48", + "#059669" + ], + "terminal": { + "background": "#f5f5f7", + "foreground": "#1a1a1c", + "cursor": "#4f46e5", + "selection_background": "#4f46e533", + "selection_foreground": "#1a1a1c", + "palette": { + "black": "#000000", + "red": "#cc0000", + "green": "#00cc00", + "yellow": "#cccc00", + "blue": "#0000cc", + "magenta": "#cc00cc", + "cyan": "#00cccc", + "white": "#cccccc", + "bright_black": "#808080", + "bright_red": "#ff0000", + "bright_green": "#00ff00", + "bright_yellow": "#ffff00", + "bright_blue": "#0000ff", + "bright_magenta": "#ff00ff", + "bright_cyan": "#00ffff", + "bright_white": "#ffffff" + } + } + }, + "typography": { + "ui_font_family": "Inter", + "terminal_font_family": "", + "base_font_size": 13.0, + "terminal_font_size": 13.0, + "line_height": 1.0 + }, + "spacing": { + "xs": 2.0, + "sm": 4.0, + "md": 8.0, + "lg": 16.0, + "xl": 24.0, + "grid_gap": 4.0, + "border_radius": 4.0 + } +} \ No newline at end of file diff --git a/crates/codirigent-ui/src/theme_config/builtin_themes/one-dark.json b/crates/codirigent-ui/src/theme_config/builtin_themes/one-dark.json new file mode 100644 index 0000000..84df3db --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/builtin_themes/one-dark.json @@ -0,0 +1,102 @@ +{ + "id": "one-dark", + "name": "One Dark", + "is_dark": true, + "colors": { + "background": { + "app": "#21252b", + "panel": "#282c34", + "header": "#1f2329", + "sidebar": "#21252b", + "icon_rail": "#21252b", + "drawer": "#282c34" + }, + "foreground": { + "primary": "#abb2bf", + "secondary": "#828997", + "muted": "#5c6370" + }, + "border": { + "default": "#3b4048", + "focused": "#61afef" + }, + "interaction": { + "hover": "#2c313a", + "active": "#333842", + "selection": "#61afef38" + }, + "accent": { + "primary": "#61afef", + "secondary": "#56b6c2", + "purple": "#c678dd", + "orange": "#d19a66", + "selected_ring": "#61afef", + "broadcast": "#e06c75", + "ai_summary_background": "#61afef14", + "ai_summary_text": "#cdd6f4", + "input_required_background": "#e06c7524", + "input_required_accent": "#e06c75" + }, + "status": { + "idle": "#5c6370", + "working": "#d19a66", + "needs_attention": "#e06c75", + "response_ready": "#98c379", + "error": "#e06c75" + }, + "priority": { + "high": "#e06c75", + "medium": "#d19a66", + "low": "#61afef" + }, + "session_groups": [ + "#61afef", + "#56b6c2", + "#c678dd", + "#d19a66", + "#e06c75", + "#98c379" + ], + "terminal": { + "background": "#282c34", + "foreground": "#abb2bf", + "cursor": "#528bff", + "selection_background": "#3e4451cc", + "selection_foreground": "#abb2bf", + "palette": { + "black": "#282c34", + "red": "#e06c75", + "green": "#98c379", + "yellow": "#e5c07b", + "blue": "#61afef", + "magenta": "#c678dd", + "cyan": "#56b6c2", + "white": "#dcdfe4", + "bright_black": "#5c6370", + "bright_red": "#e06c75", + "bright_green": "#98c379", + "bright_yellow": "#e5c07b", + "bright_blue": "#61afef", + "bright_magenta": "#c678dd", + "bright_cyan": "#56b6c2", + "bright_white": "#ffffff" + } + } + }, + "typography": { + "ui_font_family": "Inter", + "terminal_font_family": "", + "base_font_size": 13.0, + "terminal_font_size": 13.0, + "line_height": 1.0 + }, + "spacing": { + "xs": 2.0, + "sm": 4.0, + "md": 8.0, + "lg": 16.0, + "xl": 24.0, + "grid_gap": 4.0, + "border_radius": 4.0 + } +} \ No newline at end of file diff --git a/crates/codirigent-ui/src/theme_config/builtin_themes/solarized-dark.json b/crates/codirigent-ui/src/theme_config/builtin_themes/solarized-dark.json new file mode 100644 index 0000000..b51ba16 --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/builtin_themes/solarized-dark.json @@ -0,0 +1,102 @@ +{ + "id": "solarized-dark", + "name": "Solarized Dark", + "is_dark": true, + "colors": { + "background": { + "app": "#002b36", + "panel": "#073642", + "header": "#00212b", + "sidebar": "#073642", + "icon_rail": "#073642", + "drawer": "#0b3c49" + }, + "foreground": { + "primary": "#839496", + "secondary": "#93a1a1", + "muted": "#586e75" + }, + "border": { + "default": "#0f4b59", + "focused": "#268bd2" + }, + "interaction": { + "hover": "#0b3c49", + "active": "#135564", + "selection": "#268bd233" + }, + "accent": { + "primary": "#268bd2", + "secondary": "#2aa198", + "purple": "#6c71c4", + "orange": "#cb4b16", + "selected_ring": "#268bd2", + "broadcast": "#dc322f", + "ai_summary_background": "#268bd214", + "ai_summary_text": "#93a1a1", + "input_required_background": "#dc322f24", + "input_required_accent": "#dc322f" + }, + "status": { + "idle": "#586e75", + "working": "#b58900", + "needs_attention": "#dc322f", + "response_ready": "#859900", + "error": "#dc322f" + }, + "priority": { + "high": "#dc322f", + "medium": "#cb4b16", + "low": "#268bd2" + }, + "session_groups": [ + "#268bd2", + "#2aa198", + "#6c71c4", + "#cb4b16", + "#dc322f", + "#859900" + ], + "terminal": { + "background": "#002b36", + "foreground": "#839496", + "cursor": "#93a1a1", + "selection_background": "#073642cc", + "selection_foreground": "#93a1a1", + "palette": { + "black": "#073642", + "red": "#dc322f", + "green": "#859900", + "yellow": "#b58900", + "blue": "#268bd2", + "magenta": "#d33682", + "cyan": "#2aa198", + "white": "#eee8d5", + "bright_black": "#002b36", + "bright_red": "#cb4b16", + "bright_green": "#586e75", + "bright_yellow": "#657b83", + "bright_blue": "#839496", + "bright_magenta": "#6c71c4", + "bright_cyan": "#93a1a1", + "bright_white": "#fdf6e3" + } + } + }, + "typography": { + "ui_font_family": "Inter", + "terminal_font_family": "", + "base_font_size": 13.0, + "terminal_font_size": 13.0, + "line_height": 1.0 + }, + "spacing": { + "xs": 2.0, + "sm": 4.0, + "md": 8.0, + "lg": 16.0, + "xl": 24.0, + "grid_gap": 4.0, + "border_radius": 4.0 + } +} \ No newline at end of file diff --git a/crates/codirigent-ui/src/theme_config/builtin_themes/solarized-light.json b/crates/codirigent-ui/src/theme_config/builtin_themes/solarized-light.json new file mode 100644 index 0000000..6d406c1 --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/builtin_themes/solarized-light.json @@ -0,0 +1,102 @@ +{ + "id": "solarized-light", + "name": "Solarized Light", + "is_dark": false, + "colors": { + "background": { + "app": "#fdf6e3", + "panel": "#fffdf7", + "header": "#eee8d5", + "sidebar": "#f5efdc", + "icon_rail": "#f5efdc", + "drawer": "#fffdf7" + }, + "foreground": { + "primary": "#586e75", + "secondary": "#657b83", + "muted": "#93a1a1" + }, + "border": { + "default": "#d8cfb1", + "focused": "#268bd2" + }, + "interaction": { + "hover": "#eee8d5", + "active": "#e3dcc6", + "selection": "#268bd22e" + }, + "accent": { + "primary": "#268bd2", + "secondary": "#2aa198", + "purple": "#6c71c4", + "orange": "#cb4b16", + "selected_ring": "#268bd2", + "broadcast": "#dc322f", + "ai_summary_background": "#268bd214", + "ai_summary_text": "#005f87", + "input_required_background": "#dc322f1f", + "input_required_accent": "#dc322f" + }, + "status": { + "idle": "#93a1a1", + "working": "#b58900", + "needs_attention": "#dc322f", + "response_ready": "#859900", + "error": "#dc322f" + }, + "priority": { + "high": "#dc322f", + "medium": "#cb4b16", + "low": "#268bd2" + }, + "session_groups": [ + "#268bd2", + "#2aa198", + "#6c71c4", + "#cb4b16", + "#dc322f", + "#859900" + ], + "terminal": { + "background": "#fdf6e3", + "foreground": "#586e75", + "cursor": "#268bd2", + "selection_background": "#eee8d5cc", + "selection_foreground": "#586e75", + "palette": { + "black": "#073642", + "red": "#dc322f", + "green": "#859900", + "yellow": "#b58900", + "blue": "#268bd2", + "magenta": "#d33682", + "cyan": "#2aa198", + "white": "#eee8d5", + "bright_black": "#657b83", + "bright_red": "#cb4b16", + "bright_green": "#93a1a1", + "bright_yellow": "#657b83", + "bright_blue": "#839496", + "bright_magenta": "#6c71c4", + "bright_cyan": "#586e75", + "bright_white": "#002b36" + } + } + }, + "typography": { + "ui_font_family": "Inter", + "terminal_font_family": "", + "base_font_size": 13.0, + "terminal_font_size": 13.0, + "line_height": 1.0 + }, + "spacing": { + "xs": 2.0, + "sm": 4.0, + "md": 8.0, + "lg": 16.0, + "xl": 24.0, + "grid_gap": 4.0, + "border_radius": 4.0 + } +} \ No newline at end of file diff --git a/crates/codirigent-ui/src/theme_config/builtin_themes/tokyo-night.json b/crates/codirigent-ui/src/theme_config/builtin_themes/tokyo-night.json new file mode 100644 index 0000000..235fe7a --- /dev/null +++ b/crates/codirigent-ui/src/theme_config/builtin_themes/tokyo-night.json @@ -0,0 +1,102 @@ +{ + "id": "tokyo-night", + "name": "Tokyo Night", + "is_dark": true, + "colors": { + "background": { + "app": "#16161e", + "panel": "#1a1b26", + "header": "#1f2335", + "sidebar": "#1a1b26", + "icon_rail": "#1a1b26", + "drawer": "#1f2335" + }, + "foreground": { + "primary": "#c0caf5", + "secondary": "#9aa5ce", + "muted": "#565f89" + }, + "border": { + "default": "#292e42", + "focused": "#7aa2f7" + }, + "interaction": { + "hover": "#24283b", + "active": "#2f3549", + "selection": "#7aa2f73d" + }, + "accent": { + "primary": "#7aa2f7", + "secondary": "#7dcfff", + "purple": "#bb9af7", + "orange": "#ff9e64", + "selected_ring": "#7aa2f7", + "broadcast": "#f7768e", + "ai_summary_background": "#7aa2f714", + "ai_summary_text": "#c0caf5", + "input_required_background": "#f7768e24", + "input_required_accent": "#f7768e" + }, + "status": { + "idle": "#565f89", + "working": "#ff9e64", + "needs_attention": "#f7768e", + "response_ready": "#9ece6a", + "error": "#f7768e" + }, + "priority": { + "high": "#f7768e", + "medium": "#ff9e64", + "low": "#7aa2f7" + }, + "session_groups": [ + "#7aa2f7", + "#7dcfff", + "#bb9af7", + "#ff9e64", + "#f7768e", + "#9ece6a" + ], + "terminal": { + "background": "#1a1b26", + "foreground": "#c0caf5", + "cursor": "#c0caf5", + "selection_background": "#283457cc", + "selection_foreground": "#c0caf5", + "palette": { + "black": "#15161e", + "red": "#f7768e", + "green": "#9ece6a", + "yellow": "#e0af68", + "blue": "#7aa2f7", + "magenta": "#bb9af7", + "cyan": "#7dcfff", + "white": "#a9b1d6", + "bright_black": "#414868", + "bright_red": "#f7768e", + "bright_green": "#9ece6a", + "bright_yellow": "#e0af68", + "bright_blue": "#7aa2f7", + "bright_magenta": "#bb9af7", + "bright_cyan": "#7dcfff", + "bright_white": "#c0caf5" + } + } + }, + "typography": { + "ui_font_family": "Inter", + "terminal_font_family": "", + "base_font_size": 13.0, + "terminal_font_size": 13.0, + "line_height": 1.0 + }, + "spacing": { + "xs": 2.0, + "sm": 4.0, + "md": 8.0, + "lg": 16.0, + "xl": 24.0, + "grid_gap": 4.0, + "border_radius": 4.0 + } +} \ No newline at end of file diff --git a/crates/codirigent-ui/src/theme_config/builtins.rs b/crates/codirigent-ui/src/theme_config/builtins.rs index cb1564f..570a53c 100644 --- a/crates/codirigent-ui/src/theme_config/builtins.rs +++ b/crates/codirigent-ui/src/theme_config/builtins.rs @@ -9,16 +9,64 @@ 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; +const BUILTIN_THEME_JSONS: &[(&str, &str)] = &[ + ("dark", include_str!("builtin_themes/dark.json")), + ("light", include_str!("builtin_themes/light.json")), + ( + "catppuccin-latte", + include_str!("builtin_themes/catppuccin-latte.json"), + ), + ( + "github-light", + include_str!("builtin_themes/github-light.json"), + ), + ( + "solarized-light", + include_str!("builtin_themes/solarized-light.json"), + ), + ( + "catppuccin-mocha", + include_str!("builtin_themes/catppuccin-mocha.json"), + ), + ( + "tokyo-night", + include_str!("builtin_themes/tokyo-night.json"), + ), + ("one-dark", include_str!("builtin_themes/one-dark.json")), + ( + "gruvbox-dark", + include_str!("builtin_themes/gruvbox-dark.json"), + ), + ( + "solarized-dark", + include_str!("builtin_themes/solarized-dark.json"), + ), +]; + +pub(crate) fn builtin_themes() -> Vec { + BUILTIN_THEME_JSONS + .iter() + .map(|(_, json)| Theme::from_json(json).expect("builtin theme JSON must be valid")) + .collect() +} + +fn builtin_theme(id: &str) -> Theme { + let json = BUILTIN_THEME_JSONS + .iter() + .find_map(|(theme_id, json)| (*theme_id == id).then_some(*json)) + .expect("builtin theme ID must exist"); + Theme::from_json(json).expect("builtin theme JSON must be valid") +} impl Theme { /// Create the built-in dark theme definition. pub fn dark() -> Self { - Self::from_runtime("dark", "Dark", true, &CodirigentTheme::dark()) + builtin_theme("dark") } /// Create the built-in light theme definition. pub fn light() -> Self { - Self::from_runtime("light", "Light", false, &CodirigentTheme::light()) + builtin_theme("light") } /// Build a serializable theme from the runtime theme model. @@ -91,7 +139,7 @@ impl Theme { }, typography: ThemeTypography { ui_font_family: DEFAULT_UI_FONT_FAMILY.to_string(), - terminal_font_family: theme.terminal_font_family.clone(), + terminal_font_family: String::new(), base_font_size: theme.font_size_base, terminal_font_size: theme.terminal_font_size, line_height: theme.terminal_line_height, @@ -223,4 +271,13 @@ mod tests { let rgba = hsla_to_rgba(Hsla::new(0.0, 0.0, 0.5, 1.0)); assert_eq!(rgba, Rgba::rgb(128, 128, 128)); } + + #[test] + fn builtin_theme_registry_loads_all_checked_in_themes() { + let ids: Vec = builtin_themes().into_iter().map(|theme| theme.id).collect(); + assert!(ids.contains(&"dark".to_string())); + assert!(ids.contains(&"light".to_string())); + assert!(ids.contains(&"tokyo-night".to_string())); + assert!(ids.contains(&"solarized-dark".to_string())); + } } diff --git a/crates/codirigent-ui/src/theme_config/conversion.rs b/crates/codirigent-ui/src/theme_config/conversion.rs index 5870b57..44735ce 100644 --- a/crates/codirigent-ui/src/theme_config/conversion.rs +++ b/crates/codirigent-ui/src/theme_config/conversion.rs @@ -138,7 +138,11 @@ impl TryFrom<&Theme> for CodirigentTheme { 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(), + terminal_font_family: if theme.typography.terminal_font_family.is_empty() { + codirigent_core::config::default_terminal_font_family().to_string() + } else { + theme.typography.terminal_font_family.clone() + }, spacing_base: theme.spacing.md, spacing_small: theme.spacing.sm, spacing_large: theme.spacing.lg, diff --git a/crates/codirigent-ui/src/theme_config/mod.rs b/crates/codirigent-ui/src/theme_config/mod.rs index 70e25a1..a55e19a 100644 --- a/crates/codirigent-ui/src/theme_config/mod.rs +++ b/crates/codirigent-ui/src/theme_config/mod.rs @@ -20,6 +20,7 @@ const DEFAULT_EXTRA_LARGE_SPACING: f32 = 24.0; const DEFAULT_GRID_GAP: f32 = 4.0; const DEFAULT_BORDER_RADIUS: f32 = 4.0; +pub(crate) use builtins::builtin_themes; pub use conversion::ThemeConversionError; pub use schema::{ HexColor, TerminalColors, TerminalPalette, Theme, ThemeAccentColors, ThemeBackgroundColors, @@ -49,7 +50,7 @@ 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(), + terminal_font_family: String::new(), base_font_size: DEFAULT_BASE_FONT_SIZE, terminal_font_size: DEFAULT_TERMINAL_FONT_SIZE, line_height: DEFAULT_TERMINAL_LINE_HEIGHT, @@ -84,11 +85,11 @@ mod tests { } #[test] - fn light_theme_uses_runtime_terminal_font_family_default() { + fn theme_does_not_carry_terminal_font_family() { let theme = Theme::light(); - assert_eq!( - theme.typography.terminal_font_family, - crate::theme::default_terminal_font_family() + assert!( + theme.typography.terminal_font_family.is_empty(), + "themes must not dictate terminal font — that is a user setting" ); } diff --git a/crates/codirigent-ui/src/theme_manager.rs b/crates/codirigent-ui/src/theme_manager.rs index 5c874ea..e99213b 100644 --- a/crates/codirigent-ui/src/theme_manager.rs +++ b/crates/codirigent-ui/src/theme_manager.rs @@ -18,7 +18,7 @@ //! ``` use crate::theme::CodirigentTheme; -use crate::theme_config::Theme; +use crate::theme_config::{builtin_themes, Theme}; use anyhow::Result; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -26,6 +26,18 @@ use tracing::warn; /// Built-in theme ID used as the final fallback. pub const DEFAULT_THEME_ID: &str = "dark"; +const BUILTIN_THEME_IDS: &[&str] = &[ + "dark", + "light", + "catppuccin-latte", + "github-light", + "solarized-light", + "catppuccin-mocha", + "tokyo-night", + "one-dark", + "gruvbox-dark", + "solarized-dark", +]; /// Directory name under the user config root that stores custom theme files. pub const CUSTOM_THEME_DIRECTORY_NAME: &str = "themes"; @@ -72,7 +84,7 @@ impl ThemeManager { /// Create with built-in themes. /// - /// Includes the default dark and light themes. + /// Includes the default built-in themes. /// /// # Example /// @@ -80,12 +92,13 @@ impl ThemeManager { /// use codirigent_ui::theme_manager::ThemeManager; /// /// let manager = ThemeManager::with_defaults(); - /// assert_eq!(manager.list().len(), 2); + /// assert!(manager.list().len() >= 2); /// ``` pub fn with_defaults() -> Self { let mut themes = HashMap::new(); - themes.insert(DEFAULT_THEME_ID.to_string(), Theme::dark()); - themes.insert("light".to_string(), Theme::light()); + for theme in builtin_themes() { + themes.insert(theme.id.clone(), theme); + } Self { themes, @@ -304,7 +317,7 @@ impl ThemeManager { /// Remove a custom theme. /// - /// Built-in themes (dark, light) cannot be removed. + /// Built-in themes cannot be removed. /// /// # Arguments /// @@ -325,7 +338,7 @@ impl ThemeManager { /// ``` pub fn remove_theme(&mut self, id: &str) -> bool { // Cannot remove built-in themes - if id == DEFAULT_THEME_ID || id == "light" { + if BUILTIN_THEME_IDS.contains(&id) { return false; } let removed = self.themes.remove(id).is_some(); @@ -364,6 +377,11 @@ impl ThemeManager { self.themes.values().filter(|t| !t.is_dark).collect() } + /// Returns whether a theme ID refers to a built-in theme. + pub fn is_builtin(&self, id: &str) -> bool { + BUILTIN_THEME_IDS.contains(&id) + } + /// Convert a registered theme into the runtime theme model. pub fn runtime_theme(&self, id: &str) -> Result { let theme = self @@ -420,15 +438,20 @@ mod tests { #[test] fn test_with_defaults() { let manager = ThemeManager::with_defaults(); - assert_eq!(manager.len(), 2); + assert_eq!(manager.len(), 10); assert!(manager.get("dark").is_some()); assert!(manager.get("light").is_some()); + assert!(manager.get("catppuccin-latte").is_some()); + assert!(manager.get("github-light").is_some()); + assert!(manager.get("solarized-light").is_some()); + assert!(manager.get("catppuccin-mocha").is_some()); + assert!(manager.get("tokyo-night").is_some()); } #[test] fn test_default_trait() { let manager = ThemeManager::default(); - assert_eq!(manager.len(), 2); + assert_eq!(manager.len(), 10); } #[test] @@ -485,23 +508,28 @@ mod tests { fn test_list() { let manager = ThemeManager::with_defaults(); let themes = manager.list(); - assert_eq!(themes.len(), 2); + assert_eq!(themes.len(), 10); } #[test] fn test_list_ids() { let manager = ThemeManager::with_defaults(); let ids = manager.list_ids(); - assert_eq!(ids.len(), 2); + assert_eq!(ids.len(), 10); assert!(ids.contains(&"dark")); assert!(ids.contains(&"light")); + assert!(ids.contains(&"catppuccin-latte")); + assert!(ids.contains(&"github-light")); + assert!(ids.contains(&"solarized-light")); + assert!(ids.contains(&"catppuccin-mocha")); + assert!(ids.contains(&"tokyo-night")); } #[test] fn test_len_is_empty() { let manager = ThemeManager::with_defaults(); assert!(!manager.is_empty()); - assert_eq!(manager.len(), 2); + assert_eq!(manager.len(), 10); } #[test] @@ -511,7 +539,7 @@ mod tests { custom.id = "custom".to_string(); custom.name = "Custom Theme".to_string(); manager.add_theme(custom); - assert_eq!(manager.len(), 3); + assert_eq!(manager.len(), 11); assert!(manager.get("custom").is_some()); } @@ -539,6 +567,13 @@ mod tests { assert!(manager.get("light").is_some()); } + #[test] + fn test_cannot_remove_builtin_tokyo_night() { + let mut manager = ThemeManager::with_defaults(); + assert!(!manager.remove_theme("tokyo-night")); + assert!(manager.get("tokyo-night").is_some()); + } + #[test] fn test_remove_active_theme_fallback() { let mut manager = ThemeManager::with_defaults(); @@ -576,16 +611,16 @@ mod tests { fn test_dark_themes() { let manager = ThemeManager::with_defaults(); let dark_themes = manager.dark_themes(); - assert_eq!(dark_themes.len(), 1); - assert!(dark_themes[0].is_dark); + assert_eq!(dark_themes.len(), 6); + assert!(dark_themes.iter().all(|theme| theme.is_dark)); } #[test] fn test_light_themes() { let manager = ThemeManager::with_defaults(); let light_themes = manager.light_themes(); - assert_eq!(light_themes.len(), 1); - assert!(!light_themes[0].is_dark); + assert_eq!(light_themes.len(), 4); + assert!(light_themes.iter().all(|theme| !theme.is_dark)); } #[test] @@ -598,7 +633,7 @@ mod tests { let loaded = manager.load_theme_json(&json).unwrap(); assert_eq!(loaded.id, "json_test"); - assert_eq!(manager.len(), 3); + assert_eq!(manager.len(), 11); } #[test] @@ -657,7 +692,7 @@ mod tests { let mut manager = ThemeManager::with_defaults(); let loaded = manager.load_custom_themes(dir.path()).unwrap(); assert_eq!(loaded, 0); - assert_eq!(manager.len(), 2); // Only built-in themes + assert_eq!(manager.len(), 10); // Only built-in themes } #[test] @@ -674,7 +709,7 @@ mod tests { let manager = ThemeManager::with_user_themes(dir.path()); - assert_eq!(manager.len(), 3); + assert_eq!(manager.len(), 11); assert!(manager.get("aurora").is_some()); } diff --git a/crates/codirigent-ui/src/top_bar.rs b/crates/codirigent-ui/src/top_bar.rs index bf43894..fb8d0a1 100644 --- a/crates/codirigent-ui/src/top_bar.rs +++ b/crates/codirigent-ui/src/top_bar.rs @@ -142,16 +142,22 @@ impl TopBar { if let Some(id) = matching_id { self.profile_manager.set_active(&id); } else { - // No matching profile — deactivate all - // Use an impossible ID so nothing matches - self.profile_manager.set_active("__none__"); + self.profile_manager.clear_active(); } self.refresh_tabs(); } /// Set the active profile by its manager ID and refresh tabs. pub fn set_active_profile_id(&mut self, id: &str) { - self.profile_manager.set_active(id); + if !self.profile_manager.set_active(id) { + self.profile_manager.clear_active(); + } + self.refresh_tabs(); + } + + /// Clear the active profile selection and refresh tabs. + pub fn clear_active_profile(&mut self) { + self.profile_manager.clear_active(); self.refresh_tabs(); } @@ -381,6 +387,13 @@ mod tests { assert_eq!(bar.tabs().len(), 3); } + #[test] + fn invalid_active_profile_id_clears_active_tab() { + let mut bar = TopBar::new(); + bar.set_active_profile_id("does-not-exist"); + assert!(bar.tabs().iter().all(|tab| !tab.is_active)); + } + #[test] fn cannot_remove_default_tab() { let mut bar = TopBar::new(); diff --git a/crates/codirigent-ui/src/workspace/editor_detection.rs b/crates/codirigent-ui/src/workspace/editor_detection.rs index ae5d43a..a860b01 100644 --- a/crates/codirigent-ui/src/workspace/editor_detection.rs +++ b/crates/codirigent-ui/src/workspace/editor_detection.rs @@ -156,10 +156,20 @@ pub(super) fn detect_installed_editors() -> Vec { .collect() } +/// Symbol and dingbat font families that pass monospace width checks but +/// render pictographs instead of text glyphs. +fn is_symbol_font(name: &str) -> bool { + let lower = name.to_lowercase(); + lower.contains("wingding") + || lower.contains("webding") + || lower.contains("dingbat") + || lower.contains("emoji") +} + /// Detect installed monospace fonts by querying the GPUI text system. /// /// Enumerates all system fonts and filters for monospace by comparing -/// the advance width of 'm' vs 'i' ??in a monospace font these are equal. +/// the advance width of 'm' vs 'i' — in a monospace font these are equal. #[cfg(target_os = "windows")] pub(super) fn detect_monospace_fonts(text_system: &gpui::TextSystem) -> Vec { let all_names: Vec = text_system @@ -196,7 +206,7 @@ pub(super) fn detect_monospace_fonts(text_system: &gpui::TextSystem) -> Vec Vec bool { + pub(super) fn keystroke_is_text_input(event: &KeyDownEvent) -> bool { if event.keystroke.modifiers.control || event.keystroke.modifiers.alt || event.keystroke.modifiers.platform @@ -484,10 +484,7 @@ impl WorkspaceView { env!("CARGO_PKG_VERSION"), event_bus.clone(), ) { - Ok(svc) => { - svc.start_background_check(); - Some(Arc::new(svc)) - } + Ok(svc) => Some(Arc::new(svc)), Err(e) => { tracing::warn!("Failed to initialize update service: {}", e); None @@ -497,6 +494,10 @@ impl WorkspaceView { // Subscribe to EventBus for update events let update_event_rx = Some(event_bus.subscribe()); + if let Some(svc) = &update_service { + svc.start_background_check(); + } + let (storage, task_manager) = Self::init_task_manager(event_bus.clone()); let (file_tree, file_tree_model, project_root) = Self::init_file_tree(); @@ -949,6 +950,28 @@ impl WorkspaceView { self.settings.open, "handle_settings_key called with settings closed" ); + if self + .settings + .page + .as_ref() + .and_then(|page| page.focused_terminal_style_field) + .is_some() + { + let handled = match event.keystroke.key.as_str() { + "escape" | "enter" => { + self.clear_terminal_style_field_focus(); + cx.notify(); + true + } + "backspace" => self.handle_terminal_style_field_backspace(cx), + _ => false, + }; + if handled { + cx.stop_propagation(); + return true; + } + } + // Navigate Keyboard Shortcuts panel with keyboard when not recording. if self .settings @@ -1080,6 +1103,11 @@ impl WorkspaceView { return; } + if self.handle_terminal_search_key_down(event, cx) { + cx.stop_propagation(); + return; + } + // Don't send platform-modifier shortcuts to PTY (handled as GPUI actions). // GPUI's `secondary-` bindings map to Cmd on macOS and Ctrl on // Windows/Linux, so the action system handles all modifier shortcuts correctly. @@ -1521,7 +1549,18 @@ impl EntityInputHandler for WorkspaceView { // Modal text fields are handled via key events; do not leak input to PTY. return; } + if let Some(session_id) = self.focused_search_session_id() { + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.append_search_text(text); + } + self.schedule_terminal_search(session_id, cx); + cx.notify(); + return; + } if self.settings.open { + if self.append_terminal_style_field_text(text, cx) { + return; + } return; } @@ -1556,6 +1595,14 @@ impl EntityInputHandler for WorkspaceView { _window: &mut Window, cx: &mut Context, ) { + if self.focused_search_session_id().is_some() { + self.ime_marked_range = None; + self.ime_preedit_text = None; + if !text.is_empty() { + cx.notify(); + } + return; + } if self.has_blocking_modal() || self.settings.open { let had_ime_overlay = self.ime_marked_range.is_some() || self.ime_preedit_text.is_some(); @@ -1612,6 +1659,7 @@ impl Render for WorkspaceView { // Lazily detect monospace fonts on first render (text system is available here) if self.cache.monospace_fonts.is_none() { self.cache.monospace_fonts = Some(detect_monospace_fonts(window.text_system())); + self.sanitize_terminal_font_family(window, cx); } let rem = REM_BASE * (self.workspace.theme().font_size_base / FONT_SIZE_BASE_DEFAULT); @@ -1730,6 +1778,7 @@ impl Render for WorkspaceView { .on_action(cx.listener(Self::handle_close_pane)) .on_action(cx.listener(Self::handle_paste)) .on_action(cx.listener(Self::handle_copy)) + .on_action(cx.listener(Self::handle_search_terminal)) .on_action(cx.listener(Self::handle_open_settings)) // Handle keyboard input for PTY .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { diff --git a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs index 519533e..f4f4c13 100644 --- a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs +++ b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs @@ -5,13 +5,66 @@ use crate::workspace::types::{ CachedCellDims, TerminalResizeSignature, CELL_BORDER_WIDTH, HEADER_HEIGHT, TERMINAL_CONTENT_PADDING, }; -use codirigent_core::{CodirigentEvent, EventBus, SessionId, SessionManager}; +use codirigent_core::{CodirigentEvent, EventBus, LayoutMode, SessionId, SessionManager}; use gpui::{Context, Window}; use std::hash::{Hash, Hasher}; use std::time::{Duration, Instant}; use tracing::warn; impl WorkspaceView { + fn current_layout_mode_for_shortcuts(&self) -> LayoutMode { + if let Some(split_state) = self.workspace.layout_state().as_split_tree() { + LayoutMode::SplitTree { + root: split_state.tree().clone(), + } + } else { + self.workspace.layout_profile().to_mode() + } + } + + fn sync_active_top_bar_profile_to_workspace(&mut self) { + let layout_mode = self.current_layout_mode_for_shortcuts(); + if let Some(profile_id) = self + .top_bar + .profile_manager + .list_profiles() + .iter() + .find(|profile| profile.layout == layout_mode) + .map(|profile| profile.id.clone()) + { + self.top_bar.set_active_profile_id(&profile_id); + } else { + self.top_bar.clear_active_profile(); + } + } + + fn apply_layout_mode_from_profile(&mut self, layout_mode: LayoutMode) { + match layout_mode { + LayoutMode::Grid { rows, cols } => { + let profile = match (rows, cols) { + (2, 2) => crate::layout::LayoutProfile::Grid2x2, + (4, 1) => crate::layout::LayoutProfile::Stack1x4, + (2, 3) => crate::layout::LayoutProfile::Grid2x3, + (3, 3) => crate::layout::LayoutProfile::Grid3x3, + _ => crate::layout::LayoutProfile::Custom { rows, cols }, + }; + self.workspace.set_layout(profile); + } + LayoutMode::Single => { + self.workspace + .set_layout(crate::layout::LayoutProfile::Single); + } + LayoutMode::SplitTree { root } => { + self.workspace.set_split_tree(root); + } + LayoutMode::Custom { .. } => {} + } + + self.mark_layout_cache_dirty(); + self.sync_layout_derived_state(); + self.sync_active_top_bar_profile_to_workspace(); + } + /// Returns true when a computed target size is a transient collapse that /// should be ignored to avoid 1-column/1-row PTY resizes. fn should_skip_collapsed_resize( @@ -58,11 +111,18 @@ impl WorkspaceView { /// Cycle to next layout. pub fn next_layout(&mut self, cx: &mut Context) { - self.workspace.next_layout(); - self.mark_layout_cache_dirty(); - self.sync_layout_derived_state(); + self.sync_active_top_bar_profile_to_workspace(); + if let Some(layout_mode) = self + .top_bar + .profile_manager + .next_profile() + .map(|profile| profile.layout.clone()) + { + self.apply_layout_mode_from_profile(layout_mode); + } + self.event_bus.publish(CodirigentEvent::LayoutChanged { - mode: self.workspace.layout_profile().to_mode(), + mode: self.current_layout_mode_for_shortcuts(), }); cx.notify(); } diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs index ddfe80e..e2b7e25 100644 --- a/crates/codirigent-ui/src/workspace/grid_render.rs +++ b/crates/codirigent-ui/src/workspace/grid_render.rs @@ -13,7 +13,8 @@ use crate::workspace::types::HEADER_HEIGHT; use codirigent_core::SessionId; use gpui::{ div, px, Context, Focusable, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, ParentElement, ScrollWheelEvent, SharedString, Styled, Window, + MouseMoveEvent, MouseUpEvent, ParentElement, ScrollWheelEvent, SharedString, + StatefulInteractiveElement, Styled, Window, }; use std::rc::Rc; @@ -230,10 +231,20 @@ impl WorkspaceView { Some((entity, fh, is_focused, input_enabled)), window, ); + let scrollbar_overlay = + self.render_terminal_scrollbar(session_id, theme, Rc::clone(&canvas_origin), cx); + let search_overlay = self.render_terminal_search_overlay(session_id, theme, cx); // Clone canvas_origin for each mouse handler closure let origin_for_down = Rc::clone(&canvas_origin); let origin_for_move = Rc::clone(&canvas_origin); + let mut terminal_stack = div().relative().size_full().child(terminal_content); + if let Some(scrollbar_overlay) = scrollbar_overlay { + terminal_stack = terminal_stack.child(scrollbar_overlay); + } + if let Some(search_overlay) = search_overlay { + terminal_stack = terminal_stack.child(search_overlay); + } let mut outer = div() .id(SharedString::from(format!("session-cell-{}", session_id.0))) @@ -289,7 +300,17 @@ impl WorkspaceView { outer.child(header).child( terminal_area + .relative() .overflow_hidden() + .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| { + if !hovered { + return; + } + if let Some(tv) = this.terminals_mut().get_mut(&session_id) { + tv.note_scroll_activity(); + cx.notify(); + } + })) .on_scroll_wheel( cx.listener(move |this, event: &ScrollWheelEvent, _window, cx| { if let Some(tv) = this.terminals_mut().get_mut(&session_id) { @@ -324,11 +345,11 @@ impl WorkspaceView { if this.selection.drag.as_ref().is_some_and(|d| d.active) { return; } - let (ox, oy) = origin_for_down.get(); + let metrics = origin_for_down.get(); let mouse_x: f32 = event.position.x.into(); let mouse_y: f32 = event.position.y.into(); - let rel_x = mouse_x - ox; - let rel_y = mouse_y - oy; + let rel_x = mouse_x - metrics.origin_x; + let rel_y = mouse_y - metrics.origin_y; if let Some(tv) = this.terminals_mut().get_mut(&session_id) { let cell_pos: Option<(usize, usize)> = tv.pixel_to_cell(rel_x, rel_y); @@ -355,11 +376,11 @@ impl WorkspaceView { return; } - let (ox, oy) = origin_for_move.get(); + let metrics = origin_for_move.get(); let mouse_x: f32 = event.position.x.into(); let mouse_y: f32 = event.position.y.into(); - let rel_x = mouse_x - ox; - let rel_y = mouse_y - oy; + let rel_x = mouse_x - metrics.origin_x; + let rel_y = mouse_y - metrics.origin_y; if let Some(tv) = this.terminals_mut().get_mut(&session_id) { let (row, col, scroll_dir) = tv.pixel_to_cell_clamped(rel_x, rel_y); @@ -397,7 +418,7 @@ impl WorkspaceView { } }), ) - .child(terminal_content), + .child(terminal_stack), ) } } diff --git a/crates/codirigent-ui/src/workspace/impl_action_handlers.rs b/crates/codirigent-ui/src/workspace/impl_action_handlers.rs index 1f933b0..9051056 100644 --- a/crates/codirigent-ui/src/workspace/impl_action_handlers.rs +++ b/crates/codirigent-ui/src/workspace/impl_action_handlers.rs @@ -12,7 +12,7 @@ use gpui::{Context, Window}; use tracing::info; impl WorkspaceView { - /// Handle NewSession action (Cmd+N). + /// Handle NewSession action. pub(super) fn handle_new_session( &mut self, _action: &NewSession, @@ -23,7 +23,7 @@ impl WorkspaceView { self.create_session(cx); } - /// Handle CloseSession action (Cmd+W). + /// Handle CloseSession action. pub(super) fn handle_close_session( &mut self, _action: &CloseSession, @@ -34,7 +34,7 @@ impl WorkspaceView { self.close_focused_session(cx); } - /// Handle SplitHorizontal action (Cmd+D). + /// Handle SplitHorizontal action. pub(super) fn handle_split_horizontal( &mut self, _action: &SplitHorizontal, @@ -49,7 +49,7 @@ impl WorkspaceView { } } - /// Handle SplitVertical action (Cmd+Shift+D). + /// Handle SplitVertical action. pub(super) fn handle_split_vertical( &mut self, _action: &SplitVertical, @@ -64,7 +64,7 @@ impl WorkspaceView { } } - /// Handle ClosePane action (Cmd+Shift+W). + /// Handle ClosePane action. pub(super) fn handle_close_pane( &mut self, _action: &ClosePane, @@ -89,7 +89,7 @@ impl WorkspaceView { } } - /// Handle NextLayout action (Cmd+\). + /// Handle NextLayout action. pub(super) fn handle_next_layout( &mut self, _action: &NextLayout, @@ -100,7 +100,7 @@ impl WorkspaceView { self.next_layout(cx); } - /// Handle ToggleSidebar action (Cmd+B). + /// Handle ToggleSidebar action. pub(super) fn handle_toggle_sidebar( &mut self, _action: &ToggleSidebar, @@ -111,7 +111,7 @@ impl WorkspaceView { self.toggle_sidebar(cx); } - /// Handle ToggleTaskBoard action (Ctrl+T / Cmd+T). + /// Handle ToggleTaskBoard action. pub(super) fn handle_toggle_task_board( &mut self, _action: &ToggleTaskBoard, @@ -122,7 +122,7 @@ impl WorkspaceView { self.toggle_task_board(cx); } - /// Handle QuickSwitch action (Ctrl+K / Cmd+K). + /// Handle QuickSwitch action. /// /// 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. @@ -135,6 +135,16 @@ impl WorkspaceView { info!("QuickSwitch action triggered (toggling sidebar as placeholder)"); self.toggle_sidebar(cx); } + + /// Handle SearchTerminal action. + pub(super) fn handle_search_terminal( + &mut self, + _action: &SearchTerminal, + _window: &mut Window, + cx: &mut Context, + ) { + self.open_terminal_search(cx); + } } /// Generate FocusSession handler methods for WorkspaceView. diff --git a/crates/codirigent-ui/src/workspace/impl_clipboard.rs b/crates/codirigent-ui/src/workspace/impl_clipboard.rs index cefee11..abf1e1e 100644 --- a/crates/codirigent-ui/src/workspace/impl_clipboard.rs +++ b/crates/codirigent-ui/src/workspace/impl_clipboard.rs @@ -20,6 +20,23 @@ enum PreparedClipboardPaste { } impl WorkspaceView { + fn paste_text_into_terminal_search( + &mut self, + session_id: codirigent_core::SessionId, + text: &str, + cx: &mut Context, + ) { + if text.is_empty() { + return; + } + + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.append_search_text(text); + } + self.schedule_terminal_search(session_id, cx); + cx.notify(); + } + fn apply_prepared_clipboard_paste( &mut self, session_id: codirigent_core::SessionId, @@ -106,6 +123,31 @@ impl WorkspaceView { _window: &mut Window, cx: &mut Context, ) { + if let Some(session_id) = self.focused_search_session_id() { + let clipboard = self.clipboard.smart_clipboard.clone(); + + cx.spawn(async move |this: gpui::WeakEntity, cx| { + let result = cx + .background_executor() + .spawn(async move { clipboard.read_content() }) + .await; + + let _ = this.update(cx, |this, cx| match result { + Ok(ClipboardContent::Text(text)) => { + this.paste_text_into_terminal_search(session_id, &text, cx); + } + Ok(ClipboardContent::Empty) + | Ok(ClipboardContent::Files(_)) + | Ok(ClipboardContent::Image(_)) => {} + Err(e) => { + warn!("Failed to read clipboard for terminal search paste: {}", e); + } + }); + }) + .detach(); + return; + } + let Some(session_id) = self.workspace.focused_session_id() else { return; }; @@ -182,6 +224,21 @@ impl WorkspaceView { _window: &mut Window, cx: &mut Context, ) { + if let Some(session_id) = self.focused_search_session_id() { + let query = self + .terminals + .get(&session_id) + .map(|tv| tv.search_query().to_string()) + .unwrap_or_default(); + if !query.is_empty() { + if let Err(e) = self.clipboard.smart_clipboard.write_text(query) { + warn!("Failed to copy terminal search query to clipboard: {}", e); + } + } + cx.notify(); + return; + } + let Some(session_id) = self.workspace.focused_session_id() else { return; }; diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling.rs b/crates/codirigent-ui/src/workspace/impl_output_polling.rs index c82c352..fb0a292 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling.rs @@ -174,7 +174,7 @@ impl WorkspaceView { self.cleanup_stale_proposals(); self.schedule_background_git_refresh(cx); - if self.update_clipboard_preview(cx) { + if self.update_clipboard_preview(cx) || self.update_terminal_scrollbar_fades() { cx.notify(); } 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 a6eca09..1c26ec0 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 @@ -134,6 +134,7 @@ fn should_apply_hook_signal( fn resolve_hook_cli_session_id( signal_file_id: &str, explicit_cli_session_id: Option<&str>, + codirigent_session_uuid: Option<&str>, session_id: SessionId, allow_filename_backfill: bool, ) -> Option { @@ -158,7 +159,10 @@ fn resolve_hook_cli_session_id( } let fallback = signal_file_id.trim(); - if fallback.is_empty() || fallback == session_id.0.to_string() { + if fallback.is_empty() + || fallback == session_id.0.to_string() + || codirigent_session_uuid.is_some_and(|uuid| uuid == fallback) + { return None; } if !is_safe_cli_session_id(fallback) { @@ -230,6 +234,26 @@ fn parse_legacy_hook_session_id(codirigent_session_id: Option<&str>) -> Option, +) -> Option { + let session_uuid = codirigent_session_uuid?; + let matches: Vec<_> = sessions + .iter() + .filter(|s| s.session_uuid == session_uuid) + .collect(); + match matches.len() { + 1 => Some(matches[0].id), + n if n > 1 => { + warn!(session_uuid, count = n, "Ambiguous session_uuid match"); + None + } + _ => None, + } +} + fn codex_execution_mode_from_approval_and_sandbox( approval_policy: Option<&str>, sandbox_policy_type: Option<&str>, @@ -388,17 +412,17 @@ 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 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::>(); 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(), @@ -408,7 +432,9 @@ impl WorkspaceView { None => return, } } else { - match session_id + // For Codex/Gemini: prefer UUID-based matching, fall back to legacy integer ID. + match resolve_session_by_uuid(&routing_sessions, codirigent_session_uuid.as_deref()) + .or(session_id) .or_else(|| parse_legacy_hook_session_id(codirigent_session_id.as_deref())) { Some(session_id) => session_id, @@ -428,6 +454,7 @@ impl WorkspaceView { let resolved_cli_session_id = resolve_hook_cli_session_id( &signal_file_id, cli_session_id.as_deref(), + codirigent_session_uuid.as_deref(), resolved_session_id, cli_type_name != CLI_TYPE_CLAUDE, ); @@ -776,7 +803,7 @@ 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), true), + resolve_hook_cli_session_id("3", None, None, SessionId(3), true), None ); } @@ -784,7 +811,7 @@ mod tests { #[test] fn non_numeric_signal_file_id_can_backfill_cli_session_id() { assert_eq!( - resolve_hook_cli_session_id("codex-uuid", None, SessionId(3), true), + resolve_hook_cli_session_id("codex-uuid", None, None, SessionId(3), true), Some("codex-uuid".to_string()) ); } @@ -792,7 +819,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), true), + resolve_hook_cli_session_id("3", Some("real-codex-id"), None, SessionId(3), true), Some("real-codex-id".to_string()) ); } @@ -800,11 +827,11 @@ mod tests { #[test] fn unsafe_hook_cli_session_id_is_rejected() { assert_eq!( - resolve_hook_cli_session_id("3", Some("bad;id"), SessionId(3), true), + resolve_hook_cli_session_id("3", Some("bad;id"), None, SessionId(3), true), None ); assert_eq!( - resolve_hook_cli_session_id("bad;id", None, SessionId(3), true), + resolve_hook_cli_session_id("bad;id", None, None, SessionId(3), true), None ); } @@ -812,11 +839,39 @@ mod tests { #[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), + resolve_hook_cli_session_id("claude-session-id", None, None, SessionId(3), false), None ); } + #[test] + fn codex_signal_filename_matching_codirigent_uuid_is_not_backfilled_as_cli_session_id() { + assert_eq!( + resolve_hook_cli_session_id( + "session-uuid-123", + None, + Some("session-uuid-123"), + SessionId(3), + true, + ), + None + ); + } + + #[test] + fn codex_signal_filename_differs_from_codirigent_uuid_can_backfill_cli_session_id() { + assert_eq!( + resolve_hook_cli_session_id( + "codex-cli-456", + None, + Some("session-uuid-123"), + SessionId(3), + true, + ), + Some("codex-cli-456".to_string()) + ); + } + #[test] fn claude_signal_routes_by_cli_session_id_before_session_uuid() { let sessions = vec![ @@ -864,6 +919,42 @@ mod tests { assert_eq!(resolve_claude_target_session(&sessions, None, None), None); } + #[test] + fn resolve_session_by_uuid_matches_unique() { + let sessions = vec![ + claude_session(1, None, "uuid-aaa"), + claude_session(2, None, "uuid-bbb"), + ]; + assert_eq!( + resolve_session_by_uuid(&sessions, Some("uuid-bbb")), + Some(SessionId(2)) + ); + } + + #[test] + fn resolve_session_by_uuid_returns_none_for_unknown() { + let sessions = vec![claude_session(1, None, "uuid-aaa")]; + assert_eq!(resolve_session_by_uuid(&sessions, Some("uuid-zzz")), None); + } + + #[test] + fn resolve_session_by_uuid_rejects_ambiguous() { + let sessions = vec![ + claude_session(1, None, "shared-uuid"), + claude_session(2, None, "shared-uuid"), + ]; + assert_eq!( + resolve_session_by_uuid(&sessions, Some("shared-uuid")), + None + ); + } + + #[test] + fn resolve_session_by_uuid_returns_none_when_no_uuid() { + let sessions = vec![claude_session(1, None, "uuid-aaa")]; + assert_eq!(resolve_session_by_uuid(&sessions, None), None); + } + #[test] fn hook_signal_cli_type_maps_to_codex() { assert_eq!( diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs index 0b59dab..2774392 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs @@ -41,8 +41,21 @@ impl WorkspaceView { .and_then(|mut readers| { let cached = readers.cached_status.get(&session_id)?; if cached.seen_at.elapsed() > cached.ttl { - readers.cached_status.remove(&session_id); - return None; + // Hook-sourced resting states (Idle, ResponseReady) represent + // a stable "waiting for user input" condition that should + // persist until a new hook event arrives. Evicting them + // causes the detector's Working status (from the shell's + // CommandExecuted state) to take over, producing a false + // yellow indicator on long-idle Claude Code sessions. + let is_resting_hook = cached.source == CliStatusSource::Hook + && matches!( + cached.status, + SessionStatus::Idle | SessionStatus::ResponseReady + ); + if !is_resting_hook { + readers.cached_status.remove(&session_id); + return None; + } } let source = match cached.source { CliStatusSource::Hook => HintSource::HookSignal, diff --git a/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs b/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs index 48635b3..43f020b 100644 --- a/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs +++ b/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs @@ -47,6 +47,22 @@ impl WorkspaceView { return; } + if let Some(drag) = self.selection.terminal_scrollbar_drag { + if let Some(terminal_view) = self.terminals.get_mut(&drag.session_id) { + let track_y = pos.y - drag.track_top; + let target = terminal_view.scrollbar_offset_for_pointer( + track_y, + drag.track_height, + terminal_view.scrollbar_drag_offset(), + ); + if target != terminal_view.display_offset() { + terminal_view.scroll_to_offset(target); + cx.notify(); + } + } + return; + } + let Some(drag) = &mut self.selection.drag else { return; }; @@ -66,6 +82,14 @@ impl WorkspaceView { return; } + if let Some(scrollbar_drag) = self.selection.terminal_scrollbar_drag.take() { + if let Some(terminal_view) = self.terminals.get_mut(&scrollbar_drag.session_id) { + terminal_view.stop_scrollbar_drag(); + } + cx.notify(); + return; + } + self.finish_session_drag(cx); } diff --git a/crates/codirigent-ui/src/workspace/impl_settings.rs b/crates/codirigent-ui/src/workspace/impl_settings.rs index 50d43bb..36947fc 100644 --- a/crates/codirigent-ui/src/workspace/impl_settings.rs +++ b/crates/codirigent-ui/src/workspace/impl_settings.rs @@ -3,23 +3,62 @@ 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::settings::{SettingsPage, TerminalStyleField}; +use crate::theme::{CodirigentTheme, Rgba}; +use crate::theme_config::Theme; use crate::theme_manager::ThemeManager; use codirigent_core::config_service::ConfigService; use gpui::{Context, Window}; use std::collections::{HashMap, HashSet}; +use std::fs; use std::path::PathBuf; use std::time::Duration; use tracing::warn; struct LoadedSettingsSnapshot { user_settings: codirigent_core::config::UserSettings, + user_settings_changed: bool, project_config: codirigent_core::config::ProjectConfig, project_dir: Option, theme_manager: ThemeManager, } +const HEX_SHORT_RGB_LEN: usize = 3; +const HEX_SHORT_RGBA_LEN: usize = 4; +const HEX_LONG_RGB_LEN: usize = 6; +const HEX_LONG_RGBA_LEN: usize = 8; +const OPAQUE_ALPHA: u8 = u8::MAX; + +fn rgba_to_hex(color: Rgba) -> String { + 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 + ) + } +} + +fn terminal_style_values_from_theme( + theme: &crate::theme::CodirigentTheme, +) -> codirigent_core::config::TerminalThemeOverrides { + let mut values = codirigent_core::config::TerminalThemeOverrides::default(); + for field in TerminalStyleField::ALL { + *field.get_mut(&mut values) = rgba_to_hex(field.theme_color(theme)); + } + values +} + +fn apply_terminal_style_values_to_theme( + theme: &mut Theme, + values: &codirigent_core::config::TerminalThemeOverrides, +) { + for field in TerminalStyleField::ALL { + field.set_theme_config_value(theme, field.get(values).to_string()); + } +} + fn shell_picker_display_label(shell: &str) -> String { if shell.is_empty() { SHELL_PICKER_AUTO_DETECT_LABEL.to_string() @@ -149,8 +188,8 @@ fn keybindings_to_gpui_list( 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, + NextLayout, OpenSettings, Paste, QuickSwitch, Quit, SearchTerminal, SplitHorizontal, + SplitVertical, ToggleSidebar, ToggleTaskBoard, }; use crate::keybindings::KeybindingManager; @@ -174,6 +213,7 @@ fn keybindings_to_gpui_list( "quit" => gpui::KeyBinding::new(&gpui_str, Quit, None), "paste" => gpui::KeyBinding::new(&gpui_str, Paste, None), "copy" => gpui::KeyBinding::new(&gpui_str, Copy, None), + "search_terminal" => gpui::KeyBinding::new(&gpui_str, SearchTerminal, 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), @@ -224,11 +264,194 @@ fn normalize_keybinding_display(binding: &str) -> String { .unwrap_or_else(|_| binding.to_string()) } +fn migrate_legacy_terminal_conflicting_keybindings( + keybindings: &mut std::collections::HashMap, +) -> bool { + #[cfg(target_os = "macos")] + let m = "Cmd"; + #[cfg(not(target_os = "macos"))] + let m = "Ctrl"; + + let migrations = [ + ("new_session", format!("{m}+N"), format!("{m}+Shift+N")), + ("close_session", format!("{m}+W"), format!("{m}+Alt+W")), + ("toggle_layout", format!("{m}+\\"), format!("{m}+Shift+L")), + ("toggle_sidebar", format!("{m}+B"), format!("{m}+Shift+E")), + ( + "toggle_task_board", + format!("{m}+T"), + format!("{m}+Shift+T"), + ), + ("search_terminal", format!("{m}+F"), format!("{m}+Shift+F")), + ( + "split_horizontal", + format!("{m}+D"), + format!("{m}+Alt+Shift+H"), + ), + ( + "split_vertical", + format!("{m}+Shift+D"), + format!("{m}+Alt+Shift+V"), + ), + ( + "close_pane", + format!("{m}+Shift+W"), + format!("{m}+Alt+Shift+W"), + ), + ("quick_switch", format!("{m}+K"), format!("{m}+Shift+K")), + ]; + + let mut changed = false; + for (action, old_binding, new_binding) in migrations { + let new_display = normalize_keybinding_display(&new_binding); + let Some(current) = keybindings.get_mut(action) else { + continue; + }; + + let normalized_current = normalize_keybinding_display(current); + let matches_old_binding = normalized_current == normalize_keybinding_display(&old_binding) + || normalized_current == old_binding; + if matches_old_binding && *current != new_display { + *current = new_display; + changed = true; + } + } + + changed +} + +fn contains_font_family(fonts: &[String], target: &str) -> bool { + fonts.iter().any(|font| font.eq_ignore_ascii_case(target)) +} + +fn resolve_terminal_font_family(requested: &str, detected_fonts: &[String]) -> String { + if !requested.is_empty() && contains_font_family(detected_fonts, requested) { + return requested.to_string(); + } + + let platform_default = codirigent_core::config::default_terminal_font_family(); + if contains_font_family(detected_fonts, platform_default) { + return platform_default.to_string(); + } + + if let Some(font) = detected_fonts.first() { + return font.clone(); + } + + platform_default.to_string() +} + +pub(super) fn parse_terminal_override_rgba(value: &str) -> Option { + let hex = value.trim(); + if hex.is_empty() { + return None; + } + + let hex = hex.trim_start_matches('#'); + let (r, g, b, a) = match hex.len() { + HEX_SHORT_RGB_LEN => ( + parse_hex_nibble(&hex[0..1])?, + parse_hex_nibble(&hex[1..2])?, + parse_hex_nibble(&hex[2..3])?, + OPAQUE_ALPHA, + ), + HEX_SHORT_RGBA_LEN => ( + parse_hex_nibble(&hex[0..1])?, + parse_hex_nibble(&hex[1..2])?, + parse_hex_nibble(&hex[2..3])?, + parse_hex_nibble(&hex[3..4])?, + ), + HEX_LONG_RGB_LEN => ( + u8::from_str_radix(&hex[0..2], 16).ok()?, + u8::from_str_radix(&hex[2..4], 16).ok()?, + u8::from_str_radix(&hex[4..6], 16).ok()?, + OPAQUE_ALPHA, + ), + HEX_LONG_RGBA_LEN => ( + u8::from_str_radix(&hex[0..2], 16).ok()?, + u8::from_str_radix(&hex[2..4], 16).ok()?, + u8::from_str_radix(&hex[4..6], 16).ok()?, + u8::from_str_radix(&hex[6..8], 16).ok()?, + ), + _ => return None, + }; + + Some(Rgba::new(r, g, b, a)) +} + +fn parse_hex_nibble(value: &str) -> Option { + let nibble = u8::from_str_radix(value, 16).ok()?; + Some((nibble << 4) | nibble) +} + +fn terminal_override_is_valid(value: &str) -> bool { + value.trim().is_empty() || parse_terminal_override_rgba(value).is_some() +} + +fn apply_terminal_theme_overrides( + theme: &mut CodirigentTheme, + overrides: &codirigent_core::config::TerminalThemeOverrides, +) { + if let Some(color) = parse_terminal_override_rgba(&overrides.background) { + theme.terminal_background = color; + } + if let Some(color) = parse_terminal_override_rgba(&overrides.foreground) { + theme.terminal_foreground = color; + } + if let Some(color) = parse_terminal_override_rgba(&overrides.cursor) { + theme.terminal_cursor = color; + theme.cursor = color.to_hsla(); + } + if let Some(color) = parse_terminal_override_rgba(&overrides.selection_background) { + theme.terminal_selection_bg = color; + } + if let Some(color) = parse_terminal_override_rgba(&overrides.selection_foreground) { + theme.terminal_selection_fg = color; + } + + let palette = &overrides.palette; + let palette_overrides = [ + &palette.black, + &palette.red, + &palette.green, + &palette.yellow, + &palette.blue, + &palette.magenta, + &palette.cyan, + &palette.white, + &palette.bright_black, + &palette.bright_red, + &palette.bright_green, + &palette.bright_yellow, + &palette.bright_blue, + &palette.bright_magenta, + &palette.bright_cyan, + &palette.bright_white, + ]; + + for (index, value) in palette_overrides.iter().enumerate() { + if let Some(color) = parse_terminal_override_rgba(value) { + theme.ansi.colors[index] = color; + } + } +} + 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(); + const TERMINAL_SAFE_KEYBINDINGS_MIGRATION_VERSION: u32 = 1; + + let mut user_settings = config_service.load_user_settings().unwrap_or_default(); + let mut user_settings_changed = false; + if user_settings.migrations.terminal_safe_keybindings_version + < TERMINAL_SAFE_KEYBINDINGS_MIGRATION_VERSION + { + let _ = migrate_legacy_terminal_conflicting_keybindings(&mut user_settings.keybindings); + user_settings.migrations.terminal_safe_keybindings_version = + TERMINAL_SAFE_KEYBINDINGS_MIGRATION_VERSION; + user_settings_changed = true; + } let project_config = project_dir .as_ref() .and_then(|dir| config_service.load_project_config(dir).ok()) @@ -237,6 +460,7 @@ fn load_settings_snapshot( LoadedSettingsSnapshot { user_settings, + user_settings_changed, project_config, project_dir, theme_manager, @@ -289,6 +513,7 @@ impl WorkspaceView { if !user_settings.terminal.font_family.is_empty() { theme.terminal_font_family = user_settings.terminal.font_family.clone(); } + apply_terminal_theme_overrides(theme, &user_settings.terminal.theme_overrides); } fn apply_runtime_theme(&mut self, theme: CodirigentTheme) { @@ -299,6 +524,39 @@ impl WorkspaceView { } } + pub(super) fn sanitize_terminal_font_family( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let Some(detected_fonts) = self.cache.monospace_fonts.clone() else { + return; + }; + + let requested_font = self + .settings + .cached_user_settings + .terminal + .font_family + .clone(); + let resolved_font = resolve_terminal_font_family(&requested_font, &detected_fonts); + if requested_font == resolved_font { + return; + } + + self.settings.cached_user_settings.terminal.font_family = resolved_font.clone(); + if let Some(page) = self.settings.page.as_mut() { + page.user_settings.terminal.font_family = resolved_font.clone(); + } + + self.apply_terminal_font_family(window, resolved_font); + self.schedule_user_settings_snapshot_save( + self.settings.cached_user_settings.clone(), + Duration::ZERO, + cx, + ); + } + pub(super) fn resolve_and_apply_theme_id( &mut self, requested_id: &str, @@ -355,6 +613,7 @@ impl WorkspaceView { user_settings.appearance.grid_gap = theme.grid_gap as u32; user_settings.terminal.font_size = theme.terminal_font_size; user_settings.terminal.line_height = theme.terminal_line_height; + let terminal_style_values = terminal_style_values_from_theme(theme); let mut detected = self .cache @@ -380,11 +639,7 @@ impl WorkspaceView { let mut seen_shells = HashSet::new(); detected_shells.retain(|shell| seen_shells.insert(shell.clone())); - let mut detected_fonts = self.cache.monospace_fonts.clone().unwrap_or_default(); - let current_font = &user_settings.terminal.font_family; - if !current_font.is_empty() && !detected_fonts.iter().any(|f| f == current_font) { - detected_fonts.insert(0, current_font.clone()); - } + let detected_fonts = self.cache.monospace_fonts.clone().unwrap_or_default(); SettingsPage::new( user_settings, @@ -392,9 +647,215 @@ impl WorkspaceView { detected, detected_shells, detected_fonts, + terminal_style_values, ) } + pub(super) fn clear_terminal_style_field_focus(&mut self) { + if let Some(page) = self.settings.page.as_mut() { + page.focused_terminal_style_field = None; + } + } + + pub(super) fn set_terminal_style_field_focus(&mut self, field: TerminalStyleField) { + if let Some(page) = self.settings.page.as_mut() { + page.focused_terminal_style_field = Some(field); + page.recording_shortcut = None; + page.focused_shortcut_row = None; + page.open_dropdown = None; + } + } + + pub(super) fn handle_terminal_style_field_backspace(&mut self, cx: &mut Context) -> bool { + let Some((field, next_value)) = self.settings.page.as_ref().and_then(|page| { + let field = page.focused_terminal_style_field?; + let mut next = page.terminal_style_draft_value(field).to_string(); + next.pop(); + Some((field, next)) + }) else { + return false; + }; + + self.update_terminal_style_field_value(field, next_value, cx); + true + } + + pub(super) fn append_terminal_style_field_text( + &mut self, + text: &str, + cx: &mut Context, + ) -> bool { + let Some((field, next_value)) = self.settings.page.as_ref().and_then(|page| { + let field = page.focused_terminal_style_field?; + let mut next = page.terminal_style_draft_value(field).to_string(); + next.push_str(text); + Some((field, next)) + }) else { + return false; + }; + + self.update_terminal_style_field_value(field, next_value, cx); + true + } + + pub(super) fn reset_terminal_style_field( + &mut self, + field: TerminalStyleField, + cx: &mut Context, + ) { + let Some(next_value) = self.settings.page.as_mut().map(|page| { + page.reset_terminal_style_field_to_base(field); + page.terminal_style_draft_value(field).to_string() + }) else { + return; + }; + self.update_terminal_style_field_value(field, next_value, cx); + } + + pub(super) fn update_terminal_style_field_value( + &mut self, + field: TerminalStyleField, + value: String, + cx: &mut Context, + ) { + let mut theme_persist_input = None; + + if let Some(page) = self.settings.page.as_mut() { + page.set_terminal_style_draft_value(field, value.clone()); + if terminal_override_is_valid(&value) { + theme_persist_input = Some(( + page.user_settings.appearance.theme.clone(), + page.terminal_style_draft.clone(), + )); + } + } + + if let Some((active_theme_id, terminal_style_draft)) = theme_persist_input { + if let Some(custom_theme) = + self.build_terminal_style_custom_theme(&active_theme_id, &terminal_style_draft) + { + let custom_theme_id = custom_theme.id.clone(); + self.apply_custom_terminal_style_theme(&custom_theme); + self.schedule_terminal_style_theme_save(custom_theme, cx); + if let Some(page) = self.settings.page.as_mut() { + page.user_settings.appearance.theme = custom_theme_id.clone(); + page.user_settings.terminal.theme_overrides = + codirigent_core::config::TerminalThemeOverrides::default(); + page.user_save_pending = true; + let user_settings = page.user_settings.clone(); + self.settings.cached_user_settings = user_settings.clone(); + self.resolve_and_apply_theme_id(&custom_theme_id, &user_settings); + self.maybe_schedule_settings_save(cx); + } + } + } + + cx.notify(); + } + + fn build_terminal_style_custom_theme( + &self, + active_theme_id: &str, + terminal_style_draft: &codirigent_core::config::TerminalThemeOverrides, + ) -> Option { + let active_theme = self + .settings + .theme_manager + .get(active_theme_id) + .cloned() + .or_else(|| { + self.settings + .theme_manager + .runtime_theme(active_theme_id) + .ok() + .map(|theme| { + Theme::from_runtime(active_theme_id, active_theme_id, true, &theme) + }) + })?; + + let is_custom = !self.settings.theme_manager.is_builtin(active_theme_id); + let custom_id = if is_custom { + active_theme.id.clone() + } else { + format!("{}-custom", active_theme.id) + }; + let custom_name = if is_custom { + active_theme.name.clone() + } else { + format!("{} Custom", active_theme.name) + }; + + let mut custom_theme = active_theme; + custom_theme.id = custom_id.clone(); + custom_theme.name = custom_name; + apply_terminal_style_values_to_theme(&mut custom_theme, terminal_style_draft); + Some(custom_theme) + } + + fn apply_custom_terminal_style_theme(&mut self, custom_theme: &Theme) { + self.settings.theme_manager.add_theme(custom_theme.clone()); + let _ = self.settings.theme_manager.set_active(&custom_theme.id); + self.settings.active_theme_id = custom_theme.id.clone(); + } + + fn schedule_terminal_style_theme_save(&mut self, custom_theme: Theme, cx: &mut Context) { + let Some(config_service) = self.settings.config_service.clone() else { + return; + }; + + self.settings.theme_save_generation = self.settings.theme_save_generation.wrapping_add(1); + let generation = self.settings.theme_save_generation; + self.settings.theme_save_task = None; + self.settings.theme_save_task = + Some(cx.spawn(async move |this: gpui::WeakEntity, cx| { + cx.background_executor() + .timer(Duration::from_millis(250)) + .await; + + let should_run = this + .update(cx, |this, _cx| { + this.settings.theme_save_generation == generation + }) + .ok() + .unwrap_or(false); + if !should_run { + return; + } + + let theme_dir = ThemeManager::custom_theme_dir(config_service.user_config_dir()); + let theme_json = match custom_theme.to_json() { + Ok(theme_json) => theme_json, + Err(e) => { + let _ = this.update(cx, |this, _cx| { + if this.settings.theme_save_generation == generation { + this.settings.theme_save_task = None; + } + warn!("Failed to serialize custom terminal theme: {}", e); + }); + return; + } + }; + let path = theme_dir.join(format!("{}.json", custom_theme.id)); + let result = cx + .background_executor() + .spawn(async move { + fs::create_dir_all(&theme_dir)?; + fs::write(path, theme_json)?; + std::io::Result::Ok(()) + }) + .await; + + let _ = this.update(cx, |this, _cx| { + if this.settings.theme_save_generation == generation { + this.settings.theme_save_task = None; + } + if let Err(e) = result { + warn!("Failed to persist custom terminal theme: {}", e); + } + }); + })); + } + fn schedule_user_settings_snapshot_save( &mut self, user_settings: codirigent_core::config::UserSettings, @@ -605,6 +1066,13 @@ impl WorkspaceView { .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(); + if loaded.user_settings_changed { + this.schedule_user_settings_snapshot_save( + user_settings.clone(), + Duration::ZERO, + cx, + ); + } this.settings.cached_project_config = loaded.project_config.clone(); this.settings.current_working_dir = loaded.project_dir; this.notification_handle @@ -619,6 +1087,9 @@ impl WorkspaceView { 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 focused_terminal_style_field = + existing_page.focused_terminal_style_field; + let terminal_style_draft = existing_page.terminal_style_draft.clone(); let mut page = this.build_settings_page(); page.set_category(active_category); @@ -626,6 +1097,8 @@ impl WorkspaceView { page.dropdown_click_pos = dropdown_click_pos; page.recording_shortcut = recording_shortcut; page.focused_shortcut_row = focused_shortcut_row; + page.focused_terminal_style_field = focused_terminal_style_field; + page.terminal_style_draft = terminal_style_draft; // 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. @@ -738,7 +1211,7 @@ mod tests { #[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()); + map.insert("new_session".to_string(), "Ctrl+Shift+N".to_string()); let list = keybindings_to_gpui_list(&map); assert_eq!(list.len(), 1); } @@ -746,7 +1219,7 @@ mod tests { #[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()); + map.insert("unknown_action_xyz".to_string(), "Ctrl+Shift+N".to_string()); let list = keybindings_to_gpui_list(&map); assert_eq!(list.len(), 0); } @@ -754,7 +1227,7 @@ mod tests { #[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()); + map.insert("toggle_task_board".to_string(), "Ctrl+Shift+T".to_string()); let list = keybindings_to_gpui_list(&map); assert_eq!(list.len(), 1); } @@ -763,7 +1236,7 @@ mod tests { 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()); + map.insert("quick_switch".to_string(), "Ctrl+Shift+K".to_string()); let list = keybindings_to_gpui_list(&map); assert_eq!(list.len(), 1); } @@ -791,9 +1264,12 @@ mod tests { 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()); + map.insert( + "split_horizontal".to_string(), + "Ctrl+Alt+Shift+H".to_string(), + ); + map.insert("split_vertical".to_string(), "Ctrl+Alt+Shift+V".to_string()); + map.insert("close_pane".to_string(), "Ctrl+Alt+Shift+W".to_string()); let list = keybindings_to_gpui_list(&map); assert_eq!(list.len(), 7); } @@ -932,6 +1408,114 @@ mod tests { ); } + #[test] + fn migrate_legacy_terminal_conflicting_keybindings_updates_old_defaults() { + #[cfg(target_os = "macos")] + let toggle_sidebar_old = "Cmd+B"; + #[cfg(not(target_os = "macos"))] + let toggle_sidebar_old = "Ctrl+B"; + + #[cfg(target_os = "macos")] + let search_terminal_old = "Cmd+F"; + #[cfg(not(target_os = "macos"))] + let search_terminal_old = "Ctrl+F"; + + let mut keybindings = std::collections::HashMap::from([ + ( + "toggle_sidebar".to_string(), + normalize_keybinding_display(toggle_sidebar_old), + ), + ( + "search_terminal".to_string(), + normalize_keybinding_display(search_terminal_old), + ), + ]); + + let changed = migrate_legacy_terminal_conflicting_keybindings(&mut keybindings); + + assert!(changed); + #[cfg(target_os = "macos")] + { + assert_eq!( + keybindings.get("toggle_sidebar"), + Some(&"Cmd+Shift+E".to_string()) + ); + assert_eq!( + keybindings.get("search_terminal"), + Some(&"Cmd+Shift+F".to_string()) + ); + } + #[cfg(not(target_os = "macos"))] + { + assert_eq!( + keybindings.get("toggle_sidebar"), + Some(&"Ctrl+Shift+E".to_string()) + ); + assert_eq!( + keybindings.get("search_terminal"), + Some(&"Ctrl+Shift+F".to_string()) + ); + } + } + + #[test] + fn migrate_legacy_terminal_conflicting_keybindings_preserves_custom_bindings() { + let mut keybindings = + std::collections::HashMap::from([("toggle_sidebar".to_string(), "F12".to_string())]); + + let changed = migrate_legacy_terminal_conflicting_keybindings(&mut keybindings); + + assert!(!changed); + assert_eq!(keybindings.get("toggle_sidebar"), Some(&"F12".to_string())); + } + + #[test] + fn migrate_legacy_terminal_conflicting_keybindings_updates_single_backslash_layout_binding() { + #[cfg(target_os = "macos")] + let old_binding = "Cmd+\\".to_string(); + #[cfg(not(target_os = "macos"))] + let old_binding = "Ctrl+\\".to_string(); + + let mut keybindings = + std::collections::HashMap::from([("toggle_layout".to_string(), old_binding)]); + + let changed = migrate_legacy_terminal_conflicting_keybindings(&mut keybindings); + + assert!(changed); + #[cfg(target_os = "macos")] + assert_eq!( + keybindings.get("toggle_layout"), + Some(&"Cmd+Shift+L".to_string()) + ); + #[cfg(not(target_os = "macos"))] + assert_eq!( + keybindings.get("toggle_layout"), + Some(&"Ctrl+Shift+L".to_string()) + ); + } + + #[test] + fn load_settings_snapshot_only_runs_terminal_safe_keybinding_migration_once() { + let temp = tempfile::tempdir().unwrap(); + let config_service = codirigent_core::config_service::DefaultConfigService::with_config_dir( + temp.path().to_path_buf(), + ); + let mut settings = codirigent_core::config::UserSettings::default(); + settings.migrations.terminal_safe_keybindings_version = 1; + settings + .keybindings + .insert("toggle_sidebar".to_string(), "Ctrl+B".to_string()); + config_service.save_user_settings(&settings).unwrap(); + + let snapshot = load_settings_snapshot(&config_service, None); + + assert!(!snapshot.user_settings_changed); + assert_eq!( + snapshot.user_settings.keybindings.get("toggle_sidebar"), + Some(&"Ctrl+B".to_string()) + ); + } + #[test] fn apply_theme_runtime_overrides_preserves_user_preferences() { let mut theme = CodirigentTheme::dark(); @@ -965,6 +1549,78 @@ mod tests { assert_eq!(theme.terminal_font_family, original_font_family); } + #[test] + fn apply_theme_runtime_overrides_applies_terminal_theme_overrides() { + let mut theme = CodirigentTheme::dark(); + let mut user_settings = codirigent_core::config::UserSettings::default(); + user_settings.terminal.theme_overrides.background = "#112233".to_string(); + user_settings.terminal.theme_overrides.cursor = "#abcdef".to_string(); + user_settings.terminal.theme_overrides.palette.bright_blue = "#445566".to_string(); + + WorkspaceView::apply_theme_runtime_overrides(&mut theme, &user_settings); + + assert_eq!(theme.terminal_background, Rgba::rgb(0x11, 0x22, 0x33)); + assert_eq!(theme.terminal_cursor, Rgba::rgb(0xab, 0xcd, 0xef)); + assert_eq!(theme.cursor, Rgba::rgb(0xab, 0xcd, 0xef).to_hsla()); + assert_eq!(theme.ansi.colors[12], Rgba::rgb(0x44, 0x55, 0x66)); + } + + #[test] + fn apply_theme_runtime_overrides_ignores_invalid_terminal_theme_overrides() { + let mut theme = CodirigentTheme::dark(); + let original_background = theme.terminal_background; + let original_palette = theme.ansi.colors[1]; + let mut user_settings = codirigent_core::config::UserSettings::default(); + user_settings.terminal.theme_overrides.background = "#gggggg".to_string(); + user_settings.terminal.theme_overrides.palette.red = "wat".to_string(); + + WorkspaceView::apply_theme_runtime_overrides(&mut theme, &user_settings); + + assert_eq!(theme.terminal_background, original_background); + assert_eq!(theme.ansi.colors[1], original_palette); + } + + #[test] + fn parse_terminal_override_rgba_supports_short_and_alpha_hex() { + assert_eq!( + parse_terminal_override_rgba("#abc"), + Some(Rgba::rgb(0xaa, 0xbb, 0xcc)) + ); + assert_eq!( + parse_terminal_override_rgba("#11223344"), + Some(Rgba::new(0x11, 0x22, 0x33, 0x44)) + ); + assert_eq!(parse_terminal_override_rgba("#12"), None); + } + + #[test] + fn resolve_terminal_font_family_keeps_installed_requested_font() { + let fonts = vec!["Consolas".to_string(), "Cascadia Code".to_string()]; + + let resolved = resolve_terminal_font_family("Cascadia Code", &fonts); + + assert_eq!(resolved, "Cascadia Code"); + } + + #[test] + fn resolve_terminal_font_family_falls_back_to_platform_default() { + let platform_default = codirigent_core::config::default_terminal_font_family().to_string(); + let fonts = vec![platform_default.clone(), "Cascadia Code".to_string()]; + + let resolved = resolve_terminal_font_family("JetBrains Mono", &fonts); + + assert_eq!(resolved, platform_default); + } + + #[test] + fn resolve_terminal_font_family_falls_back_to_first_detected_font() { + let fonts = vec!["Cascadia Code".to_string(), "Fira Code".to_string()]; + + let resolved = resolve_terminal_font_family("JetBrains Mono", &fonts); + + assert_eq!(resolved, "Cascadia Code"); + } + #[test] fn load_settings_snapshot_loads_custom_themes_from_user_config_dir() { let dir = tempdir().unwrap(); @@ -988,6 +1644,9 @@ mod tests { assert_eq!(snapshot.user_settings.appearance.theme, "aurora"); assert!(snapshot.theme_manager.get("aurora").is_some()); - assert_eq!(snapshot.theme_manager.len(), 3); + assert_eq!( + snapshot.theme_manager.len(), + crate::theme_config::builtin_themes().len() + 1 + ); } } diff --git a/crates/codirigent-ui/src/workspace/impl_shortcuts_nav.rs b/crates/codirigent-ui/src/workspace/impl_shortcuts_nav.rs index d10c38c..145b767 100644 --- a/crates/codirigent-ui/src/workspace/impl_shortcuts_nav.rs +++ b/crates/codirigent-ui/src/workspace/impl_shortcuts_nav.rs @@ -39,6 +39,7 @@ mod tests { vec![], vec![], vec![], + codirigent_core::config::TerminalThemeOverrides::default(), ) } diff --git a/crates/codirigent-ui/src/workspace/mod.rs b/crates/codirigent-ui/src/workspace/mod.rs index 727ced3..6b2af00 100644 --- a/crates/codirigent-ui/src/workspace/mod.rs +++ b/crates/codirigent-ui/src/workspace/mod.rs @@ -92,6 +92,12 @@ pub(crate) mod render; #[cfg(feature = "gpui-full")] mod terminal_render; +#[cfg(feature = "gpui-full")] +mod scrollbar_render; + +#[cfg(feature = "gpui-full")] +mod search_render; + #[cfg(feature = "gpui-full")] mod drawer_render; diff --git a/crates/codirigent-ui/src/workspace/scrollbar_render.rs b/crates/codirigent-ui/src/workspace/scrollbar_render.rs new file mode 100644 index 0000000..7a9271d --- /dev/null +++ b/crates/codirigent-ui/src/workspace/scrollbar_render.rs @@ -0,0 +1,164 @@ +use super::gpui::WorkspaceView; +use super::terminal_render::TerminalCanvasMetrics; +use super::types::TERMINAL_CONTENT_PADDING; +use crate::theme::CodirigentTheme; +use codirigent_core::SessionId; +use gpui::{ + div, px, Context, Focusable, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, + ParentElement, SharedString, StatefulInteractiveElement, Styled, +}; +use std::cell::Cell; +use std::rc::Rc; +use std::time::Instant; + +impl WorkspaceView { + pub(super) fn render_terminal_scrollbar( + &mut self, + session_id: SessionId, + theme: &CodirigentTheme, + canvas_metrics: Rc>, + cx: &mut Context, + ) -> Option { + let terminal_view = self.terminals.get(&session_id)?; + let track_height = canvas_metrics.get().content_height; + if track_height <= 0.0 { + return None; + } + + let scrollbar = terminal_view.scrollbar(); + let (thumb_height, thumb_top) = terminal_view.scrollbar_thumb_metrics(track_height); + let dragging = scrollbar.dragging.is_some(); + let track_width = if scrollbar.hovered || dragging { + 12.0 + } else { + 8.0 + }; + let track_opacity = if scrollbar.opacity > 0.0 || scrollbar.hovered || dragging { + scrollbar.opacity.max(0.25) + } else { + 0.0 + }; + let track_bg: gpui::Hsla = theme.border.into(); + let thumb_bg: gpui::Hsla = theme.primary.into(); + let marker_bg: gpui::Hsla = theme.orange.into(); + let marker_fractions = terminal_view.search_marker_fractions(); + + let metrics_for_track = Rc::clone(&canvas_metrics); + let metrics_for_thumb = Rc::clone(&canvas_metrics); + + let mut track = div() + .id(SharedString::from(format!( + "terminal-scrollbar-track-{}", + session_id.0 + ))) + .occlude() + .absolute() + .top(px(TERMINAL_CONTENT_PADDING)) + .bottom(px(TERMINAL_CONTENT_PADDING)) + .right(px(0.0)) + .w(px(track_width)) + .rounded_lg() + .bg(track_bg.opacity(if scrollbar.hovered || dragging { + 0.18 + } else { + 0.1 + })) + .opacity(track_opacity) + .cursor_pointer() + .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| { + if let Some(terminal_view) = this.terminals.get_mut(&session_id) { + terminal_view.set_scrollbar_hovered(*hovered); + cx.notify(); + } + })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + let origin_y = metrics_for_track.get().origin_y; + let pointer_y: f32 = event.position.y.into(); + let relative_y = pointer_y - origin_y; + if let Some(terminal_view) = this.terminals.get_mut(&session_id) { + let target = terminal_view.scrollbar_offset_for_pointer( + relative_y, + track_height, + None, + ); + if target != terminal_view.display_offset() { + terminal_view.scroll_to_offset(target); + } + window.focus(&this.focus_handle(cx)); + this.select_session_with_cx(session_id, cx); + cx.notify(); + } + cx.stop_propagation(); + }), + ); + + for (index, fraction) in marker_fractions.into_iter().enumerate() { + let top = (track_height - 2.0).max(0.0) * fraction; + track = track.child( + div() + .id(gpui::SharedString::from(format!( + "terminal-scrollbar-marker-{session_id}-{index}" + ))) + .absolute() + .top(px(top)) + .left(px(0.0)) + .right(px(0.0)) + .h(px(2.0)) + .bg(marker_bg.opacity(0.85)), + ); + } + + let thumb = div() + .id(SharedString::from(format!( + "terminal-scrollbar-thumb-{}", + session_id.0 + ))) + .occlude() + .absolute() + .top(px(thumb_top)) + .left(px(0.0)) + .right(px(0.0)) + .h(px(thumb_height)) + .rounded_lg() + .bg(thumb_bg.opacity(0.9)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + let origin_y = metrics_for_thumb.get().origin_y; + let pointer_y: f32 = event.position.y.into(); + let relative_y = pointer_y - origin_y; + + if let Some(terminal_view) = this.terminals.get_mut(&session_id) { + let (_, current_thumb_top) = + terminal_view.scrollbar_thumb_metrics(track_height); + let thumb_offset = (relative_y - current_thumb_top).max(0.0); + terminal_view.start_scrollbar_drag(thumb_offset); + this.selection.terminal_scrollbar_drag = + Some(super::types::TerminalScrollbarDragState { + session_id, + track_top: origin_y, + track_height, + }); + this.select_session_with_cx(session_id, cx); + window.focus(&this.focus_handle(cx)); + cx.notify(); + } + + cx.stop_propagation(); + }), + ); + + Some(track.child(thumb).into_any_element()) + } + + pub(super) fn update_terminal_scrollbar_fades(&mut self) -> bool { + let now = Instant::now(); + let mut changed = false; + for terminal_view in self.terminals.values_mut() { + changed |= terminal_view.fade_scrollbar_if_idle(now); + } + changed + } +} diff --git a/crates/codirigent-ui/src/workspace/search_render.rs b/crates/codirigent-ui/src/workspace/search_render.rs new file mode 100644 index 0000000..e6a2ebb --- /dev/null +++ b/crates/codirigent-ui/src/workspace/search_render.rs @@ -0,0 +1,521 @@ +use super::gpui::WorkspaceView; +use crate::terminal_view::SearchFocusControl; +use crate::theme::CodirigentTheme; +use codirigent_core::SessionId; +use gpui::{ + div, px, ClickEvent, Context, Focusable, InteractiveElement, IntoElement, MouseButton, + MouseDownEvent, ParentElement, SharedString, StatefulInteractiveElement, Styled, +}; +use std::time::Duration; + +impl WorkspaceView { + pub(super) fn open_terminal_search(&mut self, cx: &mut Context) { + if self.has_blocking_modal() || self.settings.open { + return; + } + + let Some(session_id) = self.workspace.focused_session_id() else { + return; + }; + + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.open_search(); + if terminal_view.search_query().is_empty() { + terminal_view.clear_search_matches(); + cx.notify(); + return; + } + } + + self.schedule_terminal_search(session_id, cx); + cx.notify(); + } + + pub(super) fn handle_terminal_search_key_down( + &mut self, + event: &gpui::KeyDownEvent, + cx: &mut Context, + ) -> bool { + let Some(session_id) = self.focused_search_session_id() else { + return false; + }; + + let key = event.keystroke.key.to_lowercase(); + let focused_control = self + .terminals + .get(&session_id) + .map(|terminal_view| terminal_view.search_focus_control()) + .unwrap_or(SearchFocusControl::Input); + + match key.as_str() { + "escape" => { + self.close_terminal_search(session_id, cx); + return true; + } + "tab" => { + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.cycle_search_focus_control(event.keystroke.modifiers.shift); + } + cx.notify(); + return true; + } + "left" if focused_control != SearchFocusControl::Input => { + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.cycle_search_focus_control(true); + } + cx.notify(); + return true; + } + "right" if focused_control != SearchFocusControl::Input => { + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.cycle_search_focus_control(false); + } + cx.notify(); + return true; + } + "enter" | "down" => { + self.activate_terminal_search_control( + session_id, + focused_control, + !event.keystroke.modifiers.shift, + cx, + ); + return true; + } + "up" => { + self.activate_terminal_search_control(session_id, focused_control, false, cx); + return true; + } + "space" | " " if focused_control != SearchFocusControl::Input => { + self.activate_terminal_search_control(session_id, focused_control, true, cx); + return true; + } + "backspace" => { + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.set_search_focus_control(SearchFocusControl::Input); + terminal_view.pop_search_char(); + if terminal_view.search_query().is_empty() { + terminal_view.clear_search_matches(); + cx.notify(); + return true; + } + } + self.schedule_terminal_search(session_id, cx); + cx.notify(); + return true; + } + _ => {} + } + + if event.keystroke.modifiers.control + || event.keystroke.modifiers.alt + || event.keystroke.modifiers.platform + || event.keystroke.modifiers.function + { + return true; + } + + if Self::keystroke_is_text_input(event) { + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.set_search_focus_control(SearchFocusControl::Input); + } + cx.notify(); + return false; + } + + true + } + + pub(super) fn render_terminal_search_overlay( + &mut self, + session_id: SessionId, + theme: &CodirigentTheme, + cx: &mut Context, + ) -> Option { + let terminal_view = self.terminals.get(&session_id)?; + let search = terminal_view.search(); + if !search.active { + return None; + } + + 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 accent: gpui::Hsla = theme.primary.into(); + let input_bg: gpui::Hsla = theme.background.into(); + + let total = search.matches.len(); + let current = search.current_match.map(|index| index + 1).unwrap_or(0); + let query = search.query.clone(); + let focused_control = search.focus_control; + + let input_display = if query.is_empty() { + if focused_control == SearchFocusControl::Input { + div() + .flex() + .items_center() + .gap_2() + .child(div().w(px(8.0)).h(px(16.0)).rounded_sm().bg(accent)) + .child(div().text_sm().text_color(muted).child("Type to search")) + .into_any_element() + } else { + div() + .text_sm() + .text_color(muted) + .child("Find in terminal") + .into_any_element() + } + } else if focused_control == SearchFocusControl::Input { + div() + .flex() + .items_center() + .gap_1() + .child(div().text_sm().text_color(fg).child(query.clone())) + .child(div().w(px(8.0)).h(px(16.0)).rounded_sm().bg(accent)) + .into_any_element() + } else { + div() + .text_sm() + .text_color(fg) + .child(query.clone()) + .into_any_element() + }; + + let button = |label: &'static str, + control: SearchFocusControl, + session_id: SessionId, + forward: Option, + cx: &mut Context| { + let hover_bg = accent.opacity(0.12); + let is_focused = focused_control == control; + let mut button = div() + .id(SharedString::from(format!( + "terminal-search-button-{}-{label}", + session_id.0 + ))) + .px_2() + .h(px(28.0)) + .rounded_md() + .border_1() + .border_color(if is_focused { accent } else { border_color }) + .bg(if is_focused { + accent.opacity(0.12) + } else { + gpui::Hsla::transparent_black() + }) + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(fg) + .cursor_pointer() + .hover(move |style| style.bg(hover_bg)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, window, cx| { + this.select_session_with_cx(session_id, cx); + if let Some(terminal_view) = this.terminals.get_mut(&session_id) { + terminal_view.set_search_focus_control(control); + } + window.focus(&this.focus_handle(cx)); + cx.stop_propagation(); + cx.notify(); + }), + ) + .child(label); + + button = match forward { + Some(forward) => { + button.on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { + this.navigate_terminal_search(session_id, forward, cx); + })) + } + None => button.on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { + this.close_terminal_search(session_id, cx); + })), + }; + + button + }; + + Some( + div() + .occlude() + .absolute() + .top(px(10.0)) + .right(px(14.0)) + .w(px(300.0)) + .bg(panel_bg) + .border_1() + .border_color(border_color) + .rounded_lg() + .px_2() + .py_2() + .shadow_md() + .flex() + .items_center() + .gap_2() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, window, cx| { + this.select_session_with_cx(session_id, cx); + window.focus(&this.focus_handle(cx)); + cx.stop_propagation(); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, _: &MouseDownEvent, window, cx| { + this.select_session_with_cx(session_id, cx); + window.focus(&this.focus_handle(cx)); + cx.stop_propagation(); + }), + ) + .child( + div() + .id(SharedString::from(format!( + "terminal-search-input-{}", + session_id.0 + ))) + .flex_1() + .h(px(32.0)) + .px_2() + .bg(input_bg) + .border_1() + .border_color(if focused_control == SearchFocusControl::Input { + accent + } else { + border_color + }) + .rounded_md() + .flex() + .items_center() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, window, cx| { + this.select_session_with_cx(session_id, cx); + if let Some(terminal_view) = this.terminals.get_mut(&session_id) { + terminal_view + .set_search_focus_control(SearchFocusControl::Input); + } + window.focus(&this.focus_handle(cx)); + cx.stop_propagation(); + cx.notify(); + }), + ) + .child(input_display), + ) + .child( + div() + .text_xs() + .text_color(if total == 0 { muted } else { fg }) + .child(format!("{current} of {total}")), + ) + .child(button( + "Prev", + SearchFocusControl::Previous, + session_id, + Some(false), + cx, + )) + .child(button( + "Next", + SearchFocusControl::Next, + session_id, + Some(true), + cx, + )) + .child(button("X", SearchFocusControl::Close, session_id, None, cx)) + .into_any_element(), + ) + } + + pub(super) fn focused_search_session_id(&self) -> Option { + let session_id = self.workspace.focused_session_id()?; + self.terminals + .get(&session_id) + .and_then(|terminal_view| terminal_view.search().active.then_some(session_id)) + } + + pub(super) fn close_terminal_search(&mut self, session_id: SessionId, cx: &mut Context) { + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.close_search(); + cx.notify(); + } + } + + fn activate_terminal_search_control( + &mut self, + session_id: SessionId, + control: SearchFocusControl, + forward: bool, + cx: &mut Context, + ) { + match control { + SearchFocusControl::Input => { + let action = self.terminals.get(&session_id).map(|terminal_view| { + if terminal_view.search_query().is_empty() { + SearchInputAction::None + } else if terminal_view.search().matches.is_empty() { + SearchInputAction::RunSearch + } else { + SearchInputAction::Navigate + } + }); + + match action { + Some(SearchInputAction::RunSearch) => { + self.schedule_terminal_search_with_delay(session_id, None, cx); + } + Some(SearchInputAction::Navigate) => { + self.navigate_terminal_search(session_id, forward, cx); + } + Some(SearchInputAction::None) | None => {} + } + } + SearchFocusControl::Previous => self.navigate_terminal_search(session_id, false, cx), + SearchFocusControl::Next => self.navigate_terminal_search(session_id, true, cx), + SearchFocusControl::Close => self.close_terminal_search(session_id, cx), + } + } + + pub(super) fn schedule_terminal_search( + &mut self, + session_id: SessionId, + cx: &mut Context, + ) { + self.schedule_terminal_search_with_delay(session_id, Some(Duration::from_millis(150)), cx); + } + + fn schedule_terminal_search_with_delay( + &mut self, + session_id: SessionId, + delay: Option, + cx: &mut Context, + ) { + let Some(terminal_view) = self.terminals.get(&session_id) else { + return; + }; + + let query = terminal_view.search_query().to_string(); + let search_query = query.clone(); + let generation = terminal_view.search_generation(); + let runtime = terminal_view.runtime_handle(); + + if query.is_empty() { + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.clear_search_matches(); + } + return; + } + + cx.spawn(async move |this: gpui::WeakEntity, cx| { + if let Some(delay) = delay { + cx.background_executor().timer(delay).await; + } + + let search_inputs = match this.update(cx, |this, _cx| { + let terminal_view = this.terminals.get(&session_id)?; + if !terminal_view.search().active + || terminal_view.search_generation() != generation + || terminal_view.search_query() != query + { + return None; + } + + Some((runtime.clone(), search_query.clone())) + }) { + Ok(Some(search_inputs)) => search_inputs, + Ok(None) | Err(_) => return, + }; + let (runtime, search_query) = search_inputs; + + let matches = cx + .background_executor() + .spawn(async move { runtime.search(&search_query) }) + .await; + + let _ = this.update(cx, |this, cx| { + let Some(terminal_view) = this.terminals.get_mut(&session_id) else { + return; + }; + if !terminal_view.search().active + || terminal_view.search_generation() != generation + || terminal_view.search_query() != query + { + return; + } + + terminal_view.set_search_matches(matches); + if !terminal_view.search().matches.is_empty() { + terminal_view.scroll_to_search_match(0); + } + cx.notify(); + }); + }) + .detach(); + } + + pub(super) fn navigate_terminal_search( + &mut self, + session_id: SessionId, + forward: bool, + cx: &mut Context, + ) { + let (total, query, runtime, current) = { + let Some(terminal_view) = self.terminals.get(&session_id) else { + return; + }; + ( + terminal_view.search().matches.len(), + terminal_view.search_query().to_string(), + terminal_view.runtime_handle(), + terminal_view.search().current_match, + ) + }; + if total == 0 { + return; + } + + let base = match current { + Some(index) => index, + None if forward => total - 1, + None => 0, + }; + + for offset in 1..=total { + let index = if forward { + (base + offset) % total + } else { + (base + total - (offset % total)) % total + }; + let Some(search_match) = self + .terminals + .get(&session_id) + .and_then(|terminal_view| terminal_view.search().matches.get(index)) + .cloned() + else { + continue; + }; + if runtime.match_still_matches(&query, &search_match) { + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.scroll_to_search_match(index); + } + cx.notify(); + return; + } + } + + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + terminal_view.set_current_search_match(None); + cx.notify(); + } + } +} + +enum SearchInputAction { + None, + RunSearch, + Navigate, +} diff --git a/crates/codirigent-ui/src/workspace/settings_panels.rs b/crates/codirigent-ui/src/workspace/settings_panels.rs index de4e002..f20adb7 100644 --- a/crates/codirigent-ui/src/workspace/settings_panels.rs +++ b/crates/codirigent-ui/src/workspace/settings_panels.rs @@ -5,16 +5,23 @@ //! running app (theme, terminal cursor, grid gap, etc.) immediately. use gpui::*; +use std::collections::HashSet; use std::sync::Arc; use crate::settings::controls::{setting_row, setting_toggle, settings_section_header}; use crate::settings::SettingsCategory; +use crate::settings::TerminalStyleField; 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; +const TERMINAL_COLOR_PICKER_FALLBACK_SWATCHES: &[&str] = &[ + "#0f172a", "#1e293b", "#334155", "#475569", "#64748b", "#94a3b8", "#cbd5e1", "#f8fafc", + "#7f1d1d", "#b91c1c", "#dc2626", "#f97316", "#f59e0b", "#eab308", "#65a30d", "#16a34a", + "#059669", "#0891b2", "#0284c7", "#2563eb", "#4f46e5", "#7c3aed", "#c026d3", "#db2777", +]; #[derive(Clone)] enum DropdownEntry { @@ -26,30 +33,143 @@ enum DropdownEntry { 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(); + build_theme_picker_sections(theme_manager) + .into_iter() + .flat_map(|section| section.options) + .map(|option| DropdownEntry::Option { + value: option.id, + label: option.label, + }) + .collect() +} + +fn terminal_style_field_label(field: TerminalStyleField) -> &'static str { + match field { + TerminalStyleField::Background => "Background", + TerminalStyleField::Foreground => "Foreground", + TerminalStyleField::Cursor => "Cursor color", + TerminalStyleField::SelectionBackground => "Selection background", + TerminalStyleField::SelectionForeground => "Selection foreground", + TerminalStyleField::Black => "Black", + TerminalStyleField::Red => "Red", + TerminalStyleField::Green => "Green", + TerminalStyleField::Yellow => "Yellow", + TerminalStyleField::Blue => "Blue", + TerminalStyleField::Magenta => "Magenta", + TerminalStyleField::Cyan => "Cyan", + TerminalStyleField::White => "White", + TerminalStyleField::BrightBlack => "Bright black", + TerminalStyleField::BrightRed => "Bright red", + TerminalStyleField::BrightGreen => "Bright green", + TerminalStyleField::BrightYellow => "Bright yellow", + TerminalStyleField::BrightBlue => "Bright blue", + TerminalStyleField::BrightMagenta => "Bright magenta", + TerminalStyleField::BrightCyan => "Bright cyan", + TerminalStyleField::BrightWhite => "Bright white", + } +} - for (section_index, section) in sections.into_iter().enumerate() { - if section_index > 0 { - entries.push(DropdownEntry::Separator); +fn terminal_style_field_description(field: TerminalStyleField) -> &'static str { + match field { + TerminalStyleField::Background => { + "Terminal background color. Hex override (#RGB, #RGBA, #RRGGBB, #RRGGBBAA). Empty = theme." + } + TerminalStyleField::Foreground => { + "Default terminal text color for plain output that does not request a specific ANSI color." + } + TerminalStyleField::Cursor => { + "Terminal cursor color. Empty = theme." + } + TerminalStyleField::SelectionBackground => { + "Background color used when text is selected in the terminal." + } + TerminalStyleField::SelectionForeground => { + "Text color used for selected terminal text." + } + TerminalStyleField::Black => { + "Base black used for dark text, borders, and some dim CLI output." + } + TerminalStyleField::Red => { + "ANSI red. Commonly used for errors, failed commands, and git deletions." + } + TerminalStyleField::Green => { + "ANSI green. Commonly used for success, completed steps, and git additions." + } + TerminalStyleField::Yellow => { + "ANSI yellow. Commonly used for warnings, prompts, and caution states." + } + TerminalStyleField::Blue => { + "ANSI blue. Commonly used for links, paths, headings, and command hints." + } + TerminalStyleField::Magenta => { + "ANSI magenta. Often used for accents, categories, or secondary highlights." } + TerminalStyleField::Cyan => { + "ANSI cyan. Often used for info messages, metadata, or highlighted values." + } + TerminalStyleField::White => { + "Base light text used by tools that explicitly request ANSI white." + } + TerminalStyleField::BrightBlack => { + "Bright black, usually used for dim text, comments, and subtle separators." + } + TerminalStyleField::BrightRed => { + "Bright red for stronger error emphasis and urgent highlights." + } + TerminalStyleField::BrightGreen => { + "Bright green for stronger success emphasis and positive status output." + } + TerminalStyleField::BrightYellow => { + "Bright yellow for stronger warnings and attention-grabbing prompts." + } + TerminalStyleField::BrightBlue => { + "Bright blue for stronger links, headings, and highlighted commands." + } + TerminalStyleField::BrightMagenta => { + "Bright magenta for vivid accents and standout labels." + } + TerminalStyleField::BrightCyan => { + "Bright cyan for vivid info text and highlighted values." + } + TerminalStyleField::BrightWhite => { + "Bright white for the brightest text and high-contrast highlights." + } + } +} - 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, - }), - ); +fn rgba_to_hex(color: crate::theme::Rgba) -> String { + 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 + ) + } +} + +fn terminal_color_picker_swatches(theme: &crate::theme::CodirigentTheme) -> Vec { + let mut swatches = Vec::new(); + let mut seen = HashSet::new(); + let mut push = |value: String| { + if seen.insert(value.clone()) { + swatches.push(value); + } + }; + + for field in TerminalStyleField::ALL { + push(rgba_to_hex(field.theme_color(theme))); + } + + for value in TERMINAL_COLOR_PICKER_FALLBACK_SWATCHES { + push((*value).to_string()); } - entries + swatches +} + +fn terminal_style_picker_dropdown_id(field: TerminalStyleField) -> String { + format!("term-style-picker-{}", field.id()) } impl super::gpui::WorkspaceView { @@ -73,10 +193,12 @@ impl super::gpui::WorkspaceView { let bg: Hsla = theme.background.into(); let panel_bg: Hsla = theme.panel_background.into(); let fg: Hsla = theme.foreground.into(); + let muted: Hsla = theme.muted.into(); let primary: Hsla = theme.primary.into(); let border: Hsla = theme.border.into(); let active_cat = page.active_category(); let base_font_size = theme.font_size_base; + let version_label = format!("v{}", env!("CARGO_PKG_VERSION")); // Category sidebar let mut sidebar = div() @@ -140,6 +262,7 @@ impl super::gpui::WorkspaceView { page.set_category(cat); page.open_dropdown = None; page.focused_shortcut_row = None; + page.focused_terminal_style_field = None; } cx.notify(); })) @@ -147,6 +270,16 @@ impl super::gpui::WorkspaceView { ); } + sidebar = sidebar.child(div().flex_1()).child( + div() + .px_3() + .pt_2() + .pb_1() + .text_size(px(base_font_size - 1.0)) + .text_color(muted) + .child(version_label), + ); + let content_child = self.render_settings_content(cx); let content = div() @@ -279,6 +412,7 @@ impl super::gpui::WorkspaceView { let dd_id = dd_id.clone(); move |this, event: &MouseDownEvent, _, cx| { if let Some(page) = this.settings.page.as_mut() { + page.focused_terminal_style_field = None; if page.open_dropdown.as_deref() == Some(&dd_id) { page.open_dropdown = None; } else { @@ -338,6 +472,7 @@ impl super::gpui::WorkspaceView { .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, window, cx| { + this.clear_terminal_style_field_focus(); cb(this, opt_value.clone(), window, cx); if let Some(page) = this.settings.page.as_mut() { page.open_dropdown = None; @@ -396,6 +531,7 @@ impl super::gpui::WorkspaceView { .on_mouse_down( MouseButton::Left, cx.listener(|this, _, _, cx| { + this.clear_terminal_style_field_focus(); if let Some(page) = this.settings.page.as_mut() { page.open_dropdown = None; } @@ -873,12 +1009,33 @@ impl super::gpui::WorkspaceView { let cursor_style = page.user_settings.terminal.cursor_style.clone(); let line_height = page.user_settings.terminal.line_height; let font_options: Vec<&str> = page.detected_fonts.iter().map(|s| s.as_str()).collect(); + let theme_name = theme_picker_display_label( + &self.settings.theme_manager, + &page.user_settings.appearance.theme, + ); let theme = self.workspace.theme(); - - div() + let mut container = div() .flex() .flex_col() .gap_1() + .child(settings_section_header("Theme Editor", theme, true)) + .child( + div() + .px_2() + .pb_2() + .text_sm() + .text_color(theme.muted) + .child(format!( + "Editing terminal colors updates a custom theme derived from {}.", + theme_name + )), + ) + .child(setting_row( + "Preview", + "Live mock terminal preview using the current theme and terminal color edits.", + theme, + self.render_terminal_theme_preview(), + )) .child(settings_section_header("Font", theme, true)) .child(setting_row( "Font family", @@ -953,7 +1110,6 @@ impl super::gpui::WorkspaceView { page.user_settings.terminal.cursor_style = val.clone(); page.user_save_pending = true; } - // Apply cursor style to all terminals let shape: CursorShape = match val.as_str() { "underline" => CursorShape::Underline, "bar" | "beam" => CursorShape::Beam, @@ -1000,7 +1156,138 @@ impl super::gpui::WorkspaceView { }, ), )) - .into_any_element() + .child(settings_section_header("Advanced Colors", theme, false)); + + for field in TerminalStyleField::BASE { + container = container.child(setting_row( + terminal_style_field_label(field), + terminal_style_field_description(field), + theme, + self.render_terminal_style_input_control(field, cx), + )); + } + + container = container.child(settings_section_header( + "Advanced ANSI Palette", + theme, + false, + )); + for field in TerminalStyleField::ANSI { + container = container.child(setting_row( + terminal_style_field_label(field), + terminal_style_field_description(field), + theme, + self.render_terminal_style_input_control(field, cx), + )); + } + + container.into_any_element() + } + + fn render_terminal_theme_preview(&self) -> impl IntoElement { + let theme = self.workspace.theme(); + let fg: Hsla = theme.terminal_foreground.into(); + let bg: Hsla = theme.terminal_background.into(); + let header_bg: Hsla = theme.header_background.into(); + let selection_bg: Hsla = theme.terminal_selection_bg.into(); + let selection_fg: Hsla = theme.terminal_selection_fg.into(); + let cursor: Hsla = theme.terminal_cursor.into(); + let border: Hsla = theme.border.into(); + let muted: Hsla = theme.muted.into(); + let ansi = theme.ansi.colors; + + let ansi_hsla = |index: usize| -> Hsla { ansi[index].into() }; + + div() + .min_w(px(320.0)) + .bg(bg) + .border_1() + .border_color(border) + .rounded_md() + .overflow_hidden() + .child( + div() + .px_3() + .py_2() + .bg(header_bg) + .text_xs() + .text_color(muted) + .child("codirigent preview terminal"), + ) + .child( + div() + .px_3() + .py_3() + .flex() + .flex_col() + .gap_1() + .font_family(theme.terminal_font_family.clone()) + .text_size(px((theme.terminal_font_size - 1.0).max(11.0))) + .child( + div() + .flex() + .flex_row() + .gap_0() + .child("$ ") + .child(div().text_color(ansi_hsla(10)).child("cargo test")) + .child(" ") + .child(div().text_color(cursor).child("▉")), + ) + .child( + div() + .flex() + .flex_row() + .gap_0() + .text_color(ansi_hsla(12)) + .child("Compiling ") + .child(div().text_color(fg).child("codirigent-ui")) + .child(" v0.1.0"), + ) + .child( + div() + .flex() + .flex_row() + .gap_0() + .child(div().text_color(ansi_hsla(9)).child("error")) + .child(div().text_color(fg).child(": preview mismatch on line 42")), + ) + .child( + div() + .flex() + .flex_row() + .gap_0() + .child(div().text_color(ansi_hsla(11)).child("warning")) + .child(div().text_color(fg).child(": using fallback palette")), + ) + .child( + div() + .flex() + .flex_row() + .gap_1() + .text_color(fg) + .child("Selected ") + .child( + div() + .bg(selection_bg) + .text_color(selection_fg) + .px_1() + .child("terminal text"), + ), + ) + .child( + div() + .flex() + .flex_row() + .gap_1() + .children((0..16).map(|index| { + div() + .w(px(10.0)) + .h(px(10.0)) + .rounded_sm() + .bg(ansi_hsla(index)) + })), + ), + ) } // ── Keyboard Shortcuts ─────────────────────────────────────────── @@ -1062,7 +1349,7 @@ impl super::gpui::WorkspaceView { 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() + "Press a shortcut...".to_string() } else { binding.clone() }; @@ -1090,7 +1377,8 @@ impl super::gpui::WorkspaceView { .cursor_pointer() .on_mouse_down( MouseButton::Left, - cx.listener(move |this, _, _, cx| { + cx.listener(move |this, _, window, cx| { + this.clear_terminal_style_field_focus(); if let Some(page) = this.settings.page.as_mut() { if page.recording_shortcut.as_deref() == Some(&action_name) { page.recording_shortcut = None; @@ -1100,6 +1388,8 @@ impl super::gpui::WorkspaceView { page.focused_shortcut_row = Some(action_name.clone()); } } + window.focus(&this.focus_handle(cx)); + cx.stop_propagation(); cx.notify(); }), ) @@ -1440,7 +1730,8 @@ impl super::gpui::WorkspaceView { .hover(|s| s.bg(Hsla { a: 0.1, ..accent })) .on_mouse_down( MouseButton::Left, - cx.listener(|_this, _, _window, cx| { + cx.listener(|this, _, _window, cx| { + this.clear_terminal_style_field_focus(); // Open native directory picker via App (Context derefs to App) let receiver = cx.prompt_for_paths(gpui::PathPromptOptions { files: false, @@ -1470,6 +1761,287 @@ impl super::gpui::WorkspaceView { ) } + fn render_terminal_style_input_control( + &self, + field: TerminalStyleField, + cx: &mut Context, + ) -> impl IntoElement { + let theme = self.workspace.theme(); + let fg: Hsla = theme.foreground.into(); + let muted: Hsla = theme.muted.into(); + let panel_bg: Hsla = theme.panel_background.into(); + let border: Hsla = theme.border.into(); + let accent: Hsla = theme.primary.into(); + let error: Hsla = crate::sidebar::Color::from_hex("#ef4444").into(); + let (value, base_value, is_focused, is_picker_open, click_pos) = self + .settings + .page + .as_ref() + .map(|page| { + let picker_id = terminal_style_picker_dropdown_id(field); + ( + page.terminal_style_draft_value(field).to_string(), + page.terminal_style_base_value(field).to_string(), + page.focused_terminal_style_field == Some(field), + page.open_dropdown.as_deref() == Some(picker_id.as_str()), + page.dropdown_click_pos, + ) + }) + .unwrap_or_else(|| (String::new(), String::new(), false, false, (0.0, 0.0))); + + let has_error = !value.trim().is_empty() + && super::impl_settings::parse_terminal_override_rgba(&value).is_none(); + let preview = super::impl_settings::parse_terminal_override_rgba(&value) + .unwrap_or_else(|| field.theme_color(theme)); + let preview_hsla: Hsla = preview.into(); + let picker_id = terminal_style_picker_dropdown_id(field); + let picker_swatches = terminal_color_picker_swatches(theme); + let display = if value.trim().is_empty() { + "#RRGGBB".to_string() + } else { + value.clone() + }; + + let mut container = div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child( + div() + .id(SharedString::from(format!( + "term-style-{}-swatch", + field.id() + ))) + .w(px(14.0)) + .h(px(14.0)) + .rounded_sm() + .bg(preview_hsla) + .border_1() + .border_color(if is_picker_open { accent } else { border }) + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener({ + let picker_id = picker_id.clone(); + move |this, event: &MouseDownEvent, window, cx| { + this.set_terminal_style_field_focus(field); + if let Some(page) = this.settings.page.as_mut() { + page.open_dropdown = Some(picker_id.clone()); + page.dropdown_click_pos = + (event.position.x / px(1.0), event.position.y / px(1.0)); + } + window.focus(&this.focus_handle(cx)); + cx.stop_propagation(); + cx.notify(); + } + }), + ), + ) + .child( + div() + .id(SharedString::from(format!("term-style-{}", field.id()))) + .min_w(px(190.0)) + .h(px(DROPDOWN_TRIGGER_HEIGHT)) + .px_2() + .bg(panel_bg) + .border_1() + .border_color(if is_focused { + accent + } else if has_error { + error + } else { + border + }) + .rounded_md() + .flex() + .items_center() + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener({ + let picker_id = picker_id.clone(); + move |this, event: &MouseDownEvent, window, cx| { + this.set_terminal_style_field_focus(field); + if let Some(page) = this.settings.page.as_mut() { + page.open_dropdown = Some(picker_id.clone()); + page.dropdown_click_pos = + (event.position.x / px(1.0), event.position.y / px(1.0)); + } + window.focus(&this.focus_handle(cx)); + cx.stop_propagation(); + cx.notify(); + } + }), + ) + .child( + div() + .text_color(if value.trim().is_empty() || has_error { + muted + } else { + fg + }) + .child(display), + ), + ) + .child( + div() + .id(SharedString::from(format!( + "term-style-{}-reset", + field.id() + ))) + .text_color(accent) + .px_2() + .py_1() + .rounded_md() + .cursor_pointer() + .hover(|style| style.bg(Hsla { a: 0.08, ..accent })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + this.set_terminal_style_field_focus(field); + this.reset_terminal_style_field(field, cx); + if let Some(page) = this.settings.page.as_mut() { + page.open_dropdown = None; + } + window.focus(&this.focus_handle(cx)); + cx.stop_propagation(); + }), + ) + .child("Use theme"), + ); + + if is_picker_open { + let mut swatch_grid = div().flex().flex_wrap().gap_1(); + for swatch in picker_swatches { + let swatch_value = swatch.clone(); + let swatch_color = + super::impl_settings::parse_terminal_override_rgba(&swatch_value) + .unwrap_or(preview); + let swatch_hsla: Hsla = swatch_color.into(); + swatch_grid = swatch_grid.child( + div() + .id(SharedString::from(format!( + "term-style-{}-swatch-{}", + field.id(), + swatch_value.replace('#', "") + ))) + .w(px(18.0)) + .h(px(18.0)) + .rounded_sm() + .bg(swatch_hsla) + .border_1() + .border_color(border) + .cursor_pointer() + .hover(|style| style.border_color(accent)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + this.set_terminal_style_field_focus(field); + this.update_terminal_style_field_value( + field, + swatch_value.clone(), + cx, + ); + if let Some(page) = this.settings.page.as_mut() { + page.open_dropdown = None; + } + window.focus(&this.focus_handle(cx)); + cx.stop_propagation(); + }), + ), + ); + } + + let picker_panel = div() + .min_w(px(240.0)) + .bg(panel_bg) + .border_1() + .border_color(border) + .rounded_md() + .shadow_md() + .p_3() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child(terminal_style_field_label(field)), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child("Pick a swatch or type a hex value in the field."), + ) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child( + div() + .w(px(28.0)) + .h(px(28.0)) + .rounded_md() + .bg(preview_hsla) + .border_1() + .border_color(border), + ) + .child( + div() + .flex() + .flex_col() + .child(div().text_color(fg).child(value.clone())) + .child( + div() + .text_xs() + .text_color(muted) + .child(format!("Theme value: {}", base_value)), + ), + ), + ) + .child(swatch_grid); + + let backdrop = div() + .id(SharedString::from(format!("{}-backdrop", picker_id))) + .occlude() + .absolute() + .inset_0() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + if let Some(page) = this.settings.page.as_mut() { + page.open_dropdown = None; + } + cx.notify(); + }), + ); + + let overlay = deferred( + anchored() + .anchor(Corner::TopLeft) + .position(point( + px(click_pos.0), + px(click_pos.1 + DROPDOWN_TRIGGER_HEIGHT), + )) + .snap_to_window_with_margin(px(8.0)) + .child(div().occlude().child(picker_panel)), + ) + .with_priority(1); + + container = container + .child(deferred(backdrop).with_priority(0)) + .child(overlay); + } + + container + } + /// Build an interactive number stepper with - and + buttons. fn number_stepper( &self, @@ -1503,6 +2075,7 @@ impl super::gpui::WorkspaceView { .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, window, cx| { + this.clear_terminal_style_field_focus(); on_dec(this, window, cx); }), ) @@ -1533,6 +2106,7 @@ impl super::gpui::WorkspaceView { .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, window, cx| { + this.clear_terminal_style_field_focus(); on_inc(this, window, cx); }), ) @@ -1554,6 +2128,7 @@ impl super::gpui::WorkspaceView { .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, window, cx| { + this.clear_terminal_style_field_focus(); on_toggle(this, window, cx); }), ) diff --git a/crates/codirigent-ui/src/workspace/settings_state.rs b/crates/codirigent-ui/src/workspace/settings_state.rs index 767a24c..c7b4be0 100644 --- a/crates/codirigent-ui/src/workspace/settings_state.rs +++ b/crates/codirigent-ui/src/workspace/settings_state.rs @@ -18,6 +18,10 @@ pub(super) struct SettingsState { pub(super) load_task: Option>, /// Debounced background save task for settings persistence. pub(super) save_task: Option>, + /// Debounced background save task for custom terminal theme files. + pub(super) theme_save_task: Option>, + /// Monotonic generation for debounced terminal theme persistence. + pub(super) theme_save_generation: u64, /// Cached user settings loaded in the background at startup. pub(super) cached_user_settings: UserSettings, /// Cached project config for the current working directory. @@ -44,6 +48,8 @@ impl SettingsState { config_service: DefaultConfigService::new().ok(), load_task: None, save_task: None, + theme_save_task: None, + theme_save_generation: 0, cached_user_settings: UserSettings::default(), cached_project_config: ProjectConfig::default(), theme_manager, diff --git a/crates/codirigent-ui/src/workspace/settings_theme_picker.rs b/crates/codirigent-ui/src/workspace/settings_theme_picker.rs index 79f39d3..5f7a6df 100644 --- a/crates/codirigent-ui/src/workspace/settings_theme_picker.rs +++ b/crates/codirigent-ui/src/workspace/settings_theme_picker.rs @@ -89,8 +89,8 @@ mod tests { &CodirigentTheme::dark(), )); manager.add_theme(Theme::from_runtime( - "tokyo-night", - "Tokyo Night", + "zenburn", + "Zenburn", true, &CodirigentTheme::dark(), )); @@ -111,7 +111,16 @@ mod tests { .iter() .map(|option| option.label.as_str()) .collect::>(), - vec!["Dark", "Night Owl", "Tokyo Night"] + vec![ + "Catppuccin Mocha", + "Dark", + "Gruvbox Dark", + "Night Owl", + "One Dark", + "Solarized Dark", + "Tokyo Night", + "Zenburn", + ] ); assert_eq!(sections[1].title, LIGHT_THEME_SECTION_TITLE); assert_eq!( @@ -120,7 +129,13 @@ mod tests { .iter() .map(|option| option.label.as_str()) .collect::>(), - vec!["Aurora", "Light"] + vec![ + "Aurora", + "Catppuccin Latte", + "GitHub Light", + "Light", + "Solarized Light", + ] ); } diff --git a/crates/codirigent-ui/src/workspace/terminal_render.rs b/crates/codirigent-ui/src/workspace/terminal_render.rs index f536c73..2870aa7 100644 --- a/crates/codirigent-ui/src/workspace/terminal_render.rs +++ b/crates/codirigent-ui/src/workspace/terminal_render.rs @@ -15,6 +15,13 @@ use gpui::{div, px, Entity, FocusHandle, IntoElement, ParentElement, Styled}; use std::cell::Cell; use std::rc::Rc; +#[derive(Clone, Copy, Default)] +pub(super) struct TerminalCanvasMetrics { + pub(super) origin_x: f32, + pub(super) origin_y: f32, + pub(super) content_height: f32, +} + impl WorkspaceView { pub(super) fn render_terminal_content( &mut self, @@ -22,9 +29,10 @@ impl WorkspaceView { theme: &CodirigentTheme, ime_context: Option<(Entity, FocusHandle, bool, bool)>, window: &mut gpui::Window, - ) -> (gpui::AnyElement, Rc>) { + ) -> (gpui::AnyElement, Rc>) { // Shared cell for canvas origin (updated during prepaint) - let canvas_origin: Rc> = Rc::new(Cell::new((0.0, 0.0))); + let canvas_metrics: Rc> = + Rc::new(Cell::new(TerminalCanvasMetrics::default())); // IME pre-edit text should only be shown in the focused terminal pane. let ime_preedit_text = if matches!(ime_context.as_ref(), Some((_, _, true, true))) { @@ -53,7 +61,7 @@ impl WorkspaceView { .child(icons::terminal()), ) .into_any_element(), - canvas_origin, + canvas_metrics, ); }; @@ -69,6 +77,7 @@ impl WorkspaceView { let cached_rows = terminal_view.render_rows(); let shaped_rows = terminal_view.shaped_rows(window.text_system()); let selection_rects = terminal_view.selection_rects_hsla(); + let search_rects = terminal_view.search_highlight_rects_hsla(); // Both read from cache — pure field access, no terminal state touch. let cursor_rect = terminal_view.cursor_rect(); @@ -122,7 +131,7 @@ impl WorkspaceView { }; // Clone Rc for capture into the canvas prepaint closure - let canvas_origin_for_prepaint = Rc::clone(&canvas_origin); + let canvas_metrics_for_prepaint = Rc::clone(&canvas_metrics); // Capture IME context for paint closure let ime_context_for_paint = ime_context.clone(); @@ -137,9 +146,16 @@ impl WorkspaceView { let padding = super::types::TERMINAL_CONTENT_PADDING; let ox = origin_x + padding; let oy = origin_y + padding; + let content_height: f32 = bounds.size.height.into(); + let content_height = (content_height - (padding * 2.0)).max(0.0); - // Store origin for mouse coordinate translation - canvas_origin_for_prepaint.set((ox, oy)); + // Store origin and actual rendered content height for + // mouse coordinate translation and scrollbar geometry. + canvas_metrics_for_prepaint.set(TerminalCanvasMetrics { + origin_x: ox, + origin_y: oy, + content_height, + }); ( ox, @@ -147,6 +163,7 @@ impl WorkspaceView { cached_rows, shaped_rows, selection_rects, + search_rects, ime_preedit, cursor_data, cell_width, @@ -164,6 +181,7 @@ impl WorkspaceView { cached_rows, shaped_rows, selection_rects, + search_rects, ime_preedit, cursor_data, cell_w, @@ -221,7 +239,25 @@ impl WorkspaceView { window.paint_quad(gpui::fill(rect_bounds, *bg_color)); } - // 3. Paint shaped text runs + // 3. Paint search highlights. + for (rect_row, start_col, end_col, bg_color) in &search_rects { + let rect_x = ox + *start_col as f32 * cell_w; + let rect_y = oy + *rect_row as f32 * cell_h; + let rect_w = (*end_col - *start_col) as f32 * cell_w; + let rect_bounds = gpui::Bounds { + origin: gpui::Point { + x: px(rect_x), + y: px(rect_y), + }, + size: gpui::Size { + width: px(rect_w), + height: px(cell_h), + }, + }; + window.paint_quad(gpui::fill(rect_bounds, *bg_color)); + } + + // 4. Paint shaped text runs for row in &shaped_rows { for (line_row, start_col, shaped_line) in row.iter() { let text_x = ox + *start_col as f32 * cell_w; @@ -234,7 +270,7 @@ impl WorkspaceView { } } - // 4. Paint IME pre-edit text at the cursor position. + // 5. Paint IME pre-edit text at the cursor position. if let Some((preedit_x, preedit_y, preedit_line)) = &ime_preedit { let preedit_origin = gpui::Point { x: px(ox + *preedit_x), @@ -243,7 +279,7 @@ impl WorkspaceView { let _ = preedit_line.paint(preedit_origin, px(cell_h), window, cx); } - // 5. Paint cursor + // 6. Paint cursor if let Some((cursor, cursor_color)) = &cursor_data { let cx_pos = ox + cursor.x; let cy_pos = oy + cursor.y; @@ -323,6 +359,6 @@ impl WorkspaceView { .child(terminal_canvas) .into_any_element(); - (element, canvas_origin) + (element, canvas_metrics) } } diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs index 96a534c..07059b7 100644 --- a/crates/codirigent-ui/src/workspace/types.rs +++ b/crates/codirigent-ui/src/workspace/types.rs @@ -329,6 +329,19 @@ pub(super) struct SelectionState { pub drag: Option, /// Active split-divider resize gesture (None when not resizing). pub split_resize: Option, + /// Active terminal scrollbar drag gesture (None when not dragging). + pub terminal_scrollbar_drag: Option, +} + +/// Active terminal scrollbar drag state. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct TerminalScrollbarDragState { + /// Session owning the active scrollbar drag. + pub session_id: SessionId, + /// Screen-space Y coordinate where the track starts. + pub track_top: f32, + /// Height of the scrollbar track. + pub track_height: f32, } /// State for drag-and-drop session reordering. @@ -449,6 +462,7 @@ impl SelectionState { last_click_position: None, drag: None, split_resize: None, + terminal_scrollbar_drag: None, } } } diff --git a/crates/codirigent-updater/src/service.rs b/crates/codirigent-updater/src/service.rs index ede1b60..8c951a0 100644 --- a/crates/codirigent-updater/src/service.rs +++ b/crates/codirigent-updater/src/service.rs @@ -6,7 +6,7 @@ use crate::checker::{self, UpdateInfo}; use crate::downloader; -use crate::state::{self, StagedUpdateState}; +use crate::state::{self, AvailableUpdateState, StagedUpdateState}; use codirigent_core::{CodirigentEvent, EventBus}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -152,6 +152,7 @@ impl UpdateService { warn!("Failed to remove old staged artifact: {e}"); } } + persistent.available_update = None; persistent.staged_update = None; persistent.last_known_version = Some(version.to_string()); if let Err(e) = state::save_state(&persistent) { @@ -275,6 +276,21 @@ impl UpdateService { } } + let restored_update = restore_persisted_available_update(&version, &mut persistent); + if let Some(info) = restored_update { + *state.lock().unwrap_or_else(|p| p.into_inner()) = + UpdateState::UpdateAvailable(info.clone()); + event_bus.publish(CodirigentEvent::UpdateAvailable { + version: info.version.to_string(), + release_url: info.release_url.clone(), + }); + } + // Always save after restore because stale/invalid cached update data + // may have been cleared even when there is nothing to publish. + if let Err(e) = state::save_state(&persistent) { + warn!("Failed to save restored available update state: {e}"); + } + // 5. Check if enough time has elapsed since the last check. let should_check_now = match persistent.last_check_timestamp { Some(last) => { @@ -401,6 +417,7 @@ impl UpdateService { // Persist staged update for crash recovery. let mut persistent = state::load_state().unwrap_or_default(); + persistent.available_update = None; persistent.staged_update = Some(StagedUpdateState { version: info.version.to_string(), artifact_path, @@ -512,21 +529,40 @@ async fn do_check( ) { info!("Checking for updates..."); *state.lock().unwrap_or_else(|p| p.into_inner()) = UpdateState::Checking; + let checked_at = chrono::Utc::now(); - match checker::check_for_update(version, client).await { + let check_result = checker::check_for_update(version, client).await; + // Reload after the async check so we merge onto the latest on-disk state + // and never clobber staged updates written by another task in the meantime. + // This still assumes a single app instance: we do not take an inter-process + // file lock around load/save, so a separate process could still race here. + let mut persistent = state::load_state().unwrap_or_default(); + + match check_result { Ok(Some(info)) => { info!( new_version = %info.version, "Update available" ); + reconcile_persistent_state_after_check( + &mut persistent, + AvailableUpdatePersistence::Set(&info), + checked_at, + ); + *state.lock().unwrap_or_else(|p| p.into_inner()) = + UpdateState::UpdateAvailable(info.clone()); 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"); + reconcile_persistent_state_after_check( + &mut persistent, + AvailableUpdatePersistence::Clear, + checked_at, + ); *state.lock().unwrap_or_else(|p| p.into_inner()) = UpdateState::Idle; } Err(e) => { @@ -534,18 +570,82 @@ async fn do_check( event_bus.publish(CodirigentEvent::UpdateFailed { error: format!("Update check failed: {e:#}"), }); + reconcile_persistent_state_after_check( + &mut persistent, + AvailableUpdatePersistence::KeepCurrent, + checked_at, + ); *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}"); } } +fn restore_persisted_available_update( + current_version: &semver::Version, + persistent: &mut state::UpdatePersistentState, +) -> Option { + let available = persistent.available_update.clone()?; + let version = match available.version.parse::() { + Ok(version) => version, + Err(e) => { + warn!( + version = %available.version, + "Invalid persisted available update version: {e}" + ); + persistent.available_update = None; + return None; + } + }; + + if version <= *current_version { + persistent.available_update = None; + return None; + } + + Some(UpdateInfo { + version, + release_url: available.release_url, + asset_url: available.asset_url, + checksum_url: available.checksum_url, + }) +} + +fn persist_available_update(persistent: &mut state::UpdatePersistentState, info: &UpdateInfo) { + persistent.available_update = Some(AvailableUpdateState { + version: info.version.to_string(), + release_url: info.release_url.clone(), + asset_url: info.asset_url.clone(), + checksum_url: info.checksum_url.clone(), + }); +} + +enum AvailableUpdatePersistence<'a> { + KeepCurrent, + Clear, + Set(&'a UpdateInfo), +} + +fn reconcile_persistent_state_after_check( + persistent: &mut state::UpdatePersistentState, + available_update: AvailableUpdatePersistence<'_>, + checked_at: chrono::DateTime, +) { + match available_update { + AvailableUpdatePersistence::KeepCurrent => {} + AvailableUpdatePersistence::Clear => { + persistent.available_update = None; + } + AvailableUpdatePersistence::Set(info) => { + persist_available_update(persistent, info); + } + } + persistent.last_check_timestamp = Some(checked_at); +} + #[cfg(test)] mod tests { use super::*; @@ -618,6 +718,177 @@ mod tests { assert!(result.is_err()); } + #[test] + fn restore_persisted_available_update_keeps_newer_version() { + let mut persistent = state::UpdatePersistentState { + available_update: Some(AvailableUpdateState { + version: "0.2.0".to_string(), + release_url: "https://example.com/release".to_string(), + asset_url: "https://example.com/app.dmg".to_string(), + checksum_url: "https://example.com/checksums.txt".to_string(), + }), + ..Default::default() + }; + + let info = restore_persisted_available_update(&"0.1.0".parse().unwrap(), &mut persistent) + .expect("restored update"); + + assert_eq!(info.version, "0.2.0".parse().unwrap()); + assert!(persistent.available_update.is_some()); + } + + #[test] + fn restore_persisted_available_update_clears_invalid_semver() { + let mut persistent = state::UpdatePersistentState { + available_update: Some(AvailableUpdateState { + version: "not-a-semver".to_string(), + release_url: "https://example.com/release".to_string(), + asset_url: "https://example.com/app.dmg".to_string(), + checksum_url: "https://example.com/checksums.txt".to_string(), + }), + ..Default::default() + }; + + let info = restore_persisted_available_update(&"0.1.0".parse().unwrap(), &mut persistent); + + assert!(info.is_none()); + assert!(persistent.available_update.is_none()); + } + + #[test] + fn restore_persisted_available_update_clears_stale_version() { + let mut persistent = state::UpdatePersistentState { + available_update: Some(AvailableUpdateState { + version: "0.1.0".to_string(), + release_url: "https://example.com/release".to_string(), + asset_url: "https://example.com/app.dmg".to_string(), + checksum_url: "https://example.com/checksums.txt".to_string(), + }), + ..Default::default() + }; + + let info = restore_persisted_available_update(&"0.1.0".parse().unwrap(), &mut persistent); + + assert!(info.is_none()); + assert!(persistent.available_update.is_none()); + } + + #[test] + fn persist_available_update_writes_all_fields() { + let mut persistent = state::UpdatePersistentState::default(); + let info = UpdateInfo { + version: "0.2.0".parse().unwrap(), + release_url: "https://example.com/release".to_string(), + asset_url: "https://example.com/app.dmg".to_string(), + checksum_url: "https://example.com/checksums.txt".to_string(), + }; + + persist_available_update(&mut persistent, &info); + + assert_eq!( + persistent.available_update, + Some(AvailableUpdateState { + version: "0.2.0".to_string(), + release_url: "https://example.com/release".to_string(), + asset_url: "https://example.com/app.dmg".to_string(), + checksum_url: "https://example.com/checksums.txt".to_string(), + }) + ); + } + + #[test] + fn reconcile_persistent_state_after_check_clear_removes_available_update() { + let checked_at = chrono::Utc::now(); + let mut persistent = state::UpdatePersistentState { + available_update: Some(AvailableUpdateState { + version: "0.2.0".to_string(), + release_url: "https://example.com/release".to_string(), + asset_url: "https://example.com/app.dmg".to_string(), + checksum_url: "https://example.com/checksums.txt".to_string(), + }), + ..Default::default() + }; + + reconcile_persistent_state_after_check( + &mut persistent, + AvailableUpdatePersistence::Clear, + checked_at, + ); + + assert!(persistent.available_update.is_none()); + assert_eq!(persistent.last_check_timestamp, Some(checked_at)); + } + + #[test] + fn reconcile_persistent_state_after_check_keep_current_preserves_available_update() { + let checked_at = chrono::Utc::now(); + let expected = AvailableUpdateState { + version: "0.2.0".to_string(), + release_url: "https://example.com/release".to_string(), + asset_url: "https://example.com/app.dmg".to_string(), + checksum_url: "https://example.com/checksums.txt".to_string(), + }; + let mut persistent = state::UpdatePersistentState { + available_update: Some(expected.clone()), + ..Default::default() + }; + + reconcile_persistent_state_after_check( + &mut persistent, + AvailableUpdatePersistence::KeepCurrent, + checked_at, + ); + + assert_eq!(persistent.available_update, Some(expected)); + assert_eq!(persistent.last_check_timestamp, Some(checked_at)); + } + + #[test] + fn reconcile_persistent_state_after_check_preserves_staged_update() { + let checked_at = chrono::Utc::now(); + let mut persistent = state::UpdatePersistentState { + staged_update: Some(StagedUpdateState { + version: "0.3.0".to_string(), + artifact_path: PathBuf::from("/tmp/codirigent-0.3.0.dmg"), + release_url: "https://example.com/staged".to_string(), + expected_sha256: "abc123".to_string(), + }), + ..Default::default() + }; + let info = UpdateInfo { + version: "0.2.0".parse().unwrap(), + release_url: "https://example.com/release".to_string(), + asset_url: "https://example.com/app.dmg".to_string(), + checksum_url: "https://example.com/checksums.txt".to_string(), + }; + + reconcile_persistent_state_after_check( + &mut persistent, + AvailableUpdatePersistence::Set(&info), + checked_at, + ); + + assert_eq!( + persistent.staged_update, + Some(StagedUpdateState { + version: "0.3.0".to_string(), + artifact_path: PathBuf::from("/tmp/codirigent-0.3.0.dmg"), + release_url: "https://example.com/staged".to_string(), + expected_sha256: "abc123".to_string(), + }) + ); + assert_eq!(persistent.last_check_timestamp, Some(checked_at)); + assert_eq!( + persistent.available_update, + Some(AvailableUpdateState { + version: "0.2.0".to_string(), + release_url: "https://example.com/release".to_string(), + asset_url: "https://example.com/app.dmg".to_string(), + checksum_url: "https://example.com/checksums.txt".to_string(), + }) + ); + } + #[test] fn initial_state_is_idle() { let bus = Arc::new(TestEventBus::new()); diff --git a/crates/codirigent-updater/src/state.rs b/crates/codirigent-updater/src/state.rs index 4b9c280..1b15537 100644 --- a/crates/codirigent-updater/src/state.rs +++ b/crates/codirigent-updater/src/state.rs @@ -23,10 +23,30 @@ pub struct UpdatePersistentState { /// Timestamp of the most recent successful check against the GitHub API. pub last_check_timestamp: Option>, + /// A discovered update that has not been downloaded yet. + pub available_update: Option, + /// A downloaded update that is ready to apply on next restart. pub staged_update: Option, } +/// Metadata for an available update that should be restored on restart. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AvailableUpdateState { + /// Semantic version string of the available release. + pub version: String, + + /// 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, +} + /// Metadata for a staged (downloaded) update artifact. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -147,6 +167,12 @@ mod tests { 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()), + available_update: Some(AvailableUpdateState { + version: "0.2.0".to_string(), + release_url: "https://github.com/oso95/Codirigent/releases/tag/v0.2.0".to_string(), + asset_url: "https://example.com/codirigent-v0.2.0.dmg".to_string(), + checksum_url: "https://example.com/checksums-sha256.txt".to_string(), + }), staged_update: Some(StagedUpdateState { version: "0.2.0".to_string(), artifact_path: PathBuf::from("/tmp/codirigent-0.2.0.dmg"), @@ -168,6 +194,12 @@ mod tests { let state = UpdatePersistentState { last_known_version: Some("1.0.0".to_string()), last_check_timestamp: Some(Utc::now()), + available_update: Some(AvailableUpdateState { + version: "1.1.0".to_string(), + release_url: "https://example.com/release".to_string(), + asset_url: "https://example.com/app.dmg".to_string(), + checksum_url: "https://example.com/checksums.txt".to_string(), + }), staged_update: None, }; @@ -229,6 +261,7 @@ mod tests { 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.available_update, None); assert_eq!(state.staged_update, None); } diff --git a/docs/local-dmg-build.md b/docs/local-dmg-build.md new file mode 100644 index 0000000..70eac29 --- /dev/null +++ b/docs/local-dmg-build.md @@ -0,0 +1,140 @@ +# Local macOS DMG Build + +This documents the local unsigned DMG build flow for testing on macOS. + +The result is a drag-to-install DMG that contains: + +- `Codirigent.app` +- `Applications -> /Applications` + +The bundled app also includes both binaries the app needs at runtime: + +- `Codirigent.app/Contents/MacOS/codirigent` +- `Codirigent.app/Contents/MacOS/codirigent-hook` + +## Prerequisites + +- macOS +- Rust toolchain +- Xcode command line tools (`hdiutil`, `sips`, `iconutil`) + +## Build + +From the repo root: + +```bash +TARGET="aarch64-apple-darwin" +VERSION="0.1.0" + +cargo build --profile dist --features gpui-full --target "$TARGET" -p codirigent +cargo build --profile dist --target "$TARGET" -p codirigent-hook +``` + +## Package an unsigned DMG + +This uses a repo-local temporary staging directory so it does not leave +`Codirigent.app`, `AppIcon.iconset`, or `dmg-staging` clutter in the repo root. + +If an older Codirigent DMG is still mounted, detach it first: + +```bash +for vol in "/Volumes/Codirigent 1" "/Volumes/Codirigent"; do + if [ -e "$vol" ]; then + hdiutil detach "$vol" -quiet || hdiutil detach "$vol" -force -quiet || true + fi +done +``` + +Then package: + +```bash +set -euo pipefail + +TARGET="aarch64-apple-darwin" +VERSION="0.1.0" +OUT_DMG="dist/codirigent-v${VERSION}-${TARGET}-unsigned.dmg" +TMP_ROOT="dist/.packtmp" +BUNDLE="$TMP_ROOT/Codirigent.app" +ICONSET="$TMP_ROOT/AppIcon.iconset" +STAGING="$TMP_ROOT/dmg-staging" + +rm -rf "$TMP_ROOT" + +mkdir -p "$BUNDLE/Contents/MacOS" "$BUNDLE/Contents/Resources" +cp "target/${TARGET}/dist/codirigent" "$BUNDLE/Contents/MacOS/" +cp "target/${TARGET}/dist/codirigent-hook" "$BUNDLE/Contents/MacOS/" + +cat > "$BUNDLE/Contents/Info.plist" < + + + + CFBundleExecutablecodirigent + CFBundleIconFileAppIcon + CFBundleIdentifiercom.codirigent.app + CFBundleNameCodirigent + CFBundlePackageTypeAPPL + CFBundleShortVersionString${VERSION} + CFBundleVersion${VERSION} + LSMinimumSystemVersion13.0 + NSHighResolutionCapable + + +PLIST + +mkdir -p "$ICONSET" +ICON_SRC="assets/icons/app-icon-preview.png" +sips -z 16 16 "$ICON_SRC" --out "$ICONSET/icon_16x16.png" >/dev/null +sips -z 32 32 "$ICON_SRC" --out "$ICONSET/icon_16x16@2x.png" >/dev/null +sips -z 32 32 "$ICON_SRC" --out "$ICONSET/icon_32x32.png" >/dev/null +sips -z 64 64 "$ICON_SRC" --out "$ICONSET/icon_32x32@2x.png" >/dev/null +sips -z 128 128 "$ICON_SRC" --out "$ICONSET/icon_128x128.png" >/dev/null +sips -z 256 256 "$ICON_SRC" --out "$ICONSET/icon_128x128@2x.png" >/dev/null +sips -z 256 256 "$ICON_SRC" --out "$ICONSET/icon_256x256.png" >/dev/null +sips -z 512 512 "$ICON_SRC" --out "$ICONSET/icon_256x256@2x.png" >/dev/null +sips -z 512 512 "$ICON_SRC" --out "$ICONSET/icon_512x512.png" >/dev/null +sips -z 1024 1024 "$ICON_SRC" --out "$ICONSET/icon_512x512@2x.png" >/dev/null +iconutil -c icns "$ICONSET" -o "$BUNDLE/Contents/Resources/AppIcon.icns" + +mkdir -p "$STAGING" +cp -R "$BUNDLE" "$STAGING/" +ln -s /Applications "$STAGING/Applications" + +rm -f "$OUT_DMG" +hdiutil create -volname "Codirigent" -srcfolder "$STAGING" -ov -format UDZO "$OUT_DMG" + +rm -rf "$TMP_ROOT" + +echo "$OUT_DMG" +``` + +## Verify the DMG + +Mount it once and check the DMG root and app bundle contents: + +```bash +MNT=$(mktemp -d /tmp/codirigent-dmg.XXXXXX) +hdiutil attach -nobrowse -mountpoint "$MNT" dist/codirigent-v0.1.0-aarch64-apple-darwin-unsigned.dmg >/dev/null + +ls -la "$MNT" +ls -la "$MNT/Codirigent.app/Contents/MacOS" + +hdiutil detach "$MNT" -quiet +rmdir "$MNT" +``` + +Expected root contents: + +- `Codirigent.app` +- `Applications -> /Applications` + +Expected app bundle contents: + +- `codirigent` +- `codirigent-hook` + +## Notes + +- This is unsigned and not notarized. +- Gatekeeper may warn on first launch. +- The signed/notarized release flow still lives in [release.yml](/Users/cyw/Desktop/github/Dirigent/.github/workflows/release.yml). diff --git a/docs/specs/2026-03-18-terminal-scrollbar-search-design.md b/docs/specs/2026-03-18-terminal-scrollbar-search-design.md new file mode 100644 index 0000000..b454b24 --- /dev/null +++ b/docs/specs/2026-03-18-terminal-scrollbar-search-design.md @@ -0,0 +1,188 @@ +# Terminal Scrollbar & Search Design + +**Date:** 2026-03-18 +**Status:** Approved + +## Overview + +Add two features to the terminal pane: +1. An interactive scrollbar with drag-to-scroll, click-to-jump, and auto-hide +2. A find-in-terminal overlay (Cmd+F / Ctrl+F) with match highlighting, navigation, and scrollbar match markers + +## Prerequisite APIs + +### Total Scrollback Lines + +The scrollbar and search both need to know the total scrollback size. Alacritty's `Term` provides this via `grid().total_lines() - grid().screen_lines()` (history size) and `topmost_line().0.unsigned_abs()` (max scroll offset). Currently `TerminalSize::total_lines()` in `terminal.rs` returns only the visible row count. + +**Changes required:** +- Add a `history_size: usize` field to `TerminalRenderSnapshot` in `terminal_runtime.rs`, populated from `term.topmost_line().0.unsigned_abs()` during snapshot generation +- Add `total_scrollback_lines() -> usize` to `TerminalView`, reading from the snapshot +- The scrollbar uses this value for thumb sizing and position math + +### Scroll-to-Absolute-Position + +The scrollbar drag and track-click need to set the viewport to an arbitrary position. Only relative scroll APIs exist today (`scroll_up`, `scroll_down`, `scroll_to_bottom`). + +**Approach:** Compute a delta from the current `display_offset` to the target offset using `i32` arithmetic to avoid underflow: `Scroll::Delta((target as i32) - (current_display_offset as i32))`. Alacritty clamps the result to `[0, history_size]`. Add a `scroll_to_offset(target: usize)` method on `TerminalRuntimeHandle` that performs this computation internally. + +### Search Grid Access + +The search engine needs to iterate the `Term` grid cells. The `Term` is owned by `TerminalRuntime` behind `Arc>`. + +**Approach:** Add a `search(query: &str) -> Vec` method on `TerminalRuntimeHandle` that acquires the mutex lock and runs the scan, consistent with how `get_selected_text()` already works. The debounce timer resets on each keystroke so only the final query triggers a scan. If large-scrollback performance becomes an issue, this can be moved to a background task later. + +## Feature 1: Interactive Scrollbar + +### Rendering + +Overlay div on the right edge of the terminal pane, rendered as a sibling of the terminal canvas inside `grid_render.rs`. Positioned absolute, sits on top of terminal content. + +- **Track:** Full-height div, transparent by default, semi-transparent on hover +- **Thumb:** Colored div inside the track + - Height: `max(30px, (visible_rows / total_lines) * track_height)` + - Position: proportional to `display_offset / total_scrollback_lines` +- **Width:** 8px default, expands to 12px on hover + +### Interaction + +- **Drag thumb:** `on_mouse_down` on thumb captures drag start offset. `on_mouse_move` on track converts pixel Y delta to proportional scrollback position. `on_mouse_up` releases. +- **Click track:** Jump to proportional position — `(click_y / track_height) * total_scrollback_lines` +- **Mouse wheel:** Existing handler unchanged. Thumb position updates reactively from `display_offset`. + +### Auto-Hide + +- Default opacity: 0 (hidden) +- Fade in on: mouse enters terminal area, scroll wheel activity, scrollback position changes +- Fade out after: 1.5s of no scroll activity AND mouse not hovering the scrollbar +- While mouse hovers the scrollbar: stay visible, expand width +- Timer: use `cx.spawn()` with `Timer::after(Duration::from_millis(1500))` to schedule fade-out; cancel and restart on any scroll activity or hover. Update opacity via `cx.notify()`. +- The scrollbar track height accounts for `TERMINAL_CONTENT_PADDING` so the thumb range matches the visible content area. + +### State + +```rust +struct ScrollbarState { + /// Current opacity (0.0 = hidden, 1.0 = fully visible). + opacity: f32, + /// Mouse is hovering the scrollbar track or thumb. + hovered: bool, + /// Active drag: stores Y offset from thumb top at drag start. + dragging: Option, + /// Timestamp of last scroll activity (for auto-hide timer). + last_scroll_activity: Instant, +} +``` + +## Feature 2: Terminal Search + +### Search Overlay + +Floating bar at the top-right of the terminal pane, approximately 300px wide. Contains: +- Text input field (focused on open) +- Match count label: "3 of 47" +- Prev/Next buttons (up/down arrow icons, or keyboard Enter/Shift+Enter) +- Close button (X) or Escape to dismiss + +Rendered as an absolute-positioned div inside the terminal pane container. + +### Activation + +- **Open:** Cmd+F (macOS) / Ctrl+F (Windows/Linux) — registers a `SearchTerminal` GPUI action +- **Close:** Escape key or click X — clears highlights and returns focus to terminal +- **Input routing:** While search bar is open, keystrokes go to the search input, not the terminal PTY + +### Search Engine + +Location: `crates/codirigent-ui/src/terminal_search.rs` + +- Scans alacritty `Term` grid from bottom of scrollback to top (most recent content first) +- Iterates cells row by row, concatenating characters into line strings +- Handles wrapped lines as a single logical line +- Case-insensitive matching +- Returns match positions: + +```rust +struct SearchMatch { + /// Absolute grid line coordinate (matches alacritty's Line(i32) convention). + /// Negative = scrollback history, 0 = top of visible screen, positive = below. + /// This is independent of display_offset — the viewport maps absolute lines + /// to screen rows. Scrollbar marker positions are computed from these absolute + /// coordinates relative to the total scrollback range. + grid_line: i32, + /// Start column (inclusive). + start_col: usize, + /// End column (exclusive). + end_col: usize, +} +``` + +- Wrapped lines detected via alacritty's `WRAPLINE` cell flag — consecutive flagged rows are concatenated into a single logical line for matching, with column offsets adjusted accordingly +- Debounce: 150ms, timer resets on each keystroke so only the final query triggers a scan; search runs synchronously under the `TerminalRuntimeHandle` mutex (consistent with `get_selected_text()`) +- **Output during search:** When new terminal output arrives while search is active, matches are kept as-is (stale) until the user modifies the query. Match indices may shift due to new output; if the user navigates to a match whose text no longer matches the query at that position, skip to the next valid match. This avoids re-scanning on every output event. + +### Match Highlighting + +- All matches: colored background rects rendered during terminal paint phase (same layer as selection rects in `terminal_render.rs`) +- Active/current match: brighter highlight color to distinguish from other matches +- Only matches within the current viewport need rects computed — filter by visible row range during render + +### Navigation + +- Enter or Down arrow: jump to next match +- Shift+Enter or Up arrow: jump to previous match +- Jumping scrolls the viewport to center the match on screen +- Match index wraps around (last match → first match) + +### Scrollbar Match Markers + +- While search is active, render small horizontal ticks on the scrollbar track +- Each tick: 2px tall, full scrollbar width, positioned at proportional Y for the match's grid line +- Uses the search highlight color +- Only visible while search overlay is open +- Marker Y position formula: `marker_y_fraction = (history_size + grid_line) / (history_size + screen_lines)` — this maps absolute grid line coordinates to the same proportional space used by the scrollbar thumb + +### Search State + +```rust +struct SearchState { + /// Whether the search overlay is open. + active: bool, + /// Current search query. + query: String, + /// All matches found in the terminal grid. + matches: Vec, + /// Index of the currently focused match (for navigation). + current_match: Option, +} +``` + +## File Organization + +### New Files + +| File | Purpose | +|------|---------| +| `crates/codirigent-ui/src/workspace/scrollbar_render.rs` | Scrollbar rendering helper called from within the session cell render path in `grid_render.rs`; not a standalone workspace component | +| `crates/codirigent-ui/src/workspace/search_render.rs` | Search overlay: text input, match count, prev/next buttons | +| `crates/codirigent-ui/src/terminal_search.rs` | Search engine: grid scanning, match collection, result types | + +### Modified Files + +| File | Change | +|------|--------| +| `terminal_view.rs` | Add `ScrollbarState`, `SearchState` fields; expose `total_scrollback_lines()` | +| `grid_render.rs` | Compose scrollbar and search overlay into terminal pane div; wire Cmd+F | +| `terminal_render.rs` | Render search match highlight rects during paint phase | +| `app.rs` | Define `SearchTerminal` action struct and register keybinding (Cmd+F / Ctrl+F), alongside existing actions like `Copy`, `Paste` | +| `workspace/mod.rs` | Declare new modules | + +### Modified (minimal) + +- `terminal_runtime.rs` — add `history_size` to snapshot, add `scroll_to_offset()` and `search()` methods on handle + +### Unchanged + +- `terminal.rs` — alacritty wrapper stays untouched +- Session management, persistence, layout systems +- Existing mouse scroll and text selection behavior (selection continues to work normally beneath the search overlay; search matches are purely visual and do not interact with the selection system)