Skip to main content

wisp/settings/
mod.rs

1#![doc = include_str!("../docs/settings_module.md")]
2
3pub mod menu;
4pub mod overlay;
5pub(crate) mod picker;
6pub mod types;
7
8use crate::components::provider_login::{ProviderLoginEntry, ProviderLoginStatus, provider_login_summary};
9use crate::components::server_status::server_status_summary;
10use acp_utils::notifications::McpServerStatusEntry;
11use acp_utils::settings::SettingsStore;
12use agent_client_protocol::schema::{AuthMethod, SessionConfigOption};
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15use tracing::warn;
16
17#[cfg(test)]
18pub(crate) static WISP_HOME_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
19
20#[doc = include_str!("../docs/wisp_settings.md")]
21#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
22#[serde(rename_all = "camelCase")]
23pub struct WispSettings {
24    #[serde(default)]
25    pub theme: ThemeSettings,
26    #[serde(default)]
27    pub content_padding: Option<u16>,
28}
29
30pub const DEFAULT_CONTENT_PADDING: usize = 2;
31
32pub fn resolve_content_padding(settings: &WispSettings) -> usize {
33    settings.content_padding.map_or(DEFAULT_CONTENT_PADDING, |v| v.max(2) as usize)
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
37#[serde(rename_all = "camelCase")]
38pub struct ThemeSettings {
39    #[serde(default)]
40    pub file: Option<String>,
41}
42
43pub fn wisp_home() -> Option<PathBuf> {
44    Some(SettingsStore::new("WISP_HOME", ".wisp")?.home().to_path_buf())
45}
46
47pub fn themes_dir_path() -> Option<PathBuf> {
48    Some(wisp_home()?.join("themes"))
49}
50
51pub fn load_or_create_settings() -> WispSettings {
52    if let Some(store) = SettingsStore::new("WISP_HOME", ".wisp") {
53        store.load_or_create()
54    } else {
55        warn!("Unable to resolve Wisp settings path; using defaults");
56        WispSettings::default()
57    }
58}
59
60pub fn load_theme(settings: &WispSettings) -> tui::Theme {
61    let Some(theme_file) = settings.theme.file.as_deref() else {
62        return tui::Theme::default();
63    };
64
65    let Some(path) = resolve_theme_file_path(theme_file) else {
66        warn!("Rejected unsafe theme filename: {}", theme_file);
67        return tui::Theme::default();
68    };
69
70    tui::Theme::load_from_path(&path)
71}
72
73pub fn resolve_theme_file_path(file_name: &str) -> Option<PathBuf> {
74    let trimmed = file_name.trim();
75    if trimmed.is_empty() {
76        return None;
77    }
78
79    let candidate = Path::new(trimmed);
80    let base_name = candidate.file_name()?.to_str()?;
81    if base_name != trimmed {
82        return None;
83    }
84
85    if base_name == "." || base_name == ".." {
86        return None;
87    }
88
89    Some(themes_dir_path()?.join(base_name))
90}
91
92pub fn list_theme_files() -> Vec<String> {
93    let Some(themes_dir) = themes_dir_path() else {
94        return Vec::new();
95    };
96
97    let Ok(entries) = std::fs::read_dir(themes_dir) else {
98        return Vec::new();
99    };
100
101    let mut files = entries
102        .filter_map(Result::ok)
103        .filter_map(|entry| {
104            let Ok(file_type) = entry.file_type() else {
105                return None;
106            };
107
108            if !file_type.is_file() {
109                return None;
110            }
111
112            let name = entry.file_name().into_string().ok()?;
113            if !name.ends_with(".tmTheme") {
114                return None;
115            }
116            Some(name)
117        })
118        .collect::<Vec<_>>();
119
120    files.sort_unstable();
121    files
122}
123
124pub(crate) fn build_login_entries(auth_methods: &[AuthMethod]) -> Vec<ProviderLoginEntry> {
125    auth_methods
126        .iter()
127        .map(|m| {
128            let status = if m.description() == Some("authenticated") {
129                ProviderLoginStatus::LoggedIn
130            } else {
131                ProviderLoginStatus::NeedsLogin
132            };
133            ProviderLoginEntry { method_id: m.id().0.to_string(), name: m.name().to_string(), status }
134        })
135        .collect()
136}
137
138pub(crate) fn create_overlay(
139    config_options: &[SessionConfigOption],
140    server_statuses: &[McpServerStatusEntry],
141    auth_methods: &[AuthMethod],
142) -> overlay::SettingsOverlay {
143    let mut menu = menu::SettingsMenu::from_config_options(config_options);
144    decorate_menu(&mut menu, server_statuses, auth_methods);
145    overlay::SettingsOverlay::new(menu, server_statuses.to_vec(), auth_methods.to_vec())
146        .with_reasoning_effort_from_options(config_options)
147}
148
149pub(crate) fn decorate_menu(
150    menu: &mut menu::SettingsMenu,
151    server_statuses: &[McpServerStatusEntry],
152    auth_methods: &[AuthMethod],
153) {
154    let settings = load_or_create_settings();
155    let theme_files = list_theme_files();
156    menu.add_theme_entry(settings.theme.file.as_deref(), &theme_files);
157
158    refresh_mcp_servers_entry(menu, server_statuses);
159
160    if !auth_methods.is_empty() {
161        let login_entries = build_login_entries(auth_methods);
162        let login_summary = provider_login_summary(&login_entries);
163        menu.add_provider_logins_entry(&login_summary);
164    }
165}
166
167pub(crate) fn refresh_mcp_servers_entry(menu: &mut menu::SettingsMenu, server_statuses: &[McpServerStatusEntry]) {
168    menu.upsert_mcp_servers_entry(&server_status_summary(server_statuses));
169}
170
171pub(crate) fn process_config_changes(changes: Vec<types::SettingsChange>) -> Vec<overlay::SettingsMessage> {
172    use acp_utils::config_option_id::THEME_CONFIG_ID;
173
174    let mut messages = Vec::new();
175    for change in changes {
176        if change.config_id == THEME_CONFIG_ID {
177            let file = theme_file_from_picker_value(&change.new_value);
178            let mut settings = load_or_create_settings();
179            settings.theme.file = file;
180            if let Err(err) = save_settings(&settings) {
181                tracing::warn!("Failed to persist theme setting: {err}");
182            }
183            let theme = load_theme(&settings);
184            messages.push(overlay::SettingsMessage::SetTheme(theme));
185        } else {
186            messages.push(overlay::SettingsMessage::SetConfigOption {
187                config_id: change.config_id,
188                value: change.new_value,
189            });
190        }
191    }
192    messages
193}
194
195fn theme_file_from_picker_value(value: &str) -> Option<String> {
196    let trimmed = value.trim();
197    if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
198}
199
200pub(crate) fn cycle_quick_option(config_options: &[SessionConfigOption]) -> Option<(String, String)> {
201    use crate::components::status_line::is_cycleable_mode_option;
202    use agent_client_protocol::schema::{SessionConfigKind, SessionConfigSelectOptions};
203
204    let option = config_options.iter().find(|option| is_cycleable_mode_option(option))?;
205
206    let SessionConfigKind::Select(ref select) = option.kind else {
207        return None;
208    };
209
210    let SessionConfigSelectOptions::Ungrouped(ref options) = select.options else {
211        return None;
212    };
213
214    if options.is_empty() {
215        return None;
216    }
217
218    let current_index = options.iter().position(|entry| entry.value == select.current_value).unwrap_or(0);
219    let next_index = (current_index + 1) % options.len();
220    options.get(next_index).map(|next| (option.id.0.to_string(), next.value.0.to_string()))
221}
222
223pub(crate) fn cycle_reasoning_option(config_options: &[SessionConfigOption]) -> Option<(String, String)> {
224    use crate::components::status_line::{extract_reasoning_effort, extract_reasoning_levels};
225    use acp_utils::config_option_id::ConfigOptionId;
226    use utils::ReasoningEffort;
227
228    let levels = extract_reasoning_levels(config_options);
229    if levels.is_empty() {
230        return None;
231    }
232
233    let current = extract_reasoning_effort(config_options);
234    let next = ReasoningEffort::cycle_within(current, &levels);
235    Some((ConfigOptionId::ReasoningEffort.as_str().to_string(), ReasoningEffort::config_str(next).to_string()))
236}
237
238pub(crate) fn unhealthy_server_count(statuses: &[McpServerStatusEntry]) -> usize {
239    use acp_utils::notifications::McpServerStatus;
240
241    statuses.iter().filter(|status| !matches!(status.status, McpServerStatus::Connected { .. })).count()
242}
243
244pub fn save_settings(settings: &WispSettings) -> std::io::Result<()> {
245    let store = SettingsStore::new("WISP_HOME", ".wisp")
246        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Unable to resolve Wisp settings path"))?;
247
248    store.save(settings)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::test_helpers::with_wisp_home;
255    use acp_utils::config_option_id::THEME_CONFIG_ID;
256    use acp_utils::settings::SettingsStore;
257    use std::fs;
258    use tempfile::TempDir;
259
260    fn change(config_id: &str, new_value: &str) -> types::SettingsChange {
261        types::SettingsChange { config_id: config_id.to_string(), new_value: new_value.to_string() }
262    }
263
264    fn with_themes_dir(f: impl FnOnce(&std::path::Path)) {
265        let temp_dir = TempDir::new().unwrap();
266        let themes = temp_dir.path().join("themes");
267        fs::create_dir_all(&themes).unwrap();
268        f(&themes);
269        std::mem::drop(temp_dir);
270    }
271
272    #[test]
273    fn round_trip_serde() {
274        let temp_dir = TempDir::new().unwrap();
275        let store = SettingsStore::from_path(temp_dir.path());
276        let settings =
277            WispSettings { theme: ThemeSettings { file: Some("my-theme.json".to_string()) }, content_padding: None };
278        store.save(&settings).unwrap();
279        assert_eq!(store.load_or_create::<WispSettings>(), settings);
280    }
281
282    #[test]
283    fn resolve_theme_file_path_allows_basename_only() {
284        for rejected in ["", "../escape.json", "subdir/theme.json"] {
285            assert!(resolve_theme_file_path(rejected).is_none(), "should reject {rejected:?}");
286        }
287        #[cfg(windows)]
288        assert!(resolve_theme_file_path("..\\escape.json").is_none());
289    }
290
291    #[test]
292    fn list_theme_files_returns_sorted_and_filters_correctly() {
293        // Sorted .tmTheme files only, ignoring directories and non-.tmTheme files
294        with_themes_dir(|themes| {
295            fs::create_dir_all(themes.join("nested")).unwrap();
296            fs::write(themes.join("zeta.tmTheme"), "z").unwrap();
297            fs::write(themes.join("alpha.tmTheme"), "a").unwrap();
298            fs::write(themes.join("readme.txt"), "ignored").unwrap();
299
300            with_wisp_home(themes.parent().unwrap(), || {
301                assert_eq!(list_theme_files(), vec!["alpha.tmTheme", "zeta.tmTheme"]);
302            });
303        });
304    }
305
306    #[test]
307    fn list_theme_files_returns_empty_when_themes_dir_missing() {
308        let temp_dir = TempDir::new().unwrap();
309        with_wisp_home(temp_dir.path(), || {
310            assert!(list_theme_files().is_empty());
311        });
312    }
313
314    #[test]
315    fn theme_file_from_picker_value_parsing() {
316        for (input, expected) in [
317            ("   ", None),
318            ("", None),
319            ("sage.tmTheme", Some("sage.tmTheme")),
320            ("  spaced.tmTheme  ", Some("spaced.tmTheme")),
321        ] {
322            assert_eq!(theme_file_from_picker_value(input), expected.map(String::from), "input: {input:?}");
323        }
324    }
325
326    #[test]
327    fn process_theme_change_persists_and_produces_set_theme() {
328        use crate::test_helpers::CUSTOM_TMTHEME;
329        use tui::Color;
330
331        with_themes_dir(|themes| {
332            fs::write(themes.join("custom.tmTheme"), CUSTOM_TMTHEME).unwrap();
333
334            with_wisp_home(themes.parent().unwrap(), || {
335                let messages = process_config_changes(vec![change(THEME_CONFIG_ID, "custom.tmTheme")]);
336                let theme = messages.iter().find_map(|m| match m {
337                    overlay::SettingsMessage::SetTheme(t) => Some(t),
338                    _ => None,
339                });
340                assert!(theme.is_some(), "should produce SetTheme message");
341                assert_eq!(theme.unwrap().text_primary(), Color::Rgb { r: 0x11, g: 0x22, b: 0x33 });
342                assert_eq!(load_or_create_settings().theme.file.as_deref(), Some("custom.tmTheme"));
343            });
344        });
345    }
346
347    #[test]
348    fn process_theme_change_persists_default_as_none() {
349        let temp_dir = TempDir::new().unwrap();
350        with_wisp_home(temp_dir.path(), || {
351            save_settings(&WispSettings {
352                theme: ThemeSettings { file: Some("old.tmTheme".to_string()) },
353                content_padding: None,
354            })
355            .unwrap();
356            let _ = process_config_changes(vec![change(THEME_CONFIG_ID, "   ")]);
357            assert_eq!(load_or_create_settings().theme.file, None);
358        });
359    }
360
361    #[test]
362    fn process_non_theme_change_produces_set_config_option() {
363        let messages = process_config_changes(vec![change("provider", "ollama")]);
364        match messages.as_slice() {
365            [overlay::SettingsMessage::SetConfigOption { config_id, value }] => {
366                assert_eq!(config_id, "provider");
367                assert_eq!(value, "ollama");
368            }
369            other => panic!("expected SetConfigOption, got: {other:?}"),
370        }
371    }
372}