Skip to main content

wisp/settings/
mod.rs

1#[cfg(feature = "testing")]
2pub mod overlay;
3#[cfg(not(feature = "testing"))]
4pub(crate) mod overlay;
5mod settings_model;
6mod themes;
7
8use clankerdiff_ratatui::theme::ReviewTheme;
9pub(crate) use settings_model::SettingsModel;
10pub(crate) use themes::{builtin_review_theme_choices, review_theme_choices};
11pub use themes::{list_theme_files, load_theme_file};
12
13use serde::{Deserialize, Serialize};
14use tracing::warn;
15use utils::settings::SettingsStore;
16
17use crate::theme::ThemeLoadError;
18
19pub const DEFAULT_CONTENT_PADDING: usize = 2;
20
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(default, rename_all = "camelCase")]
23pub struct UiSettings {
24    pub theme: ThemeSettings,
25    pub content_padding: Option<u16>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub status_line: Option<StatusLineSettings>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub keybindings: Option<KeybindingsSettings>,
30}
31
32/// Overrides for the global command bindings, as `"ctrl+g"`-style strings.
33/// Absent entries keep their defaults.
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct KeybindingsSettings {
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub exit: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub cancel: Option<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub submit: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub open_command_picker: Option<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub open_file_picker: Option<String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub toggle_git_diff: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub cycle_reasoning: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub cycle_mode: Option<String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub open_prompt_search: Option<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "source", rename_all = "lowercase", deny_unknown_fields)]
59pub enum ThemeSettings {
60    Builtin {
61        #[serde(deserialize_with = "deserialize_builtin_theme")]
62        id: String,
63    },
64    File {
65        #[serde(deserialize_with = "deserialize_theme_file")]
66        file: String,
67    },
68}
69
70impl Default for ThemeSettings {
71    fn default() -> Self {
72        Self::Builtin { id: "sage".into() }
73    }
74}
75
76impl ThemeSettings {
77    pub fn selection_id(&self) -> String {
78        match self {
79            Self::Builtin { id } => format!("builtin:{id}"),
80            Self::File { file } => format!("file:{file}"),
81        }
82    }
83
84    pub fn from_selection_id(value: &str) -> Result<Self, ThemeLoadError> {
85        if let Some(id) = value.strip_prefix("builtin:") {
86            clankerdiff_ratatui::theme::ReviewTheme::builtin(id)?;
87            Ok(Self::Builtin { id: id.into() })
88        } else if let Some(file) = value.strip_prefix("file:") {
89            themes::validate_file_name(file)?;
90            Ok(Self::File { file: file.into() })
91        } else {
92            Err(ThemeLoadError::InvalidFile(value.into()))
93        }
94    }
95}
96
97fn deserialize_theme_file<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
98    let file = String::deserialize(deserializer)?;
99    themes::validate_file_name(&file).map_err(serde::de::Error::custom)?;
100    Ok(file)
101}
102
103fn deserialize_builtin_theme<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
104    let id = String::deserialize(deserializer)?;
105    ReviewTheme::builtin(&id).map_err(serde::de::Error::custom)?;
106    Ok(id)
107}
108
109#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase", deny_unknown_fields)]
111pub struct StatusLineSettings {
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub separator: Option<String>,
114    #[serde(default, deserialize_with = "deserialize_segments", skip_serializing_if = "Option::is_none")]
115    pub left: Option<Vec<StatusLineSegmentConfig>>,
116    #[serde(default, deserialize_with = "deserialize_segments", skip_serializing_if = "Option::is_none")]
117    pub right: Option<Vec<StatusLineSegmentConfig>>,
118}
119
120/// A configured status-line segment, as a tagged object
121/// (`{"type":"model","maxWidth":40}`).
122///
123/// A segment carrying no options may also be written as its bare name
124/// (`"model"`). Both clients read the same `~/.wisp/settings.json`, and a file
125/// serde rejects is discarded whole, so refusing the shorthand would silently
126/// reset every unrelated setting alongside it. Writing always uses the object
127/// form.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
130pub enum StatusLineSegmentConfig {
131    Cwd {
132        #[serde(default, rename = "maxWidth", skip_serializing_if = "Option::is_none")]
133        max_width: Option<u16>,
134    },
135    GitRef,
136    Agent,
137    Mode,
138    Model {
139        #[serde(default, rename = "maxWidth", skip_serializing_if = "Option::is_none")]
140        max_width: Option<u16>,
141    },
142    Reasoning,
143    Context,
144    ServerHealth,
145    Text {
146        value: String,
147        #[serde(default, skip_serializing_if = "Option::is_none")]
148        style: Option<StatusLineStyle>,
149    },
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct ResolvedStatusLineSettings {
154    pub separator: String,
155    pub left: Vec<StatusLineSegmentConfig>,
156    pub right: Vec<StatusLineSegmentConfig>,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub enum StatusLineStyle {
162    Primary,
163    Secondary,
164    Muted,
165    Info,
166    Success,
167    Warning,
168    Error,
169}
170
171impl UiSettings {
172    /// Fill missing status-line fields while preserving explicit user settings.
173    pub fn with_default_status_line(mut self, default: StatusLineSettings) -> Self {
174        let current = self.status_line.unwrap_or_default();
175        self.status_line = Some(StatusLineSettings {
176            separator: current.separator.or(default.separator),
177            left: current.left.or(default.left),
178            right: current.right.or(default.right),
179        });
180        self
181    }
182}
183
184impl StatusLineSettings {
185    pub fn resolve(self) -> ResolvedStatusLineSettings {
186        ResolvedStatusLineSettings {
187            separator: self.separator.unwrap_or_else(default_separator),
188            left: self.left.unwrap_or_else(default_left_segments),
189            right: self.right.unwrap_or_else(default_right_segments),
190        }
191    }
192}
193
194/// The bare-name spelling of every segment that takes no options.
195#[derive(Deserialize)]
196#[serde(rename_all = "camelCase")]
197enum SegmentName {
198    Cwd,
199    GitRef,
200    Agent,
201    Mode,
202    Model,
203    Reasoning,
204    Context,
205    ServerHealth,
206}
207
208#[derive(Deserialize)]
209#[serde(untagged)]
210enum SegmentWire {
211    Name(SegmentName),
212    Config(StatusLineSegmentConfig),
213}
214
215impl From<SegmentWire> for StatusLineSegmentConfig {
216    fn from(wire: SegmentWire) -> Self {
217        match wire {
218            SegmentWire::Config(config) => config,
219            SegmentWire::Name(SegmentName::Cwd) => Self::Cwd { max_width: None },
220            SegmentWire::Name(SegmentName::GitRef) => Self::GitRef,
221            SegmentWire::Name(SegmentName::Agent) => Self::Agent,
222            SegmentWire::Name(SegmentName::Mode) => Self::Mode,
223            SegmentWire::Name(SegmentName::Model) => Self::Model { max_width: None },
224            SegmentWire::Name(SegmentName::Reasoning) => Self::Reasoning,
225            SegmentWire::Name(SegmentName::Context) => Self::Context,
226            SegmentWire::Name(SegmentName::ServerHealth) => Self::ServerHealth,
227        }
228    }
229}
230
231fn deserialize_segments<'de, D: serde::Deserializer<'de>>(
232    deserializer: D,
233) -> Result<Option<Vec<StatusLineSegmentConfig>>, D::Error> {
234    let segments = Option::<Vec<SegmentWire>>::deserialize(deserializer)?;
235    Ok(segments.map(|segments| segments.into_iter().map(Into::into).collect()))
236}
237
238fn default_separator() -> String {
239    " · ".to_string()
240}
241
242fn default_left_segments() -> Vec<StatusLineSegmentConfig> {
243    vec![StatusLineSegmentConfig::Cwd { max_width: None }, StatusLineSegmentConfig::GitRef]
244}
245
246fn default_right_segments() -> Vec<StatusLineSegmentConfig> {
247    vec![
248        StatusLineSegmentConfig::Agent,
249        StatusLineSegmentConfig::Mode,
250        StatusLineSegmentConfig::Model { max_width: None },
251        StatusLineSegmentConfig::Reasoning,
252        StatusLineSegmentConfig::Context,
253        StatusLineSegmentConfig::ServerHealth,
254    ]
255}
256
257pub fn resolve_status_line_settings(settings: &UiSettings) -> ResolvedStatusLineSettings {
258    settings.status_line.clone().unwrap_or_default().resolve()
259}
260
261pub fn resolve_content_padding(settings: &UiSettings) -> usize {
262    settings.content_padding.map_or(DEFAULT_CONTENT_PADDING, |value| value.max(2) as usize)
263}
264
265pub fn load_or_create_settings() -> UiSettings {
266    store().map_or_else(
267        || {
268            warn!("Unable to resolve Wisp settings path; using defaults");
269            UiSettings::default()
270        },
271        |store| store.load_or_create(),
272    )
273}
274
275pub fn save_settings(settings: &UiSettings) -> std::io::Result<()> {
276    let store = store()
277        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Unable to resolve Wisp settings path"))?;
278    store.save(settings)
279}
280
281/// The on-disk home for wisp settings, shared by every load and save.
282fn store() -> Option<SettingsStore> {
283    SettingsStore::new("WISP_HOME", ".wisp")
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn launcher_defaults_fill_only_missing_status_line_fields() {
292        let settings = UiSettings {
293            status_line: Some(StatusLineSettings {
294                separator: Some(" | ".to_string()),
295                left: None,
296                right: Some(Vec::new()),
297            }),
298            ..UiSettings::default()
299        };
300        let defaults = StatusLineSettings {
301            separator: Some(" · ".to_string()),
302            left: Some(vec![StatusLineSegmentConfig::GitRef]),
303            right: Some(vec![StatusLineSegmentConfig::Agent]),
304        };
305
306        let status_line = settings.with_default_status_line(defaults).status_line.unwrap();
307
308        assert_eq!(status_line.separator.as_deref(), Some(" | "));
309        assert_eq!(status_line.left, Some(vec![StatusLineSegmentConfig::GitRef]));
310        assert_eq!(status_line.right, Some(Vec::new()));
311    }
312
313    #[test]
314    fn status_line_segments_support_tagged_objects() {
315        let settings: UiSettings = serde_json::from_str(
316            r#"{
317                "statusLine": {
318                    "left": [{"type": "cwd"}, {"type": "gitRef"}],
319                    "right": [{"type": "agent"}, {"type": "model", "maxWidth": 32}]
320                }
321            }"#,
322        )
323        .unwrap();
324
325        let status_line = settings.status_line.unwrap();
326        assert_eq!(
327            status_line.left,
328            Some(vec![StatusLineSegmentConfig::Cwd { max_width: None }, StatusLineSegmentConfig::GitRef])
329        );
330        assert_eq!(
331            status_line.right,
332            Some(vec![StatusLineSegmentConfig::Agent, StatusLineSegmentConfig::Model { max_width: Some(32) }])
333        );
334    }
335
336    #[test]
337    fn shorthand_segment_names_are_read_as_optionless_segments() {
338        let settings: UiSettings = serde_json::from_str(
339            r#"{
340                "statusLine": {
341                    "left": ["cwd", "gitRef"],
342                    "right": ["agent", {"type": "model", "maxWidth": 32}]
343                }
344            }"#,
345        )
346        .unwrap();
347
348        let status_line = settings.status_line.unwrap();
349        assert_eq!(
350            status_line.left,
351            Some(vec![StatusLineSegmentConfig::Cwd { max_width: None }, StatusLineSegmentConfig::GitRef])
352        );
353        assert_eq!(
354            status_line.right,
355            Some(vec![StatusLineSegmentConfig::Agent, StatusLineSegmentConfig::Model { max_width: Some(32) }])
356        );
357    }
358
359    /// A settings file serde rejects is discarded whole, taking every unrelated
360    /// setting with it, so the shorthand must not cost the reader its theme.
361    #[test]
362    fn a_shorthand_status_line_does_not_discard_the_rest_of_the_file() {
363        let settings: UiSettings = serde_json::from_str(
364            r#"{
365                "contentPadding": 4,
366                "theme": {"source": "file", "file": "nord.json"},
367                "statusLine": {"left": ["cwd"]}
368            }"#,
369        )
370        .unwrap();
371
372        assert_eq!(settings.content_padding, Some(4));
373        assert_eq!(settings.theme, ThemeSettings::File { file: "nord.json".into() });
374    }
375
376    #[test]
377    fn segments_always_serialize_as_objects() {
378        let settings = StatusLineSettings {
379            separator: None,
380            left: Some(vec![StatusLineSegmentConfig::Cwd { max_width: None }, StatusLineSegmentConfig::GitRef]),
381            right: None,
382        };
383
384        assert_eq!(
385            serde_json::to_value(&settings).unwrap(),
386            serde_json::json!({"left": [{"type": "cwd"}, {"type": "gitRef"}]})
387        );
388    }
389
390    #[test]
391    fn text_segment_with_style_deserializes() {
392        let settings: UiSettings = serde_json::from_str(
393            r#"{
394                "statusLine": {
395                    "left": [{"type": "text", "value": "hello", "style": "warning"}]
396                }
397            }"#,
398        )
399        .unwrap();
400
401        let status_line = settings.status_line.unwrap();
402        assert_eq!(
403            status_line.left,
404            Some(vec![StatusLineSegmentConfig::Text {
405                value: "hello".to_string(),
406                style: Some(StatusLineStyle::Warning)
407            }])
408        );
409    }
410
411    #[test]
412    fn status_line_settings_present_are_no_longer_ignored() {
413        let settings: UiSettings = serde_json::from_str(
414            r#"{"contentPadding":4,"theme":{"source":"file","file":"nord.json"},"statusLine":{"left":[{"type":"cwd"}],"right":[{"type":"agent"}]}}"#,
415        )
416        .unwrap();
417
418        assert_eq!(settings.content_padding, Some(4));
419        assert_eq!(settings.theme, ThemeSettings::File { file: "nord.json".into() });
420        assert!(settings.status_line.is_some());
421        let sl = settings.status_line.unwrap();
422        assert_eq!(sl.left, Some(vec![StatusLineSegmentConfig::Cwd { max_width: None }]));
423        assert_eq!(sl.right, Some(vec![StatusLineSegmentConfig::Agent]));
424    }
425
426    #[test]
427    fn cwd_max_width_deserializes() {
428        let settings: UiSettings = serde_json::from_str(
429            r#"{
430                "statusLine": {
431                    "right": [{"type": "cwd", "maxWidth": 30}]
432                }
433            }"#,
434        )
435        .unwrap();
436
437        let sl = settings.status_line.unwrap();
438        assert_eq!(sl.right, Some(vec![StatusLineSegmentConfig::Cwd { max_width: Some(30) }]));
439    }
440
441    #[test]
442    fn cwd_max_width_serializes_as_object() {
443        let seg = StatusLineSegmentConfig::Cwd { max_width: Some(30) };
444        let json = serde_json::to_value(&seg).unwrap();
445        assert_eq!(json, serde_json::json!({"type": "cwd", "maxWidth": 30}));
446    }
447
448    #[test]
449    fn status_line_settings_rejects_unknown_fields() {
450        let err =
451            serde_json::from_str::<UiSettings>(r#"{"statusLine": {"left": [{"type":"cwd"}], "unknownField": true}}"#)
452                .unwrap_err();
453        assert!(
454            err.to_string().contains("unknown field"),
455            "should reject unknown fields in StatusLineSettings, got: {err}"
456        );
457    }
458
459    #[test]
460    fn status_line_segment_object_rejects_unknown_fields() {
461        let err = serde_json::from_str::<UiSettings>(
462            r#"{"statusLine": {"left": [{"type": "cwd", "maxWidth": 30, "foo": 42}]}}"#,
463        )
464        .unwrap_err();
465        let msg = err.to_string();
466        assert!(
467            msg.contains("unknown field") || msg.contains("did not match"),
468            "should reject unknown fields in segment objects, got: {msg}"
469        );
470    }
471
472    #[test]
473    fn invalid_status_line_style_is_rejected() {
474        let err = serde_json::from_str::<UiSettings>(
475            r#"{"statusLine": {"left": [{"type": "text", "value": "hi", "style": "notARealStyle"}]}}"#,
476        )
477        .unwrap_err();
478        let msg = err.to_string();
479        assert!(
480            msg.contains("unknown variant") || msg.contains("did not match"),
481            "should reject invalid style names, got: {msg}"
482        );
483    }
484}