Skip to main content

wisp/settings/
mod.rs

1#![doc = include_str!("../docs/settings_module.md")]
2
3pub mod menu;
4pub mod overlay;
5pub(crate) mod picker;
6pub mod types;
7
8use crate::components::provider_login::{ProviderLoginEntry, ProviderLoginStatus, provider_login_summary};
9use crate::components::server_status::server_status_summary;
10use acp_utils::notifications::McpServerStatusEntry;
11use acp_utils::settings::SettingsStore;
12use agent_client_protocol::schema::{AuthMethod, SessionConfigOption};
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14use std::path::{Path, PathBuf};
15use tracing::warn;
16
17#[cfg(test)]
18pub(crate) static WISP_HOME_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
19
20#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct WispSettings {
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub status_line: Option<StatusLineSettings>,
25    #[serde(default, skip_serializing_if = "ThemeSettings::is_empty")]
26    pub theme: ThemeSettings,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub content_padding: Option<u16>,
29}
30
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase", deny_unknown_fields)]
33pub struct ThemeSettings {
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub file: Option<String>,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase", deny_unknown_fields)]
40pub struct StatusLineSettings {
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub separator: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub left: Option<Vec<StatusLineSegmentConfig>>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub right: Option<Vec<StatusLineSegmentConfig>>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum StatusLineSegmentConfig {
51    Cwd { max_width: Option<u16> },
52    GitRef,
53    Agent,
54    Mode,
55    Model { max_width: Option<u16> },
56    Reasoning,
57    Context,
58    ServerHealth,
59    Text { value: String, style: Option<StatusLineStyle> },
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ResolvedStatusLineSettings {
64    pub separator: String,
65    pub left: Vec<StatusLineSegmentConfig>,
66    pub right: Vec<StatusLineSegmentConfig>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub enum StatusLineStyle {
72    Primary,
73    Secondary,
74    Muted,
75    Info,
76    Success,
77    Warning,
78    Error,
79}
80
81impl ThemeSettings {
82    pub fn is_empty(&self) -> bool {
83        self.file.is_none()
84    }
85}
86impl WispSettings {
87    pub fn with_default_status_line(mut self, default: StatusLineSettings) -> Self {
88        let user = self.status_line.unwrap_or_default();
89        self.status_line = Some(StatusLineSettings {
90            separator: user.separator.or(default.separator),
91            left: user.left.or(default.left),
92            right: user.right.or(default.right),
93        });
94        self
95    }
96}
97
98impl StatusLineSettings {
99    pub fn defaults() -> Self {
100        Self {
101            separator: Some(default_separator()),
102            left: Some(default_left_segments()),
103            right: Some(default_right_segments()),
104        }
105    }
106
107    pub fn resolve(self) -> ResolvedStatusLineSettings {
108        ResolvedStatusLineSettings {
109            separator: self.separator.unwrap_or_else(default_separator),
110            left: self.left.unwrap_or_else(default_left_segments),
111            right: self.right.unwrap_or_else(default_right_segments),
112        }
113    }
114
115    pub fn resolved_defaults() -> ResolvedStatusLineSettings {
116        Self::defaults().resolve()
117    }
118}
119
120impl From<StatusLineSegmentName> for StatusLineSegmentConfig {
121    fn from(name: StatusLineSegmentName) -> Self {
122        match name {
123            StatusLineSegmentName::Cwd => Self::Cwd { max_width: None },
124            StatusLineSegmentName::GitRef => Self::GitRef,
125            StatusLineSegmentName::Agent => Self::Agent,
126            StatusLineSegmentName::Mode => Self::Mode,
127            StatusLineSegmentName::Model => Self::Model { max_width: None },
128            StatusLineSegmentName::Reasoning => Self::Reasoning,
129            StatusLineSegmentName::Context => Self::Context,
130            StatusLineSegmentName::ServerHealth => Self::ServerHealth,
131        }
132    }
133}
134
135impl From<StatusLineSegmentConfigObject> for StatusLineSegmentConfig {
136    fn from(object: StatusLineSegmentConfigObject) -> Self {
137        match object {
138            StatusLineSegmentConfigObject::Cwd { max_width } => Self::Cwd { max_width },
139            StatusLineSegmentConfigObject::GitRef => Self::GitRef,
140            StatusLineSegmentConfigObject::Agent => Self::Agent,
141            StatusLineSegmentConfigObject::Mode => Self::Mode,
142            StatusLineSegmentConfigObject::Model { max_width } => Self::Model { max_width },
143            StatusLineSegmentConfigObject::Reasoning => Self::Reasoning,
144            StatusLineSegmentConfigObject::Context => Self::Context,
145            StatusLineSegmentConfigObject::ServerHealth => Self::ServerHealth,
146            StatusLineSegmentConfigObject::Text { value, style } => Self::Text { value, style },
147        }
148    }
149}
150
151impl<'de> Deserialize<'de> for StatusLineSegmentConfig {
152    fn deserialize<T: Deserializer<'de>>(deserializer: T) -> Result<Self, T::Error> {
153        Ok(match StatusLineSegmentConfigWire::deserialize(deserializer)? {
154            StatusLineSegmentConfigWire::Shorthand(name) => name.into(),
155            StatusLineSegmentConfigWire::Object(object) => object.into(),
156        })
157    }
158}
159
160impl Serialize for StatusLineSegmentConfig {
161    fn serialize<T: Serializer>(&self, serializer: T) -> Result<T::Ok, T::Error> {
162        match self {
163            Self::Cwd { max_width: None } => StatusLineSegmentName::Cwd.serialize(serializer),
164            Self::Cwd { max_width } => {
165                Serialize::serialize(&StatusLineSegmentConfigObject::Cwd { max_width: *max_width }, serializer)
166            }
167            Self::GitRef => StatusLineSegmentName::GitRef.serialize(serializer),
168            Self::Agent => StatusLineSegmentName::Agent.serialize(serializer),
169            Self::Mode => StatusLineSegmentName::Mode.serialize(serializer),
170            Self::Model { max_width: None } => StatusLineSegmentName::Model.serialize(serializer),
171            Self::Model { max_width } => {
172                Serialize::serialize(&StatusLineSegmentConfigObject::Model { max_width: *max_width }, serializer)
173            }
174            Self::Reasoning => StatusLineSegmentName::Reasoning.serialize(serializer),
175            Self::Context => StatusLineSegmentName::Context.serialize(serializer),
176            Self::ServerHealth => StatusLineSegmentName::ServerHealth.serialize(serializer),
177            Self::Text { value, style } => Serialize::serialize(
178                &StatusLineSegmentConfigObject::Text { value: value.clone(), style: *style },
179                serializer,
180            ),
181        }
182    }
183}
184
185#[derive(Deserialize)]
186#[serde(untagged)]
187enum StatusLineSegmentConfigWire {
188    Shorthand(StatusLineSegmentName),
189    Object(StatusLineSegmentConfigObject),
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194enum StatusLineSegmentName {
195    Cwd,
196    GitRef,
197    Agent,
198    Mode,
199    Model,
200    Reasoning,
201    Context,
202    ServerHealth,
203}
204
205#[derive(Serialize, Deserialize)]
206#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
207enum StatusLineSegmentConfigObject {
208    Cwd {
209        #[serde(default, skip_serializing_if = "Option::is_none", rename = "maxWidth")]
210        max_width: Option<u16>,
211    },
212    GitRef,
213    Agent,
214    Mode,
215    Model {
216        #[serde(default, skip_serializing_if = "Option::is_none", rename = "maxWidth")]
217        max_width: Option<u16>,
218    },
219    Reasoning,
220    Context,
221    ServerHealth,
222    Text {
223        value: String,
224        #[serde(default, skip_serializing_if = "Option::is_none")]
225        style: Option<StatusLineStyle>,
226    },
227}
228
229fn default_separator() -> String {
230    " · ".to_string()
231}
232
233fn default_left_segments() -> Vec<StatusLineSegmentConfig> {
234    vec![StatusLineSegmentConfig::Cwd { max_width: None }, StatusLineSegmentConfig::GitRef]
235}
236
237fn default_right_segments() -> Vec<StatusLineSegmentConfig> {
238    vec![
239        StatusLineSegmentConfig::Agent,
240        StatusLineSegmentConfig::Mode,
241        StatusLineSegmentConfig::Model { max_width: None },
242        StatusLineSegmentConfig::Reasoning,
243        StatusLineSegmentConfig::Context,
244        StatusLineSegmentConfig::ServerHealth,
245    ]
246}
247
248pub const DEFAULT_CONTENT_PADDING: usize = 2;
249
250pub fn resolve_content_padding(settings: &WispSettings) -> usize {
251    settings.content_padding.map_or(DEFAULT_CONTENT_PADDING, |v| v.max(2) as usize)
252}
253
254pub fn resolve_status_line_settings(settings: &WispSettings) -> ResolvedStatusLineSettings {
255    settings.status_line.clone().unwrap_or_default().resolve()
256}
257
258pub fn wisp_home() -> Option<PathBuf> {
259    Some(SettingsStore::new("WISP_HOME", ".wisp")?.home().to_path_buf())
260}
261
262pub fn themes_dir_path() -> Option<PathBuf> {
263    Some(wisp_home()?.join("themes"))
264}
265
266pub fn load_or_create_settings(default_status_line: StatusLineSettings) -> WispSettings {
267    load_or_create_raw_settings().with_default_status_line(default_status_line)
268}
269
270fn load_or_create_raw_settings() -> WispSettings {
271    if let Some(store) = SettingsStore::new("WISP_HOME", ".wisp") {
272        store.load_or_create()
273    } else {
274        warn!("Unable to resolve Wisp settings path; using defaults");
275        WispSettings::default()
276    }
277}
278
279pub fn load_theme(settings: &WispSettings) -> tui::Theme {
280    let Some(theme_file) = settings.theme.file.as_deref() else {
281        return tui::Theme::default();
282    };
283
284    let Some(path) = resolve_theme_file_path(theme_file) else {
285        warn!("Rejected unsafe theme filename: {}", theme_file);
286        return tui::Theme::default();
287    };
288
289    tui::Theme::load_from_path(&path)
290}
291
292pub fn resolve_theme_file_path(file_name: &str) -> Option<PathBuf> {
293    let trimmed = file_name.trim();
294    if trimmed.is_empty() {
295        return None;
296    }
297
298    let candidate = Path::new(trimmed);
299    let base_name = candidate.file_name()?.to_str()?;
300    if base_name != trimmed {
301        return None;
302    }
303
304    if base_name == "." || base_name == ".." {
305        return None;
306    }
307
308    Some(themes_dir_path()?.join(base_name))
309}
310
311pub fn list_theme_files() -> Vec<String> {
312    let Some(themes_dir) = themes_dir_path() else {
313        return Vec::new();
314    };
315
316    let Ok(entries) = std::fs::read_dir(themes_dir) else {
317        return Vec::new();
318    };
319
320    let mut files = entries
321        .filter_map(Result::ok)
322        .filter_map(|entry| {
323            let Ok(file_type) = entry.file_type() else {
324                return None;
325            };
326
327            if !file_type.is_file() {
328                return None;
329            }
330
331            let name = entry.file_name().into_string().ok()?;
332            if !name.ends_with(".tmTheme") {
333                return None;
334            }
335            Some(name)
336        })
337        .collect::<Vec<_>>();
338
339    files.sort_unstable();
340    files
341}
342
343pub(crate) fn build_login_entries(auth_methods: &[AuthMethod]) -> Vec<ProviderLoginEntry> {
344    auth_methods
345        .iter()
346        .map(|m| {
347            let status = if m.description() == Some("authenticated") {
348                ProviderLoginStatus::LoggedIn
349            } else {
350                ProviderLoginStatus::NeedsLogin
351            };
352            ProviderLoginEntry { method_id: m.id().0.to_string(), name: m.name().to_string(), status }
353        })
354        .collect()
355}
356
357pub(crate) fn create_overlay(
358    config_options: &[SessionConfigOption],
359    server_statuses: &[McpServerStatusEntry],
360    auth_methods: &[AuthMethod],
361) -> overlay::SettingsOverlay {
362    let mut menu = menu::SettingsMenu::from_config_options(config_options);
363    decorate_menu(&mut menu, server_statuses, auth_methods);
364    overlay::SettingsOverlay::new(menu, server_statuses.to_vec(), auth_methods.to_vec())
365        .with_reasoning_effort_from_options(config_options)
366}
367
368pub(crate) fn decorate_menu(
369    menu: &mut menu::SettingsMenu,
370    server_statuses: &[McpServerStatusEntry],
371    auth_methods: &[AuthMethod],
372) {
373    let settings = load_or_create_raw_settings();
374    let theme_files = list_theme_files();
375    menu.add_theme_entry(settings.theme.file.as_deref(), &theme_files);
376
377    refresh_mcp_servers_entry(menu, server_statuses);
378
379    if !auth_methods.is_empty() {
380        let login_entries = build_login_entries(auth_methods);
381        let login_summary = provider_login_summary(&login_entries);
382        menu.add_provider_logins_entry(&login_summary);
383    }
384}
385
386pub(crate) fn refresh_mcp_servers_entry(menu: &mut menu::SettingsMenu, server_statuses: &[McpServerStatusEntry]) {
387    menu.upsert_mcp_servers_entry(&server_status_summary(server_statuses));
388}
389
390pub(crate) fn process_config_changes(changes: Vec<types::SettingsChange>) -> Vec<overlay::SettingsMessage> {
391    use acp_utils::config_option_id::THEME_CONFIG_ID;
392
393    let mut messages = Vec::new();
394    for change in changes {
395        if change.config_id == THEME_CONFIG_ID {
396            let file = theme_file_from_picker_value(&change.new_value);
397            let mut settings = load_or_create_raw_settings();
398            settings.theme.file = file;
399            if let Err(err) = save_settings(&settings) {
400                tracing::warn!("Failed to persist theme setting: {err}");
401            }
402            let theme = load_theme(&settings);
403            messages.push(overlay::SettingsMessage::SetTheme(theme));
404        } else {
405            messages.push(overlay::SettingsMessage::SetConfigOption {
406                config_id: change.config_id,
407                value: change.new_value,
408            });
409        }
410    }
411    messages
412}
413
414fn theme_file_from_picker_value(value: &str) -> Option<String> {
415    let trimmed = value.trim();
416    if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
417}
418
419pub(crate) fn cycle_quick_option(config_options: &[SessionConfigOption]) -> Option<(String, String)> {
420    use crate::components::status_line::is_cycleable_mode_option;
421    use agent_client_protocol::schema::{SessionConfigKind, SessionConfigSelectOptions};
422
423    let option = config_options.iter().find(|option| is_cycleable_mode_option(option))?;
424
425    let SessionConfigKind::Select(ref select) = option.kind else {
426        return None;
427    };
428
429    let SessionConfigSelectOptions::Ungrouped(ref options) = select.options else {
430        return None;
431    };
432
433    if options.is_empty() {
434        return None;
435    }
436
437    let current_index = options.iter().position(|entry| entry.value == select.current_value).unwrap_or(0);
438    let next_index = (current_index + 1) % options.len();
439    options.get(next_index).map(|next| (option.id.0.to_string(), next.value.0.to_string()))
440}
441
442pub(crate) fn cycle_reasoning_option(config_options: &[SessionConfigOption]) -> Option<(String, String)> {
443    use crate::components::status_line::{extract_reasoning_effort, extract_reasoning_levels};
444    use acp_utils::config_option_id::ConfigOptionId;
445    use utils::ReasoningEffort;
446
447    let levels = extract_reasoning_levels(config_options);
448    if levels.is_empty() {
449        return None;
450    }
451
452    let current = extract_reasoning_effort(config_options);
453    let next = ReasoningEffort::cycle_within(current, &levels);
454    Some((ConfigOptionId::ReasoningEffort.as_str().to_string(), ReasoningEffort::config_str(next).to_string()))
455}
456
457pub(crate) fn unhealthy_server_count(statuses: &[McpServerStatusEntry]) -> usize {
458    use acp_utils::notifications::McpServerStatus;
459
460    statuses.iter().filter(|status| !matches!(status.status, McpServerStatus::Connected { .. })).count()
461}
462
463pub fn save_settings(settings: &WispSettings) -> std::io::Result<()> {
464    let store = SettingsStore::new("WISP_HOME", ".wisp")
465        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Unable to resolve Wisp settings path"))?;
466
467    store.save(settings)
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::test_helpers::with_wisp_home;
474    use acp_utils::config_option_id::THEME_CONFIG_ID;
475    use acp_utils::settings::SettingsStore;
476    use std::fs;
477    use tempfile::TempDir;
478
479    fn change(config_id: &str, new_value: &str) -> types::SettingsChange {
480        types::SettingsChange { config_id: config_id.to_string(), new_value: new_value.to_string() }
481    }
482
483    fn with_themes_dir(f: impl FnOnce(&std::path::Path)) {
484        let temp_dir = TempDir::new().unwrap();
485        let themes = temp_dir.path().join("themes");
486        fs::create_dir_all(&themes).unwrap();
487        f(&themes);
488        std::mem::drop(temp_dir);
489    }
490
491    #[test]
492    fn round_trip_serde() {
493        let temp_dir = TempDir::new().unwrap();
494        let store = SettingsStore::from_path(temp_dir.path());
495        let settings =
496            WispSettings { theme: ThemeSettings { file: Some("my-theme.json".to_string()) }, ..Default::default() };
497        store.save(&settings).unwrap();
498        assert_eq!(store.load_or_create::<WispSettings>(), settings);
499    }
500
501    #[test]
502    fn resolve_theme_file_path_allows_basename_only() {
503        for rejected in ["", "../escape.json", "subdir/theme.json"] {
504            assert!(resolve_theme_file_path(rejected).is_none(), "should reject {rejected:?}");
505        }
506        #[cfg(windows)]
507        assert!(resolve_theme_file_path("..\\escape.json").is_none());
508    }
509
510    #[test]
511    fn list_theme_files_returns_sorted_and_filters_correctly() {
512        // Sorted .tmTheme files only, ignoring directories and non-.tmTheme files
513        with_themes_dir(|themes| {
514            fs::create_dir_all(themes.join("nested")).unwrap();
515            fs::write(themes.join("zeta.tmTheme"), "z").unwrap();
516            fs::write(themes.join("alpha.tmTheme"), "a").unwrap();
517            fs::write(themes.join("readme.txt"), "ignored").unwrap();
518
519            with_wisp_home(themes.parent().unwrap(), || {
520                assert_eq!(list_theme_files(), vec!["alpha.tmTheme", "zeta.tmTheme"]);
521            });
522        });
523    }
524
525    #[test]
526    fn list_theme_files_returns_empty_when_themes_dir_missing() {
527        let temp_dir = TempDir::new().unwrap();
528        with_wisp_home(temp_dir.path(), || {
529            assert!(list_theme_files().is_empty());
530        });
531    }
532
533    #[test]
534    fn theme_file_from_picker_value_parsing() {
535        for (input, expected) in [
536            ("   ", None),
537            ("", None),
538            ("sage.tmTheme", Some("sage.tmTheme")),
539            ("  spaced.tmTheme  ", Some("spaced.tmTheme")),
540        ] {
541            assert_eq!(theme_file_from_picker_value(input), expected.map(String::from), "input: {input:?}");
542        }
543    }
544
545    #[test]
546    fn process_theme_change_persists_and_produces_set_theme() {
547        use crate::test_helpers::CUSTOM_TMTHEME;
548        use tui::Color;
549
550        with_themes_dir(|themes| {
551            fs::write(themes.join("custom.tmTheme"), CUSTOM_TMTHEME).unwrap();
552
553            with_wisp_home(themes.parent().unwrap(), || {
554                let messages = process_config_changes(vec![change(THEME_CONFIG_ID, "custom.tmTheme")]);
555                let theme = messages.iter().find_map(|m| match m {
556                    overlay::SettingsMessage::SetTheme(t) => Some(t),
557                    _ => None,
558                });
559                assert!(theme.is_some(), "should produce SetTheme message");
560                assert_eq!(theme.unwrap().text_primary(), Color::Rgb { r: 0x11, g: 0x22, b: 0x33 });
561                assert_eq!(
562                    load_or_create_settings(StatusLineSettings::defaults()).theme.file.as_deref(),
563                    Some("custom.tmTheme")
564                );
565            });
566        });
567    }
568
569    #[test]
570    fn process_theme_change_persists_default_as_none() {
571        let temp_dir = TempDir::new().unwrap();
572        with_wisp_home(temp_dir.path(), || {
573            save_settings(&WispSettings {
574                theme: ThemeSettings { file: Some("old.tmTheme".to_string()) },
575                ..Default::default()
576            })
577            .unwrap();
578            let _ = process_config_changes(vec![change(THEME_CONFIG_ID, "   ")]);
579            assert_eq!(load_or_create_settings(StatusLineSettings::defaults()).theme.file, None);
580        });
581    }
582
583    #[test]
584    fn process_non_theme_change_produces_set_config_option() {
585        let messages = process_config_changes(vec![change("provider", "ollama")]);
586        match messages.as_slice() {
587            [overlay::SettingsMessage::SetConfigOption { config_id, value }] => {
588                assert_eq!(config_id, "provider");
589                assert_eq!(value, "ollama");
590            }
591            other => panic!("expected SetConfigOption, got: {other:?}"),
592        }
593    }
594
595    fn aether_default_status_line() -> StatusLineSettings {
596        StatusLineSettings {
597            separator: Some(" · ".to_string()),
598            left: Some(vec![StatusLineSegmentConfig::Cwd { max_width: None }, StatusLineSegmentConfig::GitRef]),
599            right: Some(vec![
600                StatusLineSegmentConfig::Mode,
601                StatusLineSegmentConfig::Model { max_width: None },
602                StatusLineSegmentConfig::Reasoning,
603                StatusLineSegmentConfig::Context,
604                StatusLineSegmentConfig::ServerHealth,
605            ]),
606        }
607    }
608
609    #[test]
610    fn aether_defaults_omit_agent_segment() {
611        let resolved = resolve_status_line_settings(
612            &WispSettings::default().with_default_status_line(aether_default_status_line()),
613        );
614        assert!(!resolved.right.contains(&StatusLineSegmentConfig::Agent));
615        assert!(resolved.right.contains(&StatusLineSegmentConfig::Model { max_width: None }));
616    }
617
618    #[test]
619    fn wisp_defaults_include_agent_segment() {
620        let resolved = resolve_status_line_settings(
621            &WispSettings::default().with_default_status_line(StatusLineSettings::defaults()),
622        );
623        assert!(resolved.right.contains(&StatusLineSegmentConfig::Agent));
624    }
625
626    #[test]
627    fn explicit_status_line_keeps_agent_for_aether() {
628        let settings = WispSettings {
629            status_line: Some(StatusLineSettings {
630                right: Some(vec![StatusLineSegmentConfig::Agent]),
631                ..Default::default()
632            }),
633            ..Default::default()
634        };
635
636        let resolved = resolve_status_line_settings(&settings.with_default_status_line(aether_default_status_line()));
637        assert_eq!(resolved.right, vec![StatusLineSegmentConfig::Agent]);
638    }
639
640    #[test]
641    fn partial_status_line_keeps_launcher_default_segments() {
642        let settings = WispSettings {
643            status_line: Some(StatusLineSettings { separator: Some(" | ".to_string()), ..Default::default() }),
644            ..Default::default()
645        };
646
647        let resolved = resolve_status_line_settings(&settings.with_default_status_line(aether_default_status_line()));
648
649        assert_eq!(resolved.separator, " | ");
650        assert_eq!(resolved.left, aether_default_status_line().left.unwrap());
651        assert_eq!(resolved.right, aether_default_status_line().right.unwrap());
652    }
653
654    #[test]
655    fn explicit_empty_right_stays_empty() {
656        let settings = WispSettings {
657            status_line: Some(StatusLineSettings { right: Some(vec![]), ..Default::default() }),
658            ..Default::default()
659        };
660
661        let resolved = resolve_status_line_settings(&settings.with_default_status_line(aether_default_status_line()));
662        assert!(resolved.right.is_empty());
663    }
664
665    #[test]
666    fn status_line_segments_support_shorthand_and_object_forms() {
667        let settings: WispSettings = serde_json::from_str(
668            r#"{
669                "statusLine": {
670                    "left": ["cwd", "gitRef"],
671                    "right": ["agent", {"type": "model", "maxWidth": 32}]
672                }
673            }"#,
674        )
675        .unwrap();
676
677        let status_line = settings.status_line.unwrap();
678        assert_eq!(
679            status_line.left,
680            Some(vec![StatusLineSegmentConfig::Cwd { max_width: None }, StatusLineSegmentConfig::GitRef])
681        );
682        assert_eq!(
683            status_line.right,
684            Some(vec![StatusLineSegmentConfig::Agent, StatusLineSegmentConfig::Model { max_width: Some(32) }])
685        );
686    }
687
688    #[test]
689    fn simple_status_line_segments_serialize_as_shorthand() {
690        let segments = vec![
691            StatusLineSegmentConfig::Cwd { max_width: None },
692            StatusLineSegmentConfig::GitRef,
693            StatusLineSegmentConfig::Agent,
694            StatusLineSegmentConfig::Model { max_width: None },
695        ];
696
697        assert_eq!(serde_json::to_value(&segments).unwrap(), serde_json::json!(["cwd", "gitRef", "agent", "model"]));
698    }
699}