Merge pull request #2466 from Hmbown/codex/model-picker-neutral-routes

Fix model picker cancel behavior
This commit is contained in:
Hunter Bown
2026-05-31 14:58:43 -07:00
committed by GitHub
2 changed files with 91 additions and 269 deletions
+87 -267
View File
@@ -1,13 +1,9 @@
//! `/model` picker modal: pick a model and thinking-effort tier (#39, #2026).
//!
//! For DeepSeek providers the picker shows whale-sized routes — model + effort
//! combinations sorted largest → fastest with friendly whale-species labels
//! (Blue Whale, Fin Whale, …, Beluga). A single ↑/↓ selection sets both
//! model and effort at once. The "auto" option is always available; custom
//! (unrecognised) model ids appear as a separate row.
//!
//! For pass-through providers the picker falls back to the classic two-column
//! layout (Models | Thinking), with no whale labelling.
//! The picker intentionally presents model and thinking as independent choices
//! instead of collapsing them into preset route names. The "auto" option is
//! always available; custom (unrecognized) model ids appear as a separate row.
//! Pass-through providers fall back to only "auto" plus the current custom row.
//!
//! On apply we emit a [`ViewEvent::ModelPickerApplied`] with the resolved
//! model id and effort tier.
@@ -24,14 +20,13 @@ use ratatui::{
use crate::palette;
use crate::tui::app::{App, ReasoningEffort};
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
use crate::tui::whale_routes::WHALE_ROUTES;
/// 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)] = &[
("auto", "select per turn"),
("deepseek-v4-pro", "flagship"),
("deepseek-v4-flash", "fast / cheap"),
("auto", "choose per turn"),
("deepseek-v4-pro", "larger model"),
("deepseek-v4-flash", "faster model"),
];
/// Thinking-effort rows shown in the picker, in the order DeepSeek
@@ -57,29 +52,18 @@ pub struct ModelPickerView {
selected_model_idx: usize,
selected_effort_idx: usize,
focus: Pane,
selection_touched: bool,
/// 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,
/// When true, hide DeepSeek-specific model rows (pass-through providers
/// like openai don't support them).
hide_deepseek_models: bool,
/// When true, show whale-sized routes instead of two-column model/effort.
show_whale_routes: bool,
/// Selected whale-route index (when show_whale_routes is true).
selected_route_idx: usize,
}
impl ModelPickerView {
#[must_use]
pub fn new(app: &App) -> Self {
let hide_deepseek_models = app.accepts_custom_model_ids();
// Whale routes are DeepSeek-specific — only official providers get them.
let show_whale_routes = !hide_deepseek_models
&& matches!(
app.api_provider,
crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
);
let initial_model = if app.auto_model {
"auto".to_string()
} else {
@@ -109,45 +93,14 @@ impl ModelPickerView {
.position(|e| *e == normalized)
.unwrap_or(2); // default to High if somehow unknown
// When showing whale routes, find the matching route by position in the array
// (not by sort_order, which happens to match today but is semantically wrong).
let (selected_route_idx, show_custom_model_row) = if show_whale_routes {
let idx = WHALE_ROUTES
.iter()
.position(|r| {
r.model.eq_ignore_ascii_case(&initial_model) && r.effort == normalized
})
.unwrap_or_else(|| {
// No matching whale route — key the fallback on whether the
// current model is actually "auto", not on show_custom_model_row.
// Otherwise a known DeepSeek model (e.g. v4-pro) paired with
// ReasoningEffort::Auto silently falls through to the "auto" row
// and replaces the explicit model on apply.
if initial_model.eq_ignore_ascii_case("auto") {
WHALE_ROUTES.len() // "auto" row
} else {
WHALE_ROUTES.len() + 1 // custom model row
}
});
// When the whale-route fallback selected the custom row, ensure it is
// visible so the user can see their current model in the picker.
let show_custom = show_custom_model_row || idx == WHALE_ROUTES.len() + 1;
(idx, show_custom)
} else {
(0, show_custom_model_row)
};
Self {
initial_model,
initial_effort,
selected_model_idx,
selected_effort_idx,
focus: Pane::Model,
selection_touched: false,
show_custom_model_row,
hide_deepseek_models,
show_whale_routes,
selected_route_idx,
}
}
@@ -165,9 +118,6 @@ impl ModelPickerView {
/// Resolve the currently highlighted row to a model id.
fn resolved_model(&self) -> String {
if self.show_whale_routes {
return self.resolved_whale_model();
}
let visible = self.visible_model_ids();
if self.show_custom_model_row && self.selected_model_idx == visible.len() {
self.initial_model.clone()
@@ -179,59 +129,13 @@ impl ModelPickerView {
}
fn resolved_effort(&self) -> ReasoningEffort {
if self.show_whale_routes {
return self.resolved_whale_effort();
}
if self.resolved_model().trim().eq_ignore_ascii_case("auto") {
return ReasoningEffort::Auto;
}
PICKER_EFFORTS[self.selected_effort_idx]
}
/// Resolve model from the whale-route list.
fn resolved_whale_model(&self) -> String {
if self.selected_route_idx < WHALE_ROUTES.len() {
WHALE_ROUTES[self.selected_route_idx].model.to_string()
} else if self.selected_route_idx == WHALE_ROUTES.len() {
// First fallback row: always "auto".
"auto".to_string()
} else {
// Second fallback row: custom model.
self.initial_model.clone()
}
}
/// Resolve effort from the whale-route list.
fn resolved_whale_effort(&self) -> ReasoningEffort {
if self.selected_route_idx < WHALE_ROUTES.len() {
WHALE_ROUTES[self.selected_route_idx].effort
} else if self.selected_route_idx == WHALE_ROUTES.len() {
// First fallback row: "auto".
ReasoningEffort::Auto
} else {
// Second fallback row: custom model — keep the initial effort.
self.initial_effort
}
}
/// Number of rows in the whale-route list.
fn whale_route_row_count(&self) -> usize {
let base = WHALE_ROUTES.len() + 1; // routes + auto
if self.show_custom_model_row {
base + 1
} else {
base
}
}
fn move_up(&mut self) -> bool {
if self.show_whale_routes {
if self.selected_route_idx > 0 {
self.selected_route_idx -= 1;
return true;
}
return false;
}
match self.focus {
Pane::Model => {
if self.selected_model_idx > 0 {
@@ -250,14 +154,6 @@ impl ModelPickerView {
}
fn move_down(&mut self) -> bool {
if self.show_whale_routes {
let max = self.whale_route_row_count().saturating_sub(1);
if self.selected_route_idx < max {
self.selected_route_idx += 1;
return true;
}
return false;
}
match self.focus {
Pane::Model => {
let max = self.model_row_count().saturating_sub(1);
@@ -364,20 +260,18 @@ impl ModalView for ModelPickerView {
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc => ViewAction::EmitAndClose(self.build_event()),
KeyCode::Esc => ViewAction::Close,
KeyCode::Enter => ViewAction::EmitAndClose(self.build_event()),
KeyCode::Up => {
self.selection_touched |= self.move_up();
self.move_up();
ViewAction::None
}
KeyCode::Down => {
self.selection_touched |= self.move_down();
self.move_down();
ViewAction::None
}
KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => {
if !self.show_whale_routes {
self.toggle_focus();
}
self.toggle_focus();
ViewAction::None
}
_ => ViewAction::None,
@@ -385,87 +279,11 @@ impl ModalView for ModelPickerView {
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if self.show_whale_routes {
self.render_whale_routes(area, buf);
} else {
self.render_classic(area, buf);
}
self.render_classic(area, buf);
}
}
impl ModelPickerView {
/// Single-column whale-route list for DeepSeek providers.
fn render_whale_routes(&self, area: Rect, buf: &mut Buffer) {
let popup_width = 62.min(area.width.saturating_sub(4)).max(44);
let row_count = self.whale_route_row_count();
let popup_height = (row_count as u16 + 4)
.min(area.height.saturating_sub(4))
.max(8);
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);
let outer = Block::default()
.title(Line::from(Span::styled(
" Whale Routes ",
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("choose "),
Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)),
Span::raw("apply "),
Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)),
Span::raw("apply "),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default());
let inner = outer.inner(popup_area);
outer.render(popup_area, buf);
let mut rows: Vec<(String, String)> = WHALE_ROUTES
.iter()
.map(|r| {
(
format!("{}{}", r.label, r.hint),
r.description.to_string(),
)
})
.collect();
// Fallback row 1: always "auto".
rows.push((
"auto — select per turn".to_string(),
"Let CodeWhale pick the best model each turn".to_string(),
));
// Fallback row 2: custom model when the current model isn't recognized.
if self.show_custom_model_row {
rows.push((
format!("{} — custom", self.initial_model),
"Current model (not a standard route)".to_string(),
));
}
self.render_pane(
inner,
buf,
"Model & thinking",
rows,
self.selected_route_idx,
true,
);
}
/// Classic two-column layout for pass-through providers.
fn render_classic(&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);
@@ -494,7 +312,7 @@ impl ModelPickerView {
Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)),
Span::raw("apply "),
Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)),
Span::raw("apply "),
Span::raw("cancel "),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
@@ -532,10 +350,10 @@ impl ModelPickerView {
.map(|effort| {
let label = effort.short_label().to_string();
let hint = match effort {
ReasoningEffort::Auto => "auto-select per turn".to_string(),
ReasoningEffort::Off => "thinking disabled".to_string(),
ReasoningEffort::High => "thinking enabled (default)".to_string(),
ReasoningEffort::Max => "thinking enabled, max effort".to_string(),
ReasoningEffort::Auto => "choose per turn".to_string(),
ReasoningEffort::Off => "no extra reasoning".to_string(),
ReasoningEffort::High => "deeper reasoning".to_string(),
ReasoningEffort::Max => "maximum reasoning".to_string(),
_ => String::new(),
};
(label, hint)
@@ -684,56 +502,82 @@ mod tests {
let view = ModelPickerView::new(&app);
assert!(view.hide_deepseek_models);
assert!(!view.show_whale_routes);
assert!(view.show_custom_model_row);
assert_eq!(view.resolved_model(), "opencode-go/glm-5.1");
}
#[test]
fn arrow_keys_move_within_whale_routes() {
let (app, _lock) = create_test_app();
fn arrow_keys_move_within_focused_pane() {
let (mut app, _lock) = create_test_app();
app.model = "deepseek-v4-pro".to_string();
app.reasoning_effort = ReasoningEffort::High;
let mut view = ModelPickerView::new(&app);
assert!(view.show_whale_routes);
let initial = view.selected_route_idx;
assert_eq!(view.selected_model_idx, 1);
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.selected_route_idx, initial + 1);
assert_eq!(view.selected_model_idx, 2);
view.handle_key(KeyEvent::new(
KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.selected_route_idx, initial);
}
assert_eq!(view.selected_model_idx, 1);
#[test]
fn tab_is_noop_in_whale_route_mode() {
let (app, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app);
assert!(view.show_whale_routes);
let before = view.selected_route_idx;
view.handle_key(KeyEvent::new(
KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.selected_route_idx, before);
assert_eq!(view.focus, Pane::Effort);
assert_eq!(view.selected_effort_idx, 2);
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.selected_effort_idx, 3);
}
#[test]
fn enter_with_whale_routes_emits_apply_event() {
fn tab_switches_between_model_and_thinking() {
let (app, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app);
assert_eq!(view.focus, Pane::Model);
view.handle_key(KeyEvent::new(
KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.focus, Pane::Effort);
view.handle_key(KeyEvent::new(
KeyCode::BackTab,
crossterm::event::KeyModifiers::SHIFT,
));
assert_eq!(view.focus, Pane::Model);
}
#[test]
fn enter_emits_current_model_and_thinking() {
let (mut app, _lock) = create_test_app();
app.reasoning_effort = ReasoningEffort::High;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let mut view = ModelPickerView::new(&app);
// Initial route: Fin Whale (Pro + High, sort_order=1)
assert_eq!(view.selected_route_idx, 1);
// Move down to Sperm Whale (Pro + Off, sort_order=2)
assert_eq!(view.selected_model_idx, 1);
assert_eq!(view.selected_effort_idx, 2);
// Move model from Pro to Flash, then switch to effort and move High to Max.
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
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,
@@ -745,8 +589,8 @@ mod tests {
previous_effort,
..
}) => {
assert_eq!(model, "deepseek-v4-pro");
assert_eq!(effort, ReasoningEffort::Off);
assert_eq!(model, "deepseek-v4-flash");
assert_eq!(effort, ReasoningEffort::Max);
assert_eq!(previous_effort, ReasoningEffort::High);
}
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
@@ -754,106 +598,94 @@ mod tests {
}
#[test]
fn whale_routes_initial_selection_matches_app_state() {
fn deepseek_provider_uses_neutral_two_pane_selection() {
let (mut app, _lock) = create_test_app();
app.model = "deepseek-v4-flash".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Max;
let view = ModelPickerView::new(&app);
// Humpback = Flash + Max, sort_order = 3
assert_eq!(view.selected_route_idx, 3);
assert_eq!(view.selected_model_idx, 2);
assert_eq!(view.selected_effort_idx, 3);
assert_eq!(view.focus, Pane::Model);
assert_eq!(view.resolved_model(), "deepseek-v4-flash");
assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
}
#[test]
fn whale_routes_known_model_auto_effort_does_not_fall_to_auto() {
// Regression: a known DeepSeek model paired with ReasoningEffort::Auto
// must NOT fall through to the "auto" row — that would silently replace
// the explicit model with "auto" on apply.
fn known_model_with_auto_effort_preserves_explicit_model() {
let (mut app, _lock) = create_test_app();
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Auto;
let view = ModelPickerView::new(&app);
// Should fall to custom row (WHALE_ROUTES.len() + 1), not auto row.
assert_eq!(view.selected_route_idx, WHALE_ROUTES.len() + 1);
assert!(!view.show_custom_model_row);
assert_eq!(view.selected_model_idx, 1);
assert_eq!(view.selected_effort_idx, 0);
assert_eq!(view.resolved_model(), "deepseek-v4-pro");
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
// The custom row must be visible so the user sees their current model.
assert!(view.show_custom_model_row);
}
#[test]
fn whale_routes_auto_effort_maps_to_fallback_row() {
fn auto_model_selects_auto_row() {
let (mut app, _lock) = create_test_app();
app.model = "auto".to_string();
app.auto_model = true;
app.reasoning_effort = ReasoningEffort::Auto;
let view = ModelPickerView::new(&app);
// "auto" doesn't match any whale route, falls to fallback row
assert_eq!(view.selected_route_idx, WHALE_ROUTES.len());
assert_eq!(view.selected_model_idx, 0);
assert_eq!(view.selected_effort_idx, 0);
assert_eq!(view.resolved_model(), "auto");
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
}
#[test]
fn whale_routes_custom_model_falls_back() {
fn custom_model_row_preserves_current_model_and_effort() {
let (mut app, _lock) = create_test_app();
app.model = "deepseek-v4-pro-2026-04-XX".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::High;
let view = ModelPickerView::new(&app);
// Custom model → second fallback row (after "auto")
assert_eq!(view.selected_route_idx, WHALE_ROUTES.len() + 1);
assert!(view.show_custom_model_row);
assert_eq!(view.selected_model_idx, 3);
assert_eq!(view.selected_effort_idx, 2);
assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
assert_eq!(view.resolved_effort(), ReasoningEffort::High);
// Row count includes routes + auto + custom
assert_eq!(view.whale_route_row_count(), WHALE_ROUTES.len() + 2);
}
#[test]
fn whale_routes_down_from_last_is_noop() {
fn move_down_from_last_model_is_noop() {
let (app, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app);
// Navigate to the last row
view.selected_route_idx = view.whale_route_row_count() - 1;
view.selected_model_idx = view.model_row_count() - 1;
let result = view.move_down();
assert!(!result);
}
#[test]
fn whale_routes_up_from_first_is_noop() {
fn move_up_from_first_model_is_noop() {
let (app, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app);
view.selected_route_idx = 0;
view.selected_model_idx = 0;
let result = view.move_up();
assert!(!result);
}
#[test]
fn immediate_esc_applies_current_selection() {
fn immediate_esc_closes_without_apply() {
let (app, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app);
let action = view.handle_key(KeyEvent::new(
KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
));
match action {
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied { model, .. }) => {
assert_eq!(model, "deepseek-v4-pro");
}
other => panic!("expected Esc to apply current selection, got {other:?}"),
}
assert!(matches!(action, ViewAction::Close));
}
#[test]
fn esc_after_selection_move_applies_highlighted_route() {
fn esc_after_selection_move_closes_without_apply() {
let (mut app, _lock) = create_test_app();
app.reasoning_effort = ReasoningEffort::High;
let mut view = ModelPickerView::new(&app);
// Initial: Fin Whale (Pro+High), previous_effort=High
// Down → Sperm Whale (Pro+Off)
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
@@ -864,19 +696,7 @@ mod tests {
crossterm::event::KeyModifiers::NONE,
));
match action {
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
model,
effort,
previous_effort,
..
}) => {
assert_eq!(model, "deepseek-v4-pro");
assert_eq!(effort, ReasoningEffort::Off);
assert_eq!(previous_effort, ReasoningEffort::High);
}
other => panic!("expected Esc to apply highlighted route, got {other:?}"),
}
assert!(matches!(action, ViewAction::Close));
}
#[test]
+4 -2
View File
@@ -2,8 +2,10 @@
//!
//! Maps each `(model, reasoning_effort)` pair to a friendly whale-species label,
//! sorted from largest/deepest to smallest/fastest. The labels share the same
//! species pool as sub-agent nicknames (#2016) but serve a different purpose:
//! route/tier names help users understand depth/cost/speed at a glance.
//! species pool as sub-agent nicknames (#2016). These labels are kept as an
//! internal taxonomy for sub-agent routing receipts and related affordances; the
//! main `/model` picker stays neutral and lets users choose model and thinking
//! independently.
//!
//! ## Route ordering (size → speed)
//!