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:
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user