Skip to main content

wisp/settings/
mod.rs

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