arct_tui/panels/
settings.rs

1//! Interactive settings panel for configuration
2
3use crate::icons;
4use crate::theme::Theme;
5use anyhow::Result;
6use ratatui::{
7    layout::Rect,
8    text::{Line, Span},
9    widgets::{Block, Borders, Clear, Paragraph, Wrap},
10    Frame,
11};
12
13/// Editable settings fields
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum SettingField {
16    UserName,
17    AIEnabled,
18    AIProvider,
19    Theme,
20}
21
22impl SettingField {
23    /// Get the next field
24    pub fn next(self) -> Self {
25        match self {
26            Self::UserName => Self::AIEnabled,
27            Self::AIEnabled => Self::AIProvider,
28            Self::AIProvider => Self::Theme,
29            Self::Theme => Self::UserName,
30        }
31    }
32
33    /// Get the previous field
34    pub fn previous(self) -> Self {
35        match self {
36            Self::UserName => Self::Theme,
37            Self::AIEnabled => Self::UserName,
38            Self::AIProvider => Self::AIEnabled,
39            Self::Theme => Self::AIProvider,
40        }
41    }
42
43    /// Get field name for display
44    pub fn name(&self) -> &str {
45        match self {
46            Self::UserName => "Name",
47            Self::AIEnabled => "AI Assistant",
48            Self::AIProvider => "AI Provider",
49            Self::Theme => "Theme",
50        }
51    }
52}
53
54/// Interactive settings panel
55pub struct SettingsPanel {
56    /// Currently selected field
57    pub selected_field: SettingField,
58
59    /// Whether we're in edit mode
60    pub editing: bool,
61
62    /// Edit buffer for current field
63    pub edit_buffer: String,
64}
65
66impl SettingsPanel {
67    pub fn new() -> Self {
68        Self {
69            selected_field: SettingField::UserName,
70            editing: false,
71            edit_buffer: String::new(),
72        }
73    }
74
75    /// Move selection up
76    pub fn previous_field(&mut self) {
77        self.selected_field = self.selected_field.previous();
78    }
79
80    /// Move selection down
81    pub fn next_field(&mut self) {
82        self.selected_field = self.selected_field.next();
83    }
84
85    /// Start editing the selected field
86    pub fn start_editing(&mut self, config: &arct_config::Config) {
87        self.editing = true;
88        self.edit_buffer = match self.selected_field {
89            SettingField::UserName => {
90                config.general.user_name.clone().unwrap_or_default()
91            }
92            SettingField::AIEnabled => {
93                if config.ai.enabled { "true" } else { "false" }.to_string()
94            }
95            SettingField::AIProvider => {
96                config.ai.provider.clone()
97            }
98            SettingField::Theme => {
99                config.theme.default_theme.clone()
100            }
101        };
102    }
103
104    /// Cancel editing
105    pub fn cancel_editing(&mut self) {
106        self.editing = false;
107        self.edit_buffer.clear();
108    }
109
110    /// Save the current edit to config
111    pub fn save_edit(&mut self, config: &mut arct_config::Config) -> Result<()> {
112        match self.selected_field {
113            SettingField::UserName => {
114                if self.edit_buffer.is_empty() {
115                    config.general.user_name = None;
116                } else {
117                    config.general.user_name = Some(self.edit_buffer.clone());
118                }
119            }
120            SettingField::AIEnabled => {
121                let value = self.edit_buffer.to_lowercase();
122                let enabling = value == "true" || value == "yes" || value == "1" || value == "enabled";
123
124                // If enabling AI and no provider is set, use smart defaults
125                if enabling && !config.ai.enabled {
126                    // Check if provider is unset or set to default anthropic
127                    if config.ai.provider.is_empty() || config.ai.provider == "anthropic" {
128                        // Try to detect Claude CLI first
129                        if arct_ai::claude_cli::ClaudeCLIProvider::is_available() {
130                            config.ai.provider = "claude-cli".to_string();
131                            config.ai.model = Some("claude-sonnet-4".to_string());
132                        } else {
133                            // Fall back to local LLM
134                            config.ai.provider = "local".to_string();
135                            config.ai.endpoint = Some("http://localhost:11434".to_string());
136                            config.ai.model = Some("llama3.2".to_string());
137                        }
138                    }
139                }
140
141                config.ai.enabled = enabling;
142            }
143            SettingField::AIProvider => {
144                config.ai.provider = self.edit_buffer.clone();
145            }
146            SettingField::Theme => {
147                config.theme.default_theme = self.edit_buffer.clone();
148            }
149        }
150
151        config.save()?;
152        self.editing = false;
153        self.edit_buffer.clear();
154        Ok(())
155    }
156
157    /// Add character to edit buffer
158    pub fn push_char(&mut self, c: char) {
159        if self.editing {
160            self.edit_buffer.push(c);
161        }
162    }
163
164    /// Remove last character from edit buffer
165    pub fn pop_char(&mut self) {
166        if self.editing {
167            self.edit_buffer.pop();
168        }
169    }
170
171    /// Render the settings panel as a centered overlay
172    pub fn render(&self, frame: &mut Frame, theme: &Theme, config: &arct_config::Config) {
173        let area = frame.size();
174
175        // Create centered modal
176        let modal_width = 80.min(area.width - 4);
177        let modal_height = 28.min(area.height - 4);
178
179        let horizontal_margin = (area.width.saturating_sub(modal_width)) / 2;
180        let vertical_margin = (area.height.saturating_sub(modal_height)) / 2;
181
182        let modal_area = Rect {
183            x: horizontal_margin,
184            y: vertical_margin,
185            width: modal_width,
186            height: modal_height,
187        };
188
189        // Clear background
190        frame.render_widget(Clear, modal_area);
191
192        let title = if self.editing {
193            format!(" Settings - Editing: {} ", self.selected_field.name())
194        } else {
195            " Settings - Use ↑↓ to navigate, Enter to edit ".to_string()
196        };
197
198        let block = Block::default()
199            .title(title)
200            .borders(Borders::ALL)
201            .border_style(theme.style_border_focused())
202            .style(theme.style_block());  // Set background for light themes
203
204        let inner = block.inner(modal_area);
205        frame.render_widget(block, modal_area);
206
207        // Build settings display
208        let user_name = config.general.user_name.as_deref().unwrap_or("Not set");
209        let ai_status = if config.ai.enabled { "Enabled" } else { "Disabled" };
210        let ai_provider = &config.ai.provider;
211        let theme_name = &config.theme.default_theme;
212
213        let mut lines = vec![
214            Line::from(""),
215            Line::from(vec![
216                Span::styled("  User Profile", theme.style_header()),
217            ]),
218        ];
219
220        // User Name field
221        self.add_field_line(
222            &mut lines,
223            SettingField::UserName,
224            "Name",
225            if self.editing && self.selected_field == SettingField::UserName {
226                &self.edit_buffer
227            } else {
228                user_name
229            },
230            theme,
231        );
232
233        lines.push(Line::from(""));
234        lines.push(Line::from(vec![
235            Span::styled("  AI Assistant", theme.style_header()),
236        ]));
237
238        // AI Enabled field
239        self.add_field_line(
240            &mut lines,
241            SettingField::AIEnabled,
242            "Status",
243            if self.editing && self.selected_field == SettingField::AIEnabled {
244                &self.edit_buffer
245            } else {
246                ai_status
247            },
248            theme,
249        );
250
251        // AI Provider field
252        self.add_field_line(
253            &mut lines,
254            SettingField::AIProvider,
255            "Provider",
256            if self.editing && self.selected_field == SettingField::AIProvider {
257                &self.edit_buffer
258            } else {
259                ai_provider
260            },
261            theme,
262        );
263
264        // Add helpful note about AI setup
265        if config.ai.enabled {
266            let note = match ai_provider.as_str() {
267                "claude-cli" => format!("    {}Using Claude Code CLI - no API key needed", icons::hint().content),
268                "anthropic" | "openai" => format!("    {}Set API key in config.toml", icons::hint().content),
269                "local" => format!("    {}Make sure your local LLM server is running", icons::hint().content),
270                "managed" => format!("    {}Using Arc Academy managed AI", icons::hint().content),
271                _ => format!("    {}Invalid provider - press Enter to configure", icons::warning().content),
272            };
273            lines.push(Line::from(vec![
274                Span::styled(note, theme.style_dim()),
275            ]));
276        } else {
277            lines.push(Line::from(vec![
278                Span::styled(format!("    {}Enable to use AI features (auto-configures provider)", icons::hint().content), theme.style_dim()),
279            ]));
280        }
281
282        lines.push(Line::from(""));
283        lines.push(Line::from(vec![
284            Span::styled("  Appearance", theme.style_header()),
285        ]));
286
287        // Theme field
288        self.add_field_line(
289            &mut lines,
290            SettingField::Theme,
291            "Theme",
292            if self.editing && self.selected_field == SettingField::Theme {
293                &self.edit_buffer
294            } else {
295                theme_name
296            },
297            theme,
298        );
299
300        lines.push(Line::from(""));
301        lines.push(Line::from(""));
302
303        if self.editing {
304            lines.push(Line::from(vec![
305                Span::styled("  Press ", theme.style_dim()),
306                Span::styled("Enter", theme.style_accent()),
307                Span::styled(" to save, ", theme.style_dim()),
308                Span::styled("Esc", theme.style_accent()),
309                Span::styled(" to cancel", theme.style_dim()),
310            ]));
311        } else {
312            lines.push(Line::from(vec![
313                Span::styled("  Controls", theme.style_header()),
314            ]));
315            lines.push(Line::from(vec![
316                Span::styled("    • Press ", theme.style_dim()),
317                Span::styled("↑↓", theme.style_accent()),
318                Span::styled(" to navigate fields", theme.style_dim()),
319            ]));
320            lines.push(Line::from(vec![
321                Span::styled("    • Press ", theme.style_dim()),
322                Span::styled("Enter", theme.style_accent()),
323                Span::styled(" to edit selected field", theme.style_dim()),
324            ]));
325            lines.push(Line::from(vec![
326                Span::styled("    • Press ", theme.style_dim()),
327                Span::styled("Ctrl+T", theme.style_accent()),
328                Span::styled(" to cycle themes", theme.style_dim()),
329            ]));
330            lines.push(Line::from(vec![
331                Span::styled("    • Press ", theme.style_dim()),
332                Span::styled("Ctrl+A", theme.style_accent()),
333                Span::styled(" to toggle AI", theme.style_dim()),
334            ]));
335            lines.push(Line::from(""));
336            lines.push(Line::from(vec![
337                Span::styled("  Press ", theme.style_dim()),
338                Span::styled("Esc", theme.style_accent()),
339                Span::styled(" or ", theme.style_dim()),
340                Span::styled("Ctrl+S", theme.style_accent()),
341                Span::styled(" to close", theme.style_dim()),
342            ]));
343        }
344
345        let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
346        frame.render_widget(paragraph, inner);
347    }
348
349    /// Add a field line with selection indicator
350    fn add_field_line<'a>(
351        &self,
352        lines: &mut Vec<Line<'a>>,
353        field: SettingField,
354        label: &str,
355        value: &'a str,
356        theme: &Theme,
357    ) {
358        let is_selected = self.selected_field == field;
359        let is_editing = self.editing && is_selected;
360
361        let selector = if is_selected { "  ▶ " } else { "    " };
362
363        let mut spans = vec![
364            Span::styled(selector, theme.style_accent()),
365            Span::styled(format!("{}: ", label), theme.style_dim()),
366        ];
367
368        if is_editing {
369            spans.push(Span::styled(value, theme.style_accent()));
370            spans.push(Span::styled("█", theme.style_accent()));
371        } else if is_selected {
372            spans.push(Span::styled(value, theme.style_success()));
373        } else {
374            spans.push(Span::styled(value, theme.style_normal()));
375        }
376
377        lines.push(Line::from(spans));
378    }
379}
380
381impl Default for SettingsPanel {
382    fn default() -> Self {
383        Self::new()
384    }
385}