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