diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index d694615f..c84111ca 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -74,7 +74,9 @@ pub fn exit() -> CommandResult { CommandResult::action(AppAction::Quit) } -/// Switch or view current model +/// Switch or view current model. With no argument, open the two-pane +/// picker (Pro/Flash + thinking effort) per #39 — gives users a discoverable +/// way to flip both knobs without memorising the docs. pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult { if let Some(name) = model_name { let Some(model_id) = normalize_model_name(name) else { @@ -93,11 +95,7 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult { AppAction::UpdateCompaction(app.compaction_config()), ) } else { - let common = COMMON_DEEPSEEK_MODELS.join(", "); - CommandResult::message(format!( - "Current model: {}\nUsage: /model \nCommon models: {}\nTip: any valid DeepSeek model ID is accepted. Run /models to fetch live IDs from your API endpoint.", - app.model, common - )) + CommandResult::action(AppAction::OpenModelPicker) } } @@ -371,15 +369,11 @@ mod tests { } #[test] - fn test_model_without_args_shows_info() { + fn test_model_without_args_opens_picker() { let mut app = create_test_app(); let result = model(&mut app, None); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Current model:")); - assert!(msg.contains("Common models:")); - assert!(msg.contains("any valid DeepSeek model ID")); - assert!(result.action.is_none()); + assert_eq!(result.message, None); + assert_eq!(result.action, Some(AppAction::OpenModelPicker)); } #[test] diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 079b0512..fd5de57c 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1444,6 +1444,8 @@ pub enum AppAction { workspace: PathBuf, }, OpenConfigView, + /// Open the `/model` two-pane picker (Pro/Flash + Off/High/Max). + OpenModelPicker, SendMessage(String), ListSubAgents, FetchModels, diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index f6b6c2ac..fe410541 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -10,6 +10,7 @@ pub mod diff_render; pub mod event_broker; pub mod history; pub mod markdown_render; +pub mod model_picker; pub mod onboarding; pub mod pager; pub mod paste_burst; diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs new file mode 100644 index 00000000..7d394815 --- /dev/null +++ b/crates/tui/src/tui/model_picker.rs @@ -0,0 +1,484 @@ +//! `/model` picker modal: pick a DeepSeek model and a thinking-effort tier +//! and apply both at once (#39). +//! +//! Two side-by-side panes — Models on the left, Thinking effort on the +//! right. Tab swaps focus, ↑/↓ moves within the focused pane, Enter applies +//! both and closes the modal, Esc cancels. +//! +//! The effort pane intentionally only exposes `Off / High / Max`. Per +//! DeepSeek's [Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model), +//! `low`/`medium` are silently mapped to `high` server-side and `xhigh` is +//! mapped to `max`, so surfacing them as separate choices would be misleading. +//! The legacy variants remain valid in `~/.deepseek/settings.toml` for +//! back-compat — the picker just doesn't offer them. +//! +//! On apply we emit a [`ViewEvent::ModelPickerApplied`] with the resolved +//! model id and effort tier; the UI handler updates `App` state, persists +//! the choice via [`Settings`], and forwards `Op::SetModel` so the running +//! engine picks up the change without a restart. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Direction, Layout, Rect}, + prelude::Stylize, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Widget}, +}; + +use crate::palette; +use crate::tui::app::{App, ReasoningEffort}; +use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; + +/// Models the picker exposes by default. Kept short on purpose — power +/// users can still type `/model ` for anything else. +const PICKER_MODELS: &[(&str, &str)] = &[ + ("deepseek-v4-pro", "flagship"), + ("deepseek-v4-flash", "fast / cheap"), +]; + +/// Thinking-effort rows shown in the picker, in the order DeepSeek +/// behaviorally distinguishes them. +const PICKER_EFFORTS: &[ReasoningEffort] = &[ + ReasoningEffort::Off, + ReasoningEffort::High, + ReasoningEffort::Max, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Pane { + Model, + Effort, +} + +pub struct ModelPickerView { + initial_model: String, + initial_effort: ReasoningEffort, + /// Working selection (separate from the initial values so we can offer a + /// clean Esc-to-cancel without mutating App state). + selected_model_idx: usize, + selected_effort_idx: usize, + focus: Pane, + /// True when the active model is one we don't list — we still show it + /// so the picker doesn't quietly forget the user's chosen IDs. + show_custom_model_row: bool, +} + +impl ModelPickerView { + #[must_use] + pub fn new(app: &App) -> Self { + let initial_model = app.model.clone(); + let mut selected_model_idx = PICKER_MODELS + .iter() + .position(|(id, _)| *id == initial_model); + let show_custom_model_row = selected_model_idx.is_none(); + if show_custom_model_row { + // Custom row sits at the end; precompute its index. + selected_model_idx = Some(PICKER_MODELS.len()); + } + let selected_model_idx = selected_model_idx.unwrap_or(0); + + let initial_effort = app.reasoning_effort; + // Map low/medium → high, xhigh → max for picker purposes. + let normalized = match initial_effort { + ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High, + other => other, + }; + let selected_effort_idx = PICKER_EFFORTS + .iter() + .position(|e| *e == normalized) + .unwrap_or(1); // default to High if somehow unknown + + Self { + initial_model, + initial_effort, + selected_model_idx, + selected_effort_idx, + focus: Pane::Model, + show_custom_model_row, + } + } + + fn model_row_count(&self) -> usize { + PICKER_MODELS.len() + if self.show_custom_model_row { 1 } else { 0 } + } + + /// Resolve the currently highlighted model row to a model id. If the + /// custom row is selected we return the original model from the App so + /// "Apply" doesn't blow away an unrecognised id. + fn resolved_model(&self) -> String { + if self.show_custom_model_row && self.selected_model_idx == PICKER_MODELS.len() { + self.initial_model.clone() + } else { + PICKER_MODELS[self.selected_model_idx].0.to_string() + } + } + + fn resolved_effort(&self) -> ReasoningEffort { + PICKER_EFFORTS[self.selected_effort_idx] + } + + fn move_up(&mut self) { + match self.focus { + Pane::Model => { + if self.selected_model_idx > 0 { + self.selected_model_idx -= 1; + } + } + Pane::Effort => { + if self.selected_effort_idx > 0 { + self.selected_effort_idx -= 1; + } + } + } + } + + fn move_down(&mut self) { + match self.focus { + Pane::Model => { + let max = self.model_row_count().saturating_sub(1); + if self.selected_model_idx < max { + self.selected_model_idx += 1; + } + } + Pane::Effort => { + let max = PICKER_EFFORTS.len().saturating_sub(1); + if self.selected_effort_idx < max { + self.selected_effort_idx += 1; + } + } + } + } + + fn toggle_focus(&mut self) { + self.focus = match self.focus { + Pane::Model => Pane::Effort, + Pane::Effort => Pane::Model, + }; + } + + fn build_event(&self) -> ViewEvent { + ViewEvent::ModelPickerApplied { + model: self.resolved_model(), + effort: self.resolved_effort(), + previous_model: self.initial_model.clone(), + previous_effort: self.initial_effort, + } + } + + fn render_pane( + &self, + area: Rect, + buf: &mut Buffer, + title: &str, + rows: Vec<(String, String)>, + selected: usize, + focused: bool, + ) { + let border_style = if focused { + Style::default().fg(palette::DEEPSEEK_SKY) + } else { + Style::default().fg(palette::BORDER_COLOR) + }; + let block = Block::default() + .title(Line::from(Span::styled( + format!(" {title} "), + Style::default().fg(palette::TEXT_PRIMARY).bold(), + ))) + .borders(Borders::ALL) + .border_style(border_style) + .style(Style::default().bg(palette::DEEPSEEK_INK)); + let inner = block.inner(area); + block.render(area, buf); + + let mut lines = Vec::with_capacity(rows.len()); + for (idx, (label, hint)) in rows.iter().enumerate() { + let is_selected = idx == selected; + let marker = if is_selected { "▸" } else { " " }; + let label_style = if is_selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + let hint_style = if is_selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + } else { + Style::default().fg(palette::TEXT_MUTED) + }; + let mut spans = vec![ + Span::raw(" "), + Span::styled(marker, label_style), + Span::raw(" "), + Span::styled(label.clone(), label_style), + ]; + if !hint.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled(format!("({hint})"), hint_style)); + } + lines.push(Line::from(spans)); + } + Paragraph::new(lines).render(inner, buf); + } +} + +impl ModalView for ModelPickerView { + fn kind(&self) -> ModalKind { + ModalKind::ModelPicker + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Esc => ViewAction::Close, + KeyCode::Enter => ViewAction::EmitAndClose(self.build_event()), + KeyCode::Up => { + self.move_up(); + ViewAction::None + } + KeyCode::Down => { + self.move_down(); + ViewAction::None + } + KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => { + self.toggle_focus(); + ViewAction::None + } + _ => ViewAction::None, + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let popup_width = 64.min(area.width.saturating_sub(4)).max(40); + let popup_height = 14.min(area.height.saturating_sub(4)).max(10); + let popup_area = Rect { + x: area.x + (area.width.saturating_sub(popup_width)) / 2, + y: area.y + (area.height.saturating_sub(popup_height)) / 2, + width: popup_width, + height: popup_height, + }; + + Clear.render(popup_area, buf); + + // Outer chrome with title + footer hint. + let outer = Block::default() + .title(Line::from(Span::styled( + " Model & thinking ", + Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::BOLD), + ))) + .title_bottom(Line::from(vec![ + Span::styled(" ↑↓ ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("move "), + Span::styled(" Tab ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("switch "), + Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("apply "), + Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("cancel "), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)); + let inner = outer.inner(popup_area); + outer.render(popup_area, buf); + + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(inner); + + let mut model_rows: Vec<(String, String)> = PICKER_MODELS + .iter() + .map(|(id, hint)| ((*id).to_string(), (*hint).to_string())) + .collect(); + if self.show_custom_model_row { + model_rows.push((self.initial_model.clone(), "current (custom)".to_string())); + } + self.render_pane( + columns[0], + buf, + "Model", + model_rows, + self.selected_model_idx, + self.focus == Pane::Model, + ); + + let effort_rows: Vec<(String, String)> = PICKER_EFFORTS + .iter() + .map(|effort| { + let label = effort.short_label().to_string(); + let hint = match effort { + ReasoningEffort::Off => "thinking disabled".to_string(), + ReasoningEffort::High => "thinking enabled (default)".to_string(), + ReasoningEffort::Max => "thinking enabled, max effort".to_string(), + _ => String::new(), + }; + (label, hint) + }) + .collect(); + self.render_pane( + columns[1], + buf, + "Thinking", + effort_rows, + self.selected_effort_idx, + self.focus == Pane::Effort, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn create_test_app() -> App { + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: PathBuf::from("."), + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + max_subagents: 1, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: true, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }; + App::new(options, &Config::default()) + } + + #[test] + fn picker_initial_selection_matches_app_state() { + let mut app = create_test_app(); + app.model = "deepseek-v4-flash".to_string(); + app.reasoning_effort = ReasoningEffort::Max; + let view = ModelPickerView::new(&app); + assert_eq!(view.resolved_model(), "deepseek-v4-flash"); + assert_eq!(view.resolved_effort(), ReasoningEffort::Max); + } + + #[test] + fn picker_normalizes_low_medium_to_high() { + let mut app = create_test_app(); + app.reasoning_effort = ReasoningEffort::Medium; + let view = ModelPickerView::new(&app); + assert_eq!( + view.resolved_effort(), + ReasoningEffort::High, + "medium should map to high in the picker" + ); + } + + #[test] + fn picker_preserves_unknown_model_via_custom_row() { + let mut app = create_test_app(); + app.model = "deepseek-v4-pro-2026-04-XX".to_string(); + let view = ModelPickerView::new(&app); + assert!(view.show_custom_model_row); + assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX"); + } + + #[test] + fn arrow_keys_move_within_focused_pane() { + let app = create_test_app(); + let mut view = ModelPickerView::new(&app); + // Default focus is Model; move down then up. + let initial = view.selected_model_idx; + view.handle_key(KeyEvent::new( + KeyCode::Down, + crossterm::event::KeyModifiers::NONE, + )); + assert_eq!(view.selected_model_idx, initial + 1); + view.handle_key(KeyEvent::new( + KeyCode::Up, + crossterm::event::KeyModifiers::NONE, + )); + assert_eq!(view.selected_model_idx, initial); + } + + #[test] + fn tab_switches_focus_and_arrow_now_moves_effort() { + let mut app = create_test_app(); + // Default is Max (index 2 = last); pin to Off so the Down arrow has + // somewhere to go. + app.reasoning_effort = ReasoningEffort::Off; + let mut view = ModelPickerView::new(&app); + let initial_effort_idx = view.selected_effort_idx; + view.handle_key(KeyEvent::new( + KeyCode::Tab, + crossterm::event::KeyModifiers::NONE, + )); + assert_eq!(view.focus, Pane::Effort); + view.handle_key(KeyEvent::new( + KeyCode::Down, + crossterm::event::KeyModifiers::NONE, + )); + assert!(view.selected_effort_idx > initial_effort_idx); + } + + #[test] + fn enter_emits_apply_event_with_selection() { + let mut app = create_test_app(); + app.reasoning_effort = ReasoningEffort::High; + let mut view = ModelPickerView::new(&app); + view.handle_key(KeyEvent::new( + KeyCode::Tab, + crossterm::event::KeyModifiers::NONE, + )); + view.handle_key(KeyEvent::new( + KeyCode::Down, + crossterm::event::KeyModifiers::NONE, + )); + let action = view.handle_key(KeyEvent::new( + KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + )); + match action { + ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied { + model, + effort, + previous_effort, + .. + }) => { + assert_eq!(model, "deepseek-v4-pro"); + assert_eq!(effort, ReasoningEffort::Max); + assert_eq!(previous_effort, ReasoningEffort::High); + } + other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"), + } + } + + #[test] + fn esc_closes_without_emitting() { + let app = create_test_app(); + let mut view = ModelPickerView::new(&app); + let action = view.handle_key(KeyEvent::new( + KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + )); + assert!(matches!(action, ViewAction::Close)); + } + + #[test] + fn picker_only_exposes_off_high_max() { + let labels: Vec<&str> = PICKER_EFFORTS + .iter() + .map(|effort| effort.short_label()) + .collect(); + assert_eq!(labels, vec!["off", "high", "max"]); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 863daf1d..7ea6297a 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2352,6 +2352,90 @@ async fn apply_model_and_compaction_update( .await; } +/// Apply the choice made in the `/model` picker (#39): mutate App state so +/// the next turn uses the new model/effort, persist the selection to +/// `~/.deepseek/settings.toml` so it survives a restart, push the change to +/// the running engine via `Op::SetModel`/`Op::SetCompaction`, and surface +/// a one-line status describing what changed. +async fn apply_model_picker_choice( + app: &mut App, + engine_handle: &EngineHandle, + model: String, + effort: crate::tui::app::ReasoningEffort, + previous_model: String, + previous_effort: crate::tui::app::ReasoningEffort, +) { + let model_changed = model != previous_model; + let effort_changed = effort != previous_effort; + if !model_changed && !effort_changed { + app.status_message = Some(format!( + "Model unchanged: {model} · thinking {}", + effort.short_label() + )); + return; + } + + if model_changed { + app.model = model.clone(); + app.update_model_compaction_budget(); + app.last_prompt_tokens = None; + app.last_completion_tokens = None; + app.last_prompt_cache_hit_tokens = None; + app.last_prompt_cache_miss_tokens = None; + app.last_reasoning_replay_tokens = None; + } + if effort_changed { + app.reasoning_effort = effort; + } + + // Best-effort persist; surface a status warning if the settings file + // can't be written rather than aborting the in-memory change. + let mut persist_warning: Option = None; + match crate::settings::Settings::load() { + Ok(mut settings) => { + if model_changed { + let _ = settings.set("default_model", &model); + } + if effort_changed { + let _ = settings.set("reasoning_effort", effort.as_setting()); + } + if let Err(err) = settings.save() { + persist_warning = Some(format!("(not persisted: {err})")); + } + } + Err(err) => { + persist_warning = Some(format!("(not persisted: {err})")); + } + } + + if model_changed { + apply_model_and_compaction_update(engine_handle, app.compaction_config()).await; + } + + let mut summary = match (model_changed, effort_changed) { + (true, true) => format!( + "Model: {previous_model} → {model} · thinking: {} → {}", + previous_effort.short_label(), + effort.short_label() + ), + (true, false) => format!( + "Model: {previous_model} → {model} · thinking {}", + effort.short_label() + ), + (false, true) => format!( + "Thinking: {} → {} · model {model}", + previous_effort.short_label(), + effort.short_label() + ), + (false, false) => unreachable!(), + }; + if let Some(warning) = persist_warning { + summary.push(' '); + summary.push_str(&warning); + } + app.status_message = Some(summary); +} + /// Apply a `/provider` switch by mutating the in-memory config, validating /// that credentials exist for the new provider, then respawning the engine /// so the API client picks up the new base URL/key. When `model_override` @@ -2540,6 +2624,12 @@ async fn apply_command_result( app.view_stack.push(ConfigView::new_for_app(app)); } } + AppAction::OpenModelPicker => { + if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) { + app.view_stack + .push(crate::tui::model_picker::ModelPickerView::new(app)); + } + } AppAction::CompactContext => { app.status_message = Some("Compacting context...".to_string()); let _ = engine_handle.send(Op::CompactContext).await; @@ -3515,6 +3605,22 @@ async fn handle_view_events( app.status_message = Some("Refreshing sub-agents...".to_string()); let _ = engine_handle.send(Op::ListSubAgents).await; } + ViewEvent::ModelPickerApplied { + model, + effort, + previous_model, + previous_effort, + } => { + apply_model_picker_choice( + app, + engine_handle, + model, + effort, + previous_model, + previous_effort, + ) + .await; + } } } diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index ad12feb9..d9ab959a 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -26,6 +26,7 @@ pub enum ModalKind { Pager, SessionPicker, Config, + ModelPicker, } #[derive(Debug, Clone)] @@ -79,6 +80,17 @@ pub enum ViewEvent { session_id: String, title: String, }, + /// Emitted by the `/model` picker on Enter — carries both the chosen + /// model id and reasoning effort tier so the UI handler can update App + /// state, persist via `Settings`, and forward `Op::SetModel` to the + /// running engine. `previous_*` fields let the handler skip work when + /// nothing changed and craft a clear status message. + ModelPickerApplied { + model: String, + effort: crate::tui::app::ReasoningEffort, + previous_model: String, + previous_effort: crate::tui::app::ReasoningEffort, + }, } #[derive(Debug, Clone)]