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 pub local: bool,
62}
63
64#[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
80pub struct SettingsOverlay {
82 menu: SettingsMenu,
83 pane: Option<SettingsPane>,
85 current_reasoning_effort: Option<String>,
86 live: LiveSettingsData,
87 pending_elicitation: Option<ElicitationModal>,
91}
92
93pub(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 pub fn upsert_local_entries(&mut self, entries: Vec<SettingsMenuEntry>) {
169 self.menu.upsert_local_entries(entries);
170 }
171
172 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 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 pub fn cancel_pending_elicitation(&mut self) {
228 self.pending_elicitation = None;
229 }
230
231 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 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 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 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 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 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 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 pub fn on_ui_event(&mut self, event: UiEvent) -> Vec<SettingsOutput> {
360 if let Some(pending) = self.pending_elicitation.as_mut() {
361 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 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 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
438pub(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
448fn 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
463pub(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}