Skip to main content

wisp/settings/overlay/
mod.rs

1mod menu;
2mod model_selector;
3mod picker;
4mod provider_login;
5mod server_status;
6
7use self::menu::{MenuRow, SettingsMenu};
8use self::model_selector::ModelSelector;
9use self::picker::SettingsPicker;
10use self::provider_login::{ProviderLoginEntry, ProviderLoginPane, ProviderLoginStatus, build_provider_login_entries};
11use self::server_status::ServerStatusPane;
12use crate::renderer::DrawContext;
13use crate::session::platform::{BrowserOpener, ClipboardWriter};
14use crate::session::session_config_view::{LocalConfigOption, LocalConfigView};
15use crate::surfaces::input::{ElicitationOutput, MouseAction, SettingsOutput, UiEvent, is_press};
16use crate::surfaces::modal::{ElicitationModal, frame::ModalFrame};
17use crate::theme::Theme;
18use crate::view::selection::Direction;
19use crate::view::widgets::key_hints;
20use acp_utils::config_meta::SelectOptionMeta;
21use acp_utils::notifications::McpServerStatusEntry;
22use agent_client_protocol::Responder;
23use agent_client_protocol::schema::v2::{AuthMethod, CreateElicitationRequest, CreateElicitationResponse};
24use crossterm::event::{KeyCode, KeyEvent};
25use ratatui::buffer::Buffer;
26use ratatui::layout::{Constraint, Layout, Position, Rect};
27use ratatui::style::Style;
28use ratatui::text::Line;
29use ratatui::widgets::{Clear, Paragraph, Widget};
30
31const MIN_WIDTH: u16 = 6;
32const MIN_HEIGHT: u16 = 3;
33
34#[derive(Debug)]
35pub struct SettingsChange {
36    pub config_id: String,
37    pub new_value: String,
38}
39
40#[derive(Debug, Clone)]
41pub struct SettingsMenuValue {
42    pub value: String,
43    pub name: String,
44    pub group: Option<String>,
45    pub description: Option<String>,
46    pub is_disabled: bool,
47    pub meta: SelectOptionMeta,
48}
49
50#[derive(Debug, Clone)]
51pub struct SettingsMenuEntry {
52    pub config_id: String,
53    pub title: String,
54    pub values: Vec<SettingsMenuValue>,
55    pub current_value_index: usize,
56    pub current_raw_value: String,
57    pub multi_select: bool,
58    pub display_name: Option<String>,
59    /// Client-side rather than part of the agent's schema, so it survives a
60    /// config-options push that replaces every agent-provided row.
61    pub local: bool,
62}
63
64/// A settings screen that is not a config option.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum PaneKind {
67    McpServers,
68    ProviderLogins,
69}
70
71impl PaneKind {
72    pub(crate) fn title(self) -> &'static str {
73        match self {
74            Self::McpServers => "MCP Servers",
75            Self::ProviderLogins => "Provider Logins",
76        }
77    }
78}
79
80/// The settings overlay: a menu of options, each of which opens a pane.
81pub struct SettingsOverlay {
82    menu: SettingsMenu,
83    /// The pane drawn over the menu. `None` means the menu itself has focus.
84    pane: Option<SettingsPane>,
85    current_reasoning_effort: Option<String>,
86    live: LiveSettingsData,
87    /// A request the overlay answers itself, drawn under the pane and owning
88    /// input until it is answered. Authenticating an OAuth server asks for a
89    /// browser this way, so the pane that started it has to survive the ask.
90    pending_elicitation: Option<ElicitationModal>,
91}
92
93/// Agent-pushed state that panes display. The overlay owns it so a pane opened
94/// later sees current data, and an open pane can be refreshed in place.
95pub(crate) struct LiveSettingsData {
96    pub(crate) servers: Vec<McpServerStatusEntry>,
97    pub(crate) providers: Vec<ProviderLoginEntry>,
98}
99
100pub(crate) use crate::view::widgets::KeyHint;
101
102enum SettingsPane {
103    ServerStatus(ServerStatusPane),
104    ProviderLogin(ProviderLoginPane),
105    ModelSelector(ModelSelector),
106    Picker(SettingsPicker),
107}
108
109impl SettingsPane {
110    pub(crate) fn on_ui_event(&mut self, event: UiEvent) -> Vec<SettingsOutput> {
111        match self {
112            Self::ServerStatus(pane) => pane.on_ui_event(event),
113            Self::ProviderLogin(pane) => pane.on_ui_event(event),
114            Self::ModelSelector(pane) => pane.on_ui_event(event),
115            Self::Picker(pane) => pane.on_ui_event(event),
116        }
117    }
118
119    pub(crate) fn footer(&self) -> Vec<KeyHint> {
120        match self {
121            Self::ServerStatus(_) => confirm_back_footer("authenticate OAuth servers"),
122            Self::ProviderLogin(_) => confirm_back_footer("authenticate"),
123            Self::ModelSelector(pane) => pane.footer(),
124            Self::Picker(_) => confirm_back_footer("confirm"),
125        }
126    }
127
128    fn refresh(&mut self, live: &LiveSettingsData) {
129        match self {
130            Self::ServerStatus(pane) => pane.refresh(live),
131            Self::ProviderLogin(pane) => pane.refresh(live),
132            Self::ModelSelector(_) | Self::Picker(_) => {}
133        }
134    }
135
136    fn take_changes(&mut self) -> Option<Vec<SettingsOutput>> {
137        match self {
138            Self::ServerStatus(_) | Self::ProviderLogin(_) | Self::Picker(_) => Some(Vec::new()),
139            Self::ModelSelector(pane) => pane.take_changes(),
140        }
141    }
142
143    fn render(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
144        match self {
145            Self::ServerStatus(pane) => pane.render(area, buf, cx.theme),
146            Self::ProviderLogin(pane) => pane.render(area, buf, cx.theme),
147            Self::ModelSelector(pane) => pane.render(area, buf, cx.theme),
148            Self::Picker(pane) => pane.render(area, buf, cx.theme),
149        }
150    }
151}
152impl SettingsOverlay {
153    pub fn new(
154        config_options: &[LocalConfigOption],
155        server_statuses: Vec<McpServerStatusEntry>,
156        auth_methods: &[AuthMethod],
157    ) -> Self {
158        Self {
159            menu: SettingsMenu::from_config_options(config_options),
160            pane: None,
161            current_reasoning_effort: reasoning_effort_of(config_options),
162            live: LiveSettingsData { servers: server_statuses, providers: build_provider_login_entries(auth_methods) },
163            pending_elicitation: None,
164        }
165    }
166
167    /// Adds the given rows, replacing any already shown for the same option.
168    pub fn upsert_local_entries(&mut self, entries: Vec<SettingsMenuEntry>) {
169        self.menu.upsert_local_entries(entries);
170    }
171
172    /// Adds the rows that open panes rather than pick a config value.
173    pub fn add_status_entries(&mut self) {
174        self.menu.upsert_pane_row(PaneKind::McpServers, &self.live.server_summary());
175        if !self.live.providers.is_empty() {
176            self.menu.upsert_pane_row(PaneKind::ProviderLogins, &self.live.provider_summary());
177        }
178    }
179
180    pub fn update_config_options(&mut self, options: &[LocalConfigOption]) {
181        self.current_reasoning_effort = reasoning_effort_of(options);
182        self.menu.update_options(options);
183    }
184
185    pub fn apply_change(&mut self, change: &SettingsChange) {
186        self.menu.apply_change(change);
187    }
188
189    pub fn update_server_statuses(&mut self, statuses: Vec<McpServerStatusEntry>) {
190        self.live.servers = statuses;
191        self.menu.upsert_pane_row(PaneKind::McpServers, &self.live.server_summary());
192        self.refresh_pane();
193    }
194
195    pub fn update_auth_methods(&mut self, methods: &[AuthMethod]) {
196        self.live.providers = build_provider_login_entries(methods);
197        self.menu.upsert_pane_row(PaneKind::ProviderLogins, &self.live.provider_summary());
198        self.refresh_pane();
199    }
200
201    pub fn on_authenticate_started(&mut self, method_id: &str) {
202        self.set_provider_status(method_id, ProviderLoginStatus::Authenticating);
203    }
204
205    pub fn on_authenticate_complete(&mut self, method_id: &str) {
206        self.set_provider_status(method_id, ProviderLoginStatus::LoggedIn);
207    }
208
209    pub fn on_authenticate_failed(&mut self, method_id: &str) {
210        self.set_provider_status(method_id, ProviderLoginStatus::NeedsLogin);
211    }
212
213    /// Queues the request for the overlay to answer in place, against the given
214    /// host handlers so tests observe URL opens without spawning a browser.
215    pub fn on_elicitation_request(
216        &mut self,
217        params: CreateElicitationRequest,
218        responder: Responder<CreateElicitationResponse>,
219        browser_opener: BrowserOpener,
220        clipboard_writer: ClipboardWriter,
221    ) {
222        self.pending_elicitation =
223            ElicitationModal::with_url_handlers(params, responder, browser_opener, clipboard_writer);
224    }
225
226    /// Drops an unanswered request, which cancels it.
227    pub fn cancel_pending_elicitation(&mut self) {
228        self.pending_elicitation = None;
229    }
230
231    /// Key hints for the current focus, shown in the overlay footer.
232    fn footer_hints(&self) -> Vec<KeyHint> {
233        if let Some(pending) = self.pending_elicitation.as_ref() {
234            return pending.key_hints();
235        }
236        self.pane
237            .as_ref()
238            .map_or_else(|| vec![("Enter", "select".into()), ("Esc", "close".into())], SettingsPane::footer)
239    }
240
241    fn set_provider_status(&mut self, method_id: &str, status: ProviderLoginStatus) {
242        if let Some(entry) = self.live.providers.iter_mut().find(|entry| entry.method_id == method_id) {
243            entry.status = status;
244        }
245        self.refresh_pane();
246    }
247
248    fn refresh_pane(&mut self) {
249        if let Some(pane) = self.pane.as_mut() {
250            pane.refresh(&self.live);
251        }
252    }
253
254    /// Draws whichever of the menu or the open pane has focus.
255    fn render_content(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
256        let Some(pane) = self.pane.as_mut() else {
257            self.menu.render(area, buf, cx.theme);
258            return None;
259        };
260        pane.render(area, buf, cx)
261    }
262
263    /// Splits `inner` into the rows the menu or pane keeps and the rows a
264    /// pending request takes at the bottom, separated by a blank line. The pane
265    /// keeps at least one row so the request never draws over it entirely.
266    fn split_for_prompt(&self, inner: Rect, theme: &Theme) -> (Rect, Option<Rect>) {
267        let Some(pending) = self.pending_elicitation.as_ref() else {
268            return (inner, None);
269        };
270        // The prompt lines up with the menu and pane rows, which carry their own
271        // leading space.
272        let width = inner.width.saturating_sub(1);
273        let height = pending.inline_height(theme, width).min(inner.height.saturating_sub(2));
274        if height == 0 {
275            return (inner, None);
276        }
277        let [content, _gap, prompt] =
278            Layout::vertical([Constraint::Min(1), Constraint::Length(1), Constraint::Length(height)]).areas(inner);
279        let [_indent, prompt] = Layout::horizontal([Constraint::Length(1), Constraint::Min(0)]).areas(prompt);
280        (content, Some(prompt))
281    }
282
283    /// Handles an event the menu itself owns, because no pane or request is open
284    /// above it.
285    fn on_menu_event(&mut self, event: &UiEvent) -> Vec<SettingsOutput> {
286        match event {
287            UiEvent::Key(key) if is_press(*key) => return self.on_menu_key(*key),
288            // The menu has no editor, so a paste has nowhere to land.
289            UiEvent::Key(_) | UiEvent::Paste(_) => {}
290            UiEvent::Mouse(MouseAction::ScrollUp, _) => self.menu.step(Direction::Backward),
291            UiEvent::Mouse(MouseAction::ScrollDown, _) => self.menu.step(Direction::Forward),
292            UiEvent::Mouse(MouseAction::Click, position) => {
293                if self.menu.click_at(position.1) {
294                    self.pane = self.open_selected_pane();
295                }
296            }
297        }
298        Vec::new()
299    }
300
301    fn on_menu_key(&mut self, key: KeyEvent) -> Vec<SettingsOutput> {
302        match key.code {
303            KeyCode::Esc => return vec![SettingsOutput::Close],
304            KeyCode::Up => self.menu.step(Direction::Backward),
305            KeyCode::Down => self.menu.step(Direction::Forward),
306            KeyCode::Enter => self.pane = self.open_selected_pane(),
307            _ => {}
308        }
309        Vec::new()
310    }
311
312    fn open_selected_pane(&self) -> Option<SettingsPane> {
313        let pane = match self.menu.selected_row()? {
314            MenuRow::Pane { kind: PaneKind::McpServers, .. } => {
315                SettingsPane::ServerStatus(ServerStatusPane::new(self.live.servers.clone()))
316            }
317            MenuRow::Pane { kind: PaneKind::ProviderLogins, .. } => {
318                SettingsPane::ProviderLogin(ProviderLoginPane::new(self.live.providers.clone()))
319            }
320            MenuRow::Select(entry) if entry.multi_select => SettingsPane::ModelSelector(ModelSelector::new(
321                entry.config_id.clone(),
322                entry.values.clone(),
323                &entry.current_raw_value,
324                self.current_reasoning_effort.as_deref(),
325            )),
326            MenuRow::Select(entry) => SettingsPane::Picker(SettingsPicker::from_entry(entry)?),
327        };
328        Some(pane)
329    }
330
331    /// Handles pane outputs locally: `Close` returns focus to the
332    /// menu, and config edits are mirrored into the menu on the way past so the
333    /// UI never lags the agent round-trip.
334    fn apply(&mut self, messages: Vec<SettingsOutput>) -> Vec<SettingsOutput> {
335        messages
336            .into_iter()
337            .filter(|message| {
338                if matches!(message, SettingsOutput::Close) && self.pane.is_some() {
339                    self.pane = None;
340                    return false;
341                }
342                if let Some(change) = config_edit(message) {
343                    self.menu.apply_change(&change);
344                }
345                true
346            })
347            .collect()
348    }
349}
350
351impl SettingsOverlay {
352    /// Routes every event to whichever of the three things inside the overlay
353    /// owns input, innermost first.
354    ///
355    /// One entry point rather than one override per event kind, because the
356    /// triage is the same for all of them — and because only routing them
357    /// together makes "a request closing returns focus to the pane behind it"
358    /// hold for a click as reliably as it does for a keystroke.
359    pub fn on_ui_event(&mut self, event: UiEvent) -> Vec<SettingsOutput> {
360        if let Some(pending) = self.pending_elicitation.as_mut() {
361            // Answering a request returns focus to the pane behind it rather
362            // than closing the overlay the request arrived in.
363            if pending.on_ui_event(event).iter().any(|action| matches!(action, ElicitationOutput::Close)) {
364                self.pending_elicitation = None;
365            }
366            return Vec::new();
367        }
368        let Some(pane) = self.pane.as_mut() else {
369            return self.on_menu_event(&event);
370        };
371        // Esc leaves the pane rather than the overlay, committing whatever the
372        // pane batched up while it was open.
373        let messages = match event {
374            UiEvent::Key(key) if is_press(key) && key.code == KeyCode::Esc => pane
375                .take_changes()
376                .map(|mut messages| {
377                    messages.push(SettingsOutput::Close);
378                    messages
379                })
380                .unwrap_or_default(),
381            event => pane.on_ui_event(event),
382        };
383        self.apply(messages)
384    }
385
386    /// A URL request wants the terminal's own text selection back, so the URL
387    /// can be copied by hand when opening a browser is not an option.
388    pub(crate) fn needs_mouse_capture(&self) -> bool {
389        self.pending_elicitation.as_ref().is_none_or(ElicitationModal::needs_mouse_capture)
390    }
391}
392
393impl SettingsOverlay {
394    pub fn render(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
395        let theme = cx.theme;
396        if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
397            Clear.render(area, buf);
398            Paragraph::new(Line::styled("(terminal too small)", Style::new().fg(theme.text_secondary)))
399                .render(area, buf);
400            return None;
401        }
402        let footer = key_hints(&self.footer_hints(), theme);
403        let frame = ModalFrame::new(
404            "Configuration",
405            Some(footer),
406            Constraint::Percentage(80),
407            Constraint::Percentage(80),
408            theme,
409        );
410        let inner = frame.inner(area);
411        (&frame).render(area, buf);
412
413        let (content, prompt) = self.split_for_prompt(inner, theme);
414        let cursor = self.render_content(content, buf, cx);
415
416        let (Some(prompt), Some(pending)) = (prompt, self.pending_elicitation.as_mut()) else {
417            return cursor;
418        };
419        pending.render_inline(prompt, buf, cx.theme);
420        None
421    }
422}
423
424impl LiveSettingsData {
425    fn server_summary(&self) -> String {
426        server_status::summary(&self.servers)
427    }
428
429    fn provider_summary(&self) -> String {
430        provider_login::summary(&self.providers)
431    }
432}
433
434fn reasoning_effort_of(options: &[LocalConfigOption]) -> Option<String> {
435    LocalConfigView::new(options).reasoning_effort().map(|effort| effort.as_str().to_string())
436}
437
438/// The outbound message for a config edit. Theme is a client-side setting, so it
439/// takes a different route than an agent config option.
440pub(crate) fn message_for_change(change: &SettingsChange) -> SettingsOutput {
441    if change.config_id == acp_utils::config_option_id::THEME_CONFIG_ID {
442        SettingsOutput::SetTheme(change.new_value.clone())
443    } else {
444        SettingsOutput::SetConfigOption { config_id: change.config_id.clone(), value: change.new_value.clone() }
445    }
446}
447
448/// The config edit `message` carries, when it is one — the inverse of
449/// [`message_for_change`], so the overlay can mirror edits into its own menu.
450fn config_edit(message: &SettingsOutput) -> Option<SettingsChange> {
451    match message {
452        SettingsOutput::SetTheme(value) => Some(SettingsChange {
453            config_id: acp_utils::config_option_id::THEME_CONFIG_ID.to_string(),
454            new_value: value.clone(),
455        }),
456        SettingsOutput::SetConfigOption { config_id, value } => {
457            Some(SettingsChange { config_id: config_id.clone(), new_value: value.clone() })
458        }
459        _ => None,
460    }
461}
462
463/// Joins the non-zero `(count, label)` buckets into "2 connected, 1 failed",
464/// falling back to `empty` when every bucket is zero.
465pub(crate) fn summarize(buckets: &[(usize, &str)], empty: &str) -> String {
466    let parts: Vec<String> =
467        buckets.iter().filter(|(count, _)| *count > 0).map(|(count, label)| format!("{count} {label}")).collect();
468    if parts.is_empty() { empty.to_string() } else { parts.join(", ") }
469}
470
471fn confirm_back_footer(enter_label: &'static str) -> Vec<KeyHint> {
472    vec![("Enter", enter_label.into()), ("Esc", "back".into())]
473}
474
475pub(crate) fn value_match_key(value: &SettingsMenuValue) -> String {
476    format!("{} {}", value.name, value.value)
477}