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;
10pub(crate) use self::provider_login::ProviderLoginStatus;
11use self::provider_login::{ProviderLoginEntry, ProviderLoginPane, build_provider_login_entries};
12use self::server_status::ServerStatusPane;
13use crate::renderer::DrawContext;
14use crate::session::platform::{BrowserOpener, ClipboardWriter};
15use crate::session::session_config_view::{LocalConfigOption, LocalConfigValue, LocalConfigView};
16use crate::surfaces::input::UiOutput;
17use crate::surfaces::modal::{ElicitationModal, frame::ModalFrame};
18use crate::theme::Theme;
19use crate::view::input::{Nav, UiEvent};
20use crate::view::widgets::key_hints;
21use acp_utils::notifications::McpServerStatusEntry;
22use agent_client_protocol::Responder;
23use agent_client_protocol::schema::v2::{AuthMethod, CreateElicitationRequest, CreateElicitationResponse};
24use crossterm::event::KeyCode;
25use ratatui::buffer::Buffer;
26use ratatui::layout::{Constraint, Layout, Position, Rect};
27use ratatui::text::Line;
28use ratatui::widgets::{Clear, Paragraph, Widget};
29
30const MIN_WIDTH: u16 = 6;
31const MIN_HEIGHT: u16 = 3;
32
33#[derive(Debug)]
34pub struct SettingsChange {
35 pub config_id: String,
36 pub new_value: String,
37}
38
39#[derive(Debug, Clone)]
40pub struct SettingsMenuEntry {
41 pub config_id: String,
42 pub title: String,
43 pub values: Vec<LocalConfigValue>,
44 pub current_raw_value: String,
45 pub multi_select: bool,
46 pub local: bool,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum PaneKind {
54 McpServers,
55 ProviderLogins,
56}
57
58impl PaneKind {
59 pub(crate) fn title(self) -> &'static str {
60 match self {
61 Self::McpServers => "MCP Servers",
62 Self::ProviderLogins => "Provider Logins",
63 }
64 }
65
66 fn summary(self, live: &LiveSettingsData) -> String {
67 match self {
68 Self::McpServers => server_status::summary(&live.servers),
69 Self::ProviderLogins => provider_login::summary(&live.providers),
70 }
71 }
72}
73
74pub struct SettingsOverlay {
76 menu: SettingsMenu,
77 pane: Option<SettingsPane>,
79 current_reasoning_effort: Option<String>,
80 live: LiveSettingsData,
81 pending_elicitation: Option<ElicitationModal>,
85}
86
87pub(crate) struct LiveSettingsData {
90 pub(crate) servers: Vec<McpServerStatusEntry>,
91 pub(crate) providers: Vec<ProviderLoginEntry>,
92}
93
94pub(crate) use crate::view::widgets::KeyHint;
95
96enum SettingsPane {
97 ServerStatus(ServerStatusPane),
98 ProviderLogin(ProviderLoginPane),
99 ModelSelector(ModelSelector),
100 Picker(SettingsPicker),
101}
102
103impl SettingsPane {
104 pub(crate) fn on_ui_event(&mut self, event: &UiEvent) -> Vec<UiOutput> {
105 match self {
106 Self::ServerStatus(pane) => pane.on_ui_event(event),
107 Self::ProviderLogin(pane) => pane.on_ui_event(event),
108 Self::ModelSelector(pane) => pane.on_ui_event(event),
109 Self::Picker(pane) => pane.on_ui_event(event),
110 }
111 }
112
113 pub(crate) fn footer(&self) -> Vec<KeyHint> {
114 match self {
115 Self::ServerStatus(_) => confirm_back_footer("authenticate OAuth servers"),
116 Self::ProviderLogin(_) => confirm_back_footer("authenticate"),
117 Self::ModelSelector(pane) => pane.footer(),
118 Self::Picker(_) => confirm_back_footer("confirm"),
119 }
120 }
121
122 fn refresh(&mut self, live: &LiveSettingsData) {
123 match self {
124 Self::ServerStatus(pane) => pane.refresh(live),
125 Self::ProviderLogin(pane) => pane.refresh(live),
126 Self::ModelSelector(_) | Self::Picker(_) => {}
127 }
128 }
129
130 fn take_changes(&mut self) -> Option<Vec<UiOutput>> {
131 match self {
132 Self::ServerStatus(_) | Self::ProviderLogin(_) | Self::Picker(_) => Some(Vec::new()),
133 Self::ModelSelector(pane) => pane.take_changes(),
134 }
135 }
136
137 fn render(&mut self, area: Rect, buf: &mut Buffer, theme: &Theme) {
138 match self {
139 Self::ServerStatus(pane) => pane.render(area, buf, theme),
140 Self::ProviderLogin(pane) => pane.render(area, buf, theme),
141 Self::ModelSelector(pane) => pane.render(area, buf, theme),
142 Self::Picker(pane) => pane.render(area, buf, theme),
143 }
144 }
145}
146impl SettingsOverlay {
147 pub fn new(
148 config_options: &[LocalConfigOption],
149 server_statuses: Vec<McpServerStatusEntry>,
150 auth_methods: &[AuthMethod],
151 ) -> Self {
152 let mut overlay = Self {
153 menu: SettingsMenu::from_config_options(config_options),
154 pane: None,
155 current_reasoning_effort: reasoning_effort_of(config_options),
156 live: LiveSettingsData { servers: server_statuses, providers: build_provider_login_entries(auth_methods) },
157 pending_elicitation: None,
158 };
159 overlay.sync_pane_rows();
160 overlay
161 }
162
163 pub fn upsert_local_entries(&mut self, entries: Vec<SettingsMenuEntry>) {
165 self.menu.upsert_local_entries(entries);
166 }
167
168 pub fn update_config_options(&mut self, options: &[LocalConfigOption]) {
169 self.current_reasoning_effort = reasoning_effort_of(options);
170 self.menu.update_options(options);
171 }
172
173 pub fn apply_change(&mut self, change: &SettingsChange) {
174 self.menu.apply_change(change);
175 }
176
177 pub fn update_server_statuses(&mut self, statuses: Vec<McpServerStatusEntry>) {
178 self.live.servers = statuses;
179 self.refresh_pane();
180 }
181
182 pub fn update_auth_methods(&mut self, methods: &[AuthMethod]) {
183 self.live.providers = build_provider_login_entries(methods);
184 self.sync_pane_rows();
185 self.refresh_pane();
186 }
187
188 pub(crate) fn set_provider_status(&mut self, method_id: &str, status: ProviderLoginStatus) {
189 if let Some(entry) = self.live.providers.iter_mut().find(|entry| entry.method_id == method_id) {
190 entry.status = status;
191 }
192 self.refresh_pane();
193 }
194
195 pub fn on_elicitation_request(
198 &mut self,
199 params: CreateElicitationRequest,
200 responder: Responder<CreateElicitationResponse>,
201 browser_opener: BrowserOpener,
202 clipboard_writer: ClipboardWriter,
203 ) {
204 self.pending_elicitation =
205 ElicitationModal::with_url_handlers(params, responder, browser_opener, clipboard_writer);
206 }
207
208 pub fn cancel_pending_elicitation(&mut self) {
210 self.pending_elicitation = None;
211 }
212
213 fn footer_hints(&self) -> Vec<KeyHint> {
215 if let Some(pending) = self.pending_elicitation.as_ref() {
216 return pending.key_hints();
217 }
218 self.pane
219 .as_ref()
220 .map_or_else(|| vec![("Enter", "select".into()), ("Esc", "close".into())], SettingsPane::footer)
221 }
222
223 fn sync_pane_rows(&mut self) {
224 self.menu.show_pane_row(PaneKind::McpServers, true);
225 self.menu.show_pane_row(PaneKind::ProviderLogins, !self.live.providers.is_empty());
226 }
227
228 fn refresh_pane(&mut self) {
229 if let Some(pane) = self.pane.as_mut() {
230 pane.refresh(&self.live);
231 }
232 }
233
234 fn render_content(&mut self, area: Rect, buf: &mut Buffer, theme: &Theme) {
235 match self.pane.as_mut() {
236 Some(pane) => pane.render(area, buf, theme),
237 None => self.menu.render(area, buf, theme, &self.live),
238 }
239 }
240
241 fn split_for_prompt(&self, inner: Rect, theme: &Theme) -> (Rect, Option<Rect>) {
245 let Some(pending) = self.pending_elicitation.as_ref() else {
246 return (inner, None);
247 };
248 let width = inner.width.saturating_sub(1);
251 let height = pending.inline_height(theme, width).min(inner.height.saturating_sub(2));
252 if height == 0 {
253 return (inner, None);
254 }
255 let [content, _gap, prompt] =
256 Layout::vertical([Constraint::Min(1), Constraint::Length(1), Constraint::Length(height)]).areas(inner);
257 let [_indent, prompt] = Layout::horizontal([Constraint::Length(1), Constraint::Min(0)]).areas(prompt);
258 (content, Some(prompt))
259 }
260
261 fn on_menu_event(&mut self, event: &UiEvent) -> Vec<UiOutput> {
264 match self.menu.selection.on_nav_event(event, self.menu.rows.len(), |_| true) {
265 Nav::Close => return vec![UiOutput::Close],
266 Nav::Activate | Nav::Clicked => self.pane = self.open_selected_pane(),
267 Nav::Moved | Nav::Unhandled => {}
268 }
269 Vec::new()
270 }
271
272 fn open_selected_pane(&self) -> Option<SettingsPane> {
273 let pane = match self.menu.selected_row()? {
274 MenuRow::Pane(PaneKind::McpServers) => {
275 SettingsPane::ServerStatus(ServerStatusPane::new(self.live.servers.clone()))
276 }
277 MenuRow::Pane(PaneKind::ProviderLogins) => {
278 SettingsPane::ProviderLogin(ProviderLoginPane::new(self.live.providers.clone()))
279 }
280 MenuRow::Select(entry) if entry.multi_select => SettingsPane::ModelSelector(ModelSelector::new(
281 entry.config_id.clone(),
282 entry.values.clone(),
283 &entry.current_raw_value,
284 self.current_reasoning_effort.as_deref(),
285 )),
286 MenuRow::Select(entry) => SettingsPane::Picker(SettingsPicker::from_entry(entry)?),
287 };
288 Some(pane)
289 }
290
291 fn apply(&mut self, outputs: Vec<UiOutput>) -> Vec<UiOutput> {
292 outputs
293 .into_iter()
294 .filter(|output| match output {
295 UiOutput::Close if self.pane.is_some() => {
296 self.pane = None;
297 false
298 }
299 _ => true,
300 })
301 .collect()
302 }
303}
304
305impl SettingsOverlay {
306 pub fn on_ui_event(&mut self, event: &UiEvent) -> Vec<UiOutput> {
314 if let Some(pending) = self.pending_elicitation.as_mut() {
315 if pending.on_ui_event(event).iter().any(|output| matches!(output, UiOutput::Close)) {
318 self.pending_elicitation = None;
319 }
320 return Vec::new();
321 }
322 let Some(pane) = self.pane.as_mut() else {
323 return self.on_menu_event(event);
324 };
325 let outputs = match event {
328 UiEvent::Key(key) if key.code == KeyCode::Esc => pane
329 .take_changes()
330 .map(|mut outputs| {
331 outputs.push(UiOutput::Close);
332 outputs
333 })
334 .unwrap_or_default(),
335 event => pane.on_ui_event(event),
336 };
337 self.apply(outputs)
338 }
339
340 pub(crate) fn needs_mouse_capture(&self) -> bool {
343 self.pending_elicitation.as_ref().is_none_or(ElicitationModal::needs_mouse_capture)
344 }
345}
346
347impl SettingsOverlay {
348 pub fn render(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
349 let theme = cx.theme;
350 if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
351 Clear.render(area, buf);
352 Paragraph::new(Line::styled("(terminal too small)", theme.text_secondary)).render(area, buf);
353 return None;
354 }
355 let footer = key_hints(&self.footer_hints(), theme);
356 let frame = ModalFrame::new(
357 "Configuration",
358 Some(footer),
359 Constraint::Percentage(80),
360 Constraint::Percentage(80),
361 theme,
362 );
363 let inner = frame.inner(area);
364 (&frame).render(area, buf);
365
366 let (content, prompt) = self.split_for_prompt(inner, theme);
367 self.render_content(content, buf, theme);
368
369 let (Some(prompt), Some(pending)) = (prompt, self.pending_elicitation.as_mut()) else {
370 return None;
371 };
372 pending.render_inline(prompt, buf, theme)
373 }
374}
375
376fn reasoning_effort_of(options: &[LocalConfigOption]) -> Option<String> {
377 LocalConfigView::new(options).reasoning_effort().map(|effort| effort.as_str().to_string())
378}
379
380pub(crate) fn summarize(buckets: &[(usize, &str)], empty: &str) -> String {
383 let parts: Vec<String> =
384 buckets.iter().filter(|(count, _)| *count > 0).map(|(count, label)| format!("{count} {label}")).collect();
385 if parts.is_empty() { empty.to_string() } else { parts.join(", ") }
386}
387
388fn confirm_back_footer(enter_label: &'static str) -> Vec<KeyHint> {
389 vec![("Enter", enter_label.into()), ("Esc", "back".into())]
390}
391
392pub(crate) fn value_match_key(value: &LocalConfigValue) -> String {
393 format!("{} {}", value.name, value.value)
394}