返回 DeepSeek-TUI-2026
model_picker.rs
根目录 / crates / tui / src / tui / model_picker.rs
1 //! `/model` picker modal: pick a DeepSeek model and a thinking-effort tier
2 //! and apply both at once (#39).
3 //!
4 //! Two side-by-side panes — Models on the left, Thinking effort on the
5 //! right. Tab swaps focus, ↑/↓ moves within the focused pane, Enter applies
6 //! both and closes the modal, Esc cancels.
7 //!
8 //! The effort pane intentionally only exposes `Off / High / Max`. Per
9 //! DeepSeek's [Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model),
10 //! `low`/`medium` are silently mapped to `high` server-side and `xhigh` is
11 //! mapped to `max`, so surfacing them as separate choices would be misleading.
12 //! The legacy variants remain valid in `~/.deepseek/settings.toml` for
13 //! back-compat — the picker just doesn't offer them.
14 //!
15 //! On apply we emit a [`ViewEvent::ModelPickerApplied`] with the resolved
16 //! model id and effort tier; the UI handler updates `App` state, persists
17 //! the choice via `Settings`, and forwards `Op::SetModel` so the running
18 //! engine picks up the change without a restart.
19
20 use crossterm::event::{KeyCode, KeyEvent};
21 use ratatui::{
22 buffer::Buffer,
23 layout::{Constraint, Direction, Layout, Rect},
24 prelude::Stylize,
25 style::{Modifier, Style},
26 text::{Line, Span},
27 widgets::{Block, Borders, Clear, Paragraph, Widget},
28 };
29
30 use crate::palette;
31 use crate::tui::app::{App, ReasoningEffort};
32 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
33
34 /// Models the picker exposes by default. Kept short on purpose — power
35 /// users can still type `/model <id>` for anything else.
36 const PICKER_MODELS: &[(&str, &str)] = &[
37 ("auto", "select per turn"),
38 ("deepseek-v4-pro", "flagship"),
39 ("deepseek-v4-flash", "fast / cheap"),
40 ];
41
42 /// Thinking-effort rows shown in the picker, in the order DeepSeek
43 /// behaviorally distinguishes them.
44 const PICKER_EFFORTS: &[ReasoningEffort] = &[
45 ReasoningEffort::Auto,
46 ReasoningEffort::Off,
47 ReasoningEffort::High,
48 ReasoningEffort::Max,
49 ];
50
51 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
52 enum Pane {
53 Model,
54 Effort,
55 }
56
57 pub struct ModelPickerView {
58 initial_model: String,
59 initial_effort: ReasoningEffort,
60 /// Working selection (separate from the initial values so we can offer a
61 /// clean Esc-to-cancel without mutating App state).
62 selected_model_idx: usize,
63 selected_effort_idx: usize,
64 focus: Pane,
65 /// True when the active model is one we don't list — we still show it
66 /// so the picker doesn't quietly forget the user's chosen IDs.
67 show_custom_model_row: bool,
68 }
69
70 impl ModelPickerView {
71 #[must_use]
72 pub fn new(app: &App) -> Self {
73 let initial_model = if app.auto_model {
74 "auto".to_string()
75 } else {
76 app.model.clone()
77 };
78 let mut selected_model_idx = PICKER_MODELS
79 .iter()
80 .position(|(id, _)| *id == initial_model);
81 let show_custom_model_row = selected_model_idx.is_none();
82 if show_custom_model_row {
83 // Custom row sits at the end; precompute its index.
84 selected_model_idx = Some(PICKER_MODELS.len());
85 }
86 let selected_model_idx = selected_model_idx.unwrap_or(0);
87
88 let initial_effort = app.reasoning_effort;
89 // Map low/medium → high, xhigh → max for picker purposes.
90 let normalized = match initial_effort {
91 ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High,
92 other => other,
93 };
94 let selected_effort_idx = PICKER_EFFORTS
95 .iter()
96 .position(|e| *e == normalized)
97 .unwrap_or(2); // default to High if somehow unknown
98
99 Self {
100 initial_model,
101 initial_effort,
102 selected_model_idx,
103 selected_effort_idx,
104 focus: Pane::Model,
105 show_custom_model_row,
106 }
107 }
108
109 fn model_row_count(&self) -> usize {
110 PICKER_MODELS.len() + if self.show_custom_model_row { 1 } else { 0 }
111 }
112
113 /// Resolve the currently highlighted model row to a model id. If the
114 /// custom row is selected we return the original model from the App so
115 /// "Apply" doesn't blow away an unrecognised id.
116 fn resolved_model(&self) -> String {
117 if self.show_custom_model_row && self.selected_model_idx == PICKER_MODELS.len() {
118 self.initial_model.clone()
119 } else {
120 PICKER_MODELS[self.selected_model_idx].0.to_string()
121 }
122 }
123
124 fn resolved_effort(&self) -> ReasoningEffort {
125 if self.resolved_model().trim().eq_ignore_ascii_case("auto") {
126 return ReasoningEffort::Auto;
127 }
128 PICKER_EFFORTS[self.selected_effort_idx]
129 }
130
131 fn move_up(&mut self) {
132 match self.focus {
133 Pane::Model => {
134 if self.selected_model_idx > 0 {
135 self.selected_model_idx -= 1;
136 }
137 }
138 Pane::Effort => {
139 if self.selected_effort_idx > 0 {
140 self.selected_effort_idx -= 1;
141 }
142 }
143 }
144 }
145
146 fn move_down(&mut self) {
147 match self.focus {
148 Pane::Model => {
149 let max = self.model_row_count().saturating_sub(1);
150 if self.selected_model_idx < max {
151 self.selected_model_idx += 1;
152 }
153 }
154 Pane::Effort => {
155 let max = PICKER_EFFORTS.len().saturating_sub(1);
156 if self.selected_effort_idx < max {
157 self.selected_effort_idx += 1;
158 }
159 }
160 }
161 }
162
163 fn toggle_focus(&mut self) {
164 self.focus = match self.focus {
165 Pane::Model => Pane::Effort,
166 Pane::Effort => Pane::Model,
167 };
168 }
169
170 fn build_event(&self) -> ViewEvent {
171 ViewEvent::ModelPickerApplied {
172 model: self.resolved_model(),
173 effort: self.resolved_effort(),
174 previous_model: self.initial_model.clone(),
175 previous_effort: self.initial_effort,
176 }
177 }
178
179 fn render_pane(
180 &self,
181 area: Rect,
182 buf: &mut Buffer,
183 title: &str,
184 rows: Vec<(String, String)>,
185 selected: usize,
186 focused: bool,
187 ) {
188 let border_style = if focused {
189 Style::default().fg(palette::DEEPSEEK_SKY)
190 } else {
191 Style::default().fg(palette::BORDER_COLOR)
192 };
193 let block = Block::default()
194 .title(Line::from(Span::styled(
195 format!(" {title} "),
196 Style::default().fg(palette::TEXT_PRIMARY).bold(),
197 )))
198 .borders(Borders::ALL)
199 .border_style(border_style)
200 .style(Style::default());
201 let inner = block.inner(area);
202 block.render(area, buf);
203
204 let mut lines = Vec::with_capacity(rows.len());
205 for (idx, (label, hint)) in rows.iter().enumerate() {
206 let is_selected = idx == selected;
207 let marker = if is_selected { "▸" } else { " " };
208 let label_style = if is_selected {
209 Style::default()
210 .fg(palette::SELECTION_TEXT)
211 .bg(palette::SELECTION_BG)
212 .add_modifier(Modifier::BOLD)
213 } else {
214 Style::default().fg(palette::TEXT_PRIMARY)
215 };
216 let hint_style = if is_selected {
217 Style::default()
218 .fg(palette::SELECTION_TEXT)
219 .bg(palette::SELECTION_BG)
220 } else {
221 Style::default().fg(palette::TEXT_MUTED)
222 };
223 let mut spans = vec![
224 Span::raw(" "),
225 Span::styled(marker, label_style),
226 Span::raw(" "),
227 Span::styled(label.clone(), label_style),
228 ];
229 if !hint.is_empty() {
230 spans.push(Span::raw(" "));
231 spans.push(Span::styled(format!("({hint})"), hint_style));
232 }
233 lines.push(Line::from(spans));
234 }
235 Paragraph::new(lines).render(inner, buf);
236 }
237 }
238
239 impl ModalView for ModelPickerView {
240 fn kind(&self) -> ModalKind {
241 ModalKind::ModelPicker
242 }
243
244 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
245 self
246 }
247
248 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
249 match key.code {
250 KeyCode::Esc => ViewAction::Close,
251 KeyCode::Enter => ViewAction::EmitAndClose(self.build_event()),
252 KeyCode::Up => {
253 self.move_up();
254 ViewAction::None
255 }
256 KeyCode::Down => {
257 self.move_down();
258 ViewAction::None
259 }
260 KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => {
261 self.toggle_focus();
262 ViewAction::None
263 }
264 _ => ViewAction::None,
265 }
266 }
267
268 fn render(&self, area: Rect, buf: &mut Buffer) {
269 let popup_width = 64.min(area.width.saturating_sub(4)).max(40);
270 let popup_height = 14.min(area.height.saturating_sub(4)).max(10);
271 let popup_area = Rect {
272 x: area.x + (area.width.saturating_sub(popup_width)) / 2,
273 y: area.y + (area.height.saturating_sub(popup_height)) / 2,
274 width: popup_width,
275 height: popup_height,
276 };
277
278 Clear.render(popup_area, buf);
279
280 // Outer chrome with title + footer hint.
281 let outer = Block::default()
282 .title(Line::from(Span::styled(
283 " Model & thinking ",
284 Style::default()
285 .fg(palette::DEEPSEEK_SKY)
286 .add_modifier(Modifier::BOLD),
287 )))
288 .title_bottom(Line::from(vec![
289 Span::styled(" ↑↓ ", Style::default().fg(palette::TEXT_MUTED)),
290 Span::raw("move "),
291 Span::styled(" Tab ", Style::default().fg(palette::TEXT_MUTED)),
292 Span::raw("switch "),
293 Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)),
294 Span::raw("apply "),
295 Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)),
296 Span::raw("cancel "),
297 ]))
298 .borders(Borders::ALL)
299 .border_style(Style::default().fg(palette::BORDER_COLOR))
300 .style(Style::default());
301 let inner = outer.inner(popup_area);
302 outer.render(popup_area, buf);
303
304 let columns = Layout::default()
305 .direction(Direction::Horizontal)
306 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
307 .split(inner);
308
309 let mut model_rows: Vec<(String, String)> = PICKER_MODELS
310 .iter()
311 .map(|(id, hint)| ((*id).to_string(), (*hint).to_string()))
312 .collect();
313 if self.show_custom_model_row {
314 model_rows.push((self.initial_model.clone(), "current (custom)".to_string()));
315 }
316 self.render_pane(
317 columns[0],
318 buf,
319 "Model",
320 model_rows,
321 self.selected_model_idx,
322 self.focus == Pane::Model,
323 );
324
325 let effort_rows: Vec<(String, String)> = PICKER_EFFORTS
326 .iter()
327 .map(|effort| {
328 let label = effort.short_label().to_string();
329 let hint = match effort {
330 ReasoningEffort::Auto => "auto-select per turn".to_string(),
331 ReasoningEffort::Off => "thinking disabled".to_string(),
332 ReasoningEffort::High => "thinking enabled (default)".to_string(),
333 ReasoningEffort::Max => "thinking enabled, max effort".to_string(),
334 _ => String::new(),
335 };
336 (label, hint)
337 })
338 .collect();
339 self.render_pane(
340 columns[1],
341 buf,
342 "Thinking",
343 effort_rows,
344 self.selected_effort_idx,
345 self.focus == Pane::Effort,
346 );
347 }
348 }
349
350 #[cfg(test)]
351 mod tests {
352 use super::*;
353 use crate::config::Config;
354 use crate::tui::app::{App, TuiOptions};
355 use std::path::PathBuf;
356
357 fn create_test_app() -> App {
358 let options = TuiOptions {
359 model: "deepseek-v4-pro".to_string(),
360 workspace: PathBuf::from("."),
361 config_path: None,
362 config_profile: None,
363 allow_shell: false,
364 use_alt_screen: true,
365 use_mouse_capture: false,
366 use_bracketed_paste: true,
367 max_subagents: 1,
368 skills_dir: PathBuf::from("."),
369 memory_path: PathBuf::from("memory.md"),
370 notes_path: PathBuf::from("notes.txt"),
371 mcp_config_path: PathBuf::from("mcp.json"),
372 use_memory: false,
373 start_in_agent_mode: true,
374 skip_onboarding: true,
375 yolo: false,
376 resume_session_id: None,
377 initial_input: None,
378 };
379 let mut app = App::new(options, &Config::default());
380 // App::new merges in `~/.config/deepseek/settings.toml` /
381 // `Application Support/deepseek/settings.toml`, which can override
382 // the model and effort with whatever the developer happens to have
383 // saved. Pin both back to known values so the picker tests below
384 // exercise the picker logic, not the user's environment.
385 app.model = "deepseek-v4-pro".to_string();
386 app.reasoning_effort = ReasoningEffort::Max;
387 app
388 }
389
390 #[test]
391 fn picker_initial_selection_matches_app_state() {
392 let mut app = create_test_app();
393 app.model = "deepseek-v4-flash".to_string();
394 app.reasoning_effort = ReasoningEffort::Max;
395 let view = ModelPickerView::new(&app);
396 assert_eq!(view.resolved_model(), "deepseek-v4-flash");
397 assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
398 }
399
400 #[test]
401 fn picker_initial_selection_matches_auto_state() {
402 let mut app = create_test_app();
403 app.model = "auto".to_string();
404 app.auto_model = true;
405 app.reasoning_effort = ReasoningEffort::Auto;
406
407 let view = ModelPickerView::new(&app);
408
409 assert_eq!(view.resolved_model(), "auto");
410 assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
411 }
412
413 #[test]
414 fn picker_auto_model_forces_auto_effort_on_apply() {
415 let mut app = create_test_app();
416 app.model = "auto".to_string();
417 app.auto_model = true;
418 app.reasoning_effort = ReasoningEffort::Off;
419
420 let mut view = ModelPickerView::new(&app);
421 view.selected_model_idx = 0;
422 view.selected_effort_idx = PICKER_EFFORTS
423 .iter()
424 .position(|effort| *effort == ReasoningEffort::Max)
425 .expect("max effort row");
426
427 assert_eq!(view.resolved_model(), "auto");
428 assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
429 }
430
431 #[test]
432 fn picker_normalizes_low_medium_to_high() {
433 let mut app = create_test_app();
434 app.reasoning_effort = ReasoningEffort::Medium;
435 let view = ModelPickerView::new(&app);
436 assert_eq!(
437 view.resolved_effort(),
438 ReasoningEffort::High,
439 "medium should map to high in the picker"
440 );
441 }
442
443 #[test]
444 fn picker_exposes_auto_and_distinct_thinking_tiers() {
445 let model_labels: Vec<_> = PICKER_MODELS.iter().map(|(id, _)| *id).collect();
446 assert_eq!(
447 model_labels,
448 vec!["auto", "deepseek-v4-pro", "deepseek-v4-flash"]
449 );
450
451 let effort_labels: Vec<_> = PICKER_EFFORTS
452 .iter()
453 .map(|effort| effort.as_setting())
454 .collect();
455 assert_eq!(effort_labels, vec!["auto", "off", "high", "max"]);
456 }
457
458 #[test]
459 fn picker_preserves_unknown_model_via_custom_row() {
460 let mut app = create_test_app();
461 app.model = "deepseek-v4-pro-2026-04-XX".to_string();
462 let view = ModelPickerView::new(&app);
463 assert!(view.show_custom_model_row);
464 assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
465 }
466
467 #[test]
468 fn arrow_keys_move_within_focused_pane() {
469 let app = create_test_app();
470 let mut view = ModelPickerView::new(&app);
471 // Default focus is Model; move down then up.
472 let initial = view.selected_model_idx;
473 view.handle_key(KeyEvent::new(
474 KeyCode::Down,
475 crossterm::event::KeyModifiers::NONE,
476 ));
477 assert_eq!(view.selected_model_idx, initial + 1);
478 view.handle_key(KeyEvent::new(
479 KeyCode::Up,
480 crossterm::event::KeyModifiers::NONE,
481 ));
482 assert_eq!(view.selected_model_idx, initial);
483 }
484
485 #[test]
486 fn tab_switches_focus_and_arrow_now_moves_effort() {
487 let mut app = create_test_app();
488 // Default is Max; pin to Off so the Down arrow has
489 // somewhere to go.
490 app.reasoning_effort = ReasoningEffort::Off;
491 let mut view = ModelPickerView::new(&app);
492 let initial_effort_idx = view.selected_effort_idx;
493 view.handle_key(KeyEvent::new(
494 KeyCode::Tab,
495 crossterm::event::KeyModifiers::NONE,
496 ));
497 assert_eq!(view.focus, Pane::Effort);
498 view.handle_key(KeyEvent::new(
499 KeyCode::Down,
500 crossterm::event::KeyModifiers::NONE,
501 ));
502 assert!(view.selected_effort_idx > initial_effort_idx);
503 }
504
505 #[test]
506 fn enter_emits_apply_event_with_selection() {
507 let mut app = create_test_app();
508 app.reasoning_effort = ReasoningEffort::High;
509 let mut view = ModelPickerView::new(&app);
510 view.handle_key(KeyEvent::new(
511 KeyCode::Tab,
512 crossterm::event::KeyModifiers::NONE,
513 ));
514 view.handle_key(KeyEvent::new(
515 KeyCode::Down,
516 crossterm::event::KeyModifiers::NONE,
517 ));
518 let action = view.handle_key(KeyEvent::new(
519 KeyCode::Enter,
520 crossterm::event::KeyModifiers::NONE,
521 ));
522 match action {
523 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
524 model,
525 effort,
526 previous_effort,
527 ..
528 }) => {
529 assert_eq!(model, "deepseek-v4-pro");
530 assert_eq!(effort, ReasoningEffort::Max);
531 assert_eq!(previous_effort, ReasoningEffort::High);
532 }
533 other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
534 }
535 }
536
537 #[test]
538 fn esc_closes_without_emitting() {
539 let app = create_test_app();
540 let mut view = ModelPickerView::new(&app);
541 let action = view.handle_key(KeyEvent::new(
542 KeyCode::Esc,
543 crossterm::event::KeyModifiers::NONE,
544 ));
545 assert!(matches!(action, ViewAction::Close));
546 }
547
548 #[test]
549 fn picker_only_exposes_auto_off_high_max() {
550 let labels: Vec<&str> = PICKER_EFFORTS
551 .iter()
552 .map(|effort| effort.short_label())
553 .collect();
554 assert_eq!(labels, vec!["auto", "off", "high", "max"]);
555 }
556 }
557
557 lines RUST