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