feat(#39): /model opens Pro/Flash + Off/High/Max picker

`/model` with no argument now opens a two-pane modal: model on the left
(deepseek-v4-pro flagship vs deepseek-v4-flash fast-and-cheap, with the
current id appearing as a "current (custom)" row when it isn't one of
the listed defaults), and thinking effort on the right (Off, High,
Max). Tab/←/→ swaps panes, ↑/↓ moves within the focused pane, Enter
applies both, Esc cancels.

Effort exposes only the three rows DeepSeek behaviorally distinguishes
per the Thinking Mode docs — `low`/`medium` are mapped server-side to
`high`, and `xhigh` to `max`, so listing them as separate choices would
mislead. The legacy variants stay valid in `~/.deepseek/settings.toml`
for back-compat (the existing `cycle_next` already only visits
Off→High→Max), the picker just doesn't surface them.

Apply path:
 * mutates app.model and app.reasoning_effort
 * resets last_*_tokens / cache / replay-token gauges so the next-turn
   footer numbers reflect the new model rather than stale ones
 * persists `default_model` and `reasoning_effort` to settings via the
   existing Settings::set/save flow so the choice survives restart
 * forwards Op::SetModel + Op::SetCompaction to the engine so the
   running session picks up the new compaction budget
 * surfaces a one-line summary describing what changed
 * if persistence fails, the in-memory change still applies and a
   "(not persisted: ...)" suffix is appended to the status line

`/model <id>` keeps working unchanged for power users; only the
no-argument branch was redirected to the new modal.

Files:
 * tui/model_picker.rs — new ModelPickerView struct + ModalView impl,
   plus eight unit tests (initial state, low/medium normalisation,
   custom model preservation, arrow navigation, focus toggle, Enter
   emits ModelPickerApplied with the right values, Esc closes silently,
   and a guard that the picker exposes exactly off/high/max).
 * tui/views/mod.rs — adds ModalKind::ModelPicker and
   ViewEvent::ModelPickerApplied carrying both new and previous
   model+effort so the handler can describe the diff.
 * tui/app.rs — adds AppAction::OpenModelPicker.
 * commands/core.rs — `/model` no-arg branch now returns
   AppAction::OpenModelPicker; `/model <id>` shortcut is unchanged.
 * tui/ui.rs — pushes ModelPickerView on the action and adds
   apply_model_picker_choice() that handles persistence + engine sync
   when ViewEvent::ModelPickerApplied fires.
 * tui/mod.rs — registers the new submodule.

Closes #39 (against v0.5.2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-25 14:50:49 -05:00
parent f42f94207c
commit ebdda09c29
6 changed files with 612 additions and 13 deletions
+7 -13
View File
@@ -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 <name>\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]
+2
View File
@@ -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,
+1
View File
@@ -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;
+484
View File
@@ -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 <id>` 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"]);
}
}
+106
View File
@@ -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<String> = 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;
}
}
}
+12
View File
@@ -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)]