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 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}