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