Skip to main content

atomcode_tuix/modals/
model_picker.rs

1// crates/atomcode-tuix/src/modals/model_picker.rs
2//
3// `/model` modal — provider list picker.
4//
5// Holds the provider list sorted alphabetically with the current default
6// first. Up/Down navigates, Enter selects (persists to config + notifies
7// agent), Esc cancels. Renders as a MenuPayload above the input box.
8
9use anyhow::Result;
10use atomcode_core::config::Config;
11use crossterm::event::{KeyCode, KeyModifiers};
12
13use super::{Modal, ModalAction};
14use crate::event_loop::{build_status, save_and_reload, Buffer, LoopCtx};
15use crate::render::{MenuPayload, Renderer, UiLine};
16use crate::state::UiState;
17
18pub struct ModelPicker {
19    pub providers: Vec<String>,
20    pub selected: usize,
21}
22
23impl ModelPicker {
24    pub fn open(config: &Config) -> Self {
25        let mut providers: Vec<String> = config.providers.keys().cloned().collect();
26        providers.sort();
27        // Put the current default at top for quick re-confirmation.
28        let cur = config.default_provider.clone();
29        if let Some(idx) = providers.iter().position(|p| *p == cur) {
30            providers.swap(0, idx);
31        }
32        Self {
33            providers,
34            selected: 0,
35        }
36    }
37}
38
39impl Modal for ModelPicker {
40    fn handle_key(
41        &mut self,
42        code: KeyCode,
43        _mods: KeyModifiers,
44        buf: &mut Buffer,
45        state: &mut UiState,
46        ctx: &mut LoopCtx,
47        renderer: &mut dyn Renderer,
48    ) -> Result<ModalAction> {
49        match code {
50            KeyCode::Up => {
51                self.selected = self.selected.saturating_sub(1);
52                self.draw(buf, state, ctx, renderer);
53                Ok(ModalAction::Continue)
54            }
55            KeyCode::Down => {
56                let max = self.providers.len().saturating_sub(1);
57                if self.selected < max {
58                    self.selected += 1;
59                }
60                self.draw(buf, state, ctx, renderer);
61                Ok(ModalAction::Continue)
62            }
63            KeyCode::Enter => {
64                let chosen = self.providers[self.selected].clone();
65                let display = ctx
66                    .config
67                    .providers
68                    .get(&chosen)
69                    .map(|p| p.model.clone())
70                    .unwrap_or_else(|| chosen.clone());
71                ctx.config.default_provider = chosen.clone();
72                ctx.model_name = display.clone();
73                // Persist to config.toml + notify agent. Without this,
74                // the switch lives only in memory and the next startup
75                // reverts to whatever was last saved.
76                save_and_reload(ctx, renderer);
77                // Clear any stale drift warning tied to the PREVIOUS
78                // active provider — if the new provider is also CodingPlan,
79                // the re-fire below will repopulate the slot with a
80                // correct warning (or leave it clean).
81                if let Ok(mut g) = ctx.monitor_warning.lock() {
82                    *g = None;
83                }
84                // Same treatment for the usage slot: switching providers
85                // invalidates the previous CodingPlan's quota snapshot.
86                // Clearing here makes `build_usage_hint` short-circuit to
87                // None until a fresh fetch lands; otherwise the user would
88                // briefly see the OLD plan's percent on the new provider.
89                if let Ok(mut g) = ctx.usage_slot.lock() {
90                    *g = None;
91                }
92                // Re-fire the drift check if the new provider is also
93                // CodingPlan-managed. Bypasses the 15-min cooldown on
94                // purpose — explicit user action deserves a fresh read.
95                if crate::event_loop::monitor::is_codingplan_provider(&chosen) {
96                    ctx.monitor_last_check_at = Some(std::time::Instant::now());
97                    crate::event_loop::monitor::spawn_check(
98                        ctx.config.clone(),
99                        ctx.model_name.clone(),
100                        ctx.monitor_warning.clone(),
101                        ctx.wake_tx.clone(),
102                    );
103                    // Mirror: re-fire usage check too. 30s cooldown is
104                    // bypassed because the user just made an explicit
105                    // switch — they want fresh data, not stale 30s data.
106                    ctx.usage_last_check_at = Some(std::time::Instant::now());
107                    crate::event_loop::usage_monitor::spawn_check(
108                        ctx.usage_slot.clone(),
109                        ctx.wake_tx.clone(),
110                    );
111                }
112                renderer.render(UiLine::CommandOutput(
113                    crate::i18n::t(crate::i18n::Msg::ModelSwitched { provider: &chosen, model: &display }).into_owned(),
114                ));
115                renderer.flush();
116                Ok(ModalAction::Close)
117            }
118            KeyCode::Esc => Ok(ModalAction::Close),
119            _ => Ok(ModalAction::Continue),
120        }
121    }
122
123    fn draw(&self, buf: &Buffer, state: &UiState, ctx: &LoopCtx, renderer: &mut dyn Renderer) {
124        let items: Vec<(String, String)> = self
125            .providers
126            .iter()
127            .map(|name| {
128                let desc = ctx
129                    .config
130                    .providers
131                    .get(name)
132                    .map(|c| format!("{} · {}", c.provider_type, c.model))
133                    .unwrap_or_default();
134                (name.clone(), desc)
135            })
136            .collect();
137        let payload = MenuPayload {
138            items,
139            selected: self.selected,
140            kind: crate::render::MenuKind::SlashCommand,
141        };
142        renderer.render(UiLine::InputPrompt {
143            buf: buf.text.clone(),
144            cursor_byte: buf.cursor,
145            menu: Some(payload),
146            status: build_status(state, ctx),
147            attachments: Vec::new(),
148        });
149        renderer.flush();
150    }
151}