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#[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#[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 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#[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
231fn 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 #[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}