Merge pull request #2338 from encyc/feat/whale-routes-2026
feat: whale-size route taxonomy for model + thinking-effort picker
This commit is contained in:
@@ -71,6 +71,7 @@ mod ui_text;
|
||||
pub mod user_input;
|
||||
pub mod views;
|
||||
pub mod vim_mode;
|
||||
pub mod whale_routes;
|
||||
pub mod widgets;
|
||||
pub mod workspace_context;
|
||||
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
//! `/model` picker modal: pick a DeepSeek model and a thinking-effort tier
|
||||
//! and apply both at once (#39).
|
||||
//! `/model` picker modal: pick a model and thinking-effort tier (#39, #2026).
|
||||
//!
|
||||
//! 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 applies the last-highlighted choice and
|
||||
//! closes.
|
||||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
//! For pass-through providers the picker falls back to the classic two-column
|
||||
//! layout (Models | Thinking), with no whale labelling.
|
||||
//!
|
||||
//! 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.
|
||||
//! model id and effort tier.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
@@ -30,6 +24,7 @@ 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.
|
||||
@@ -69,12 +64,21 @@ pub struct ModelPickerView {
|
||||
/// 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 = crate::config::provider_passes_model_through(app.api_provider);
|
||||
// Whale routes are DeepSeek-specific — only official providers get them.
|
||||
let show_whale_routes = matches!(
|
||||
app.api_provider,
|
||||
crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
|
||||
);
|
||||
let initial_model = if app.auto_model {
|
||||
"auto".to_string()
|
||||
} else {
|
||||
@@ -104,6 +108,34 @@ 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,
|
||||
@@ -113,6 +145,8 @@ impl ModelPickerView {
|
||||
selection_touched: false,
|
||||
show_custom_model_row,
|
||||
hide_deepseek_models,
|
||||
show_whale_routes,
|
||||
selected_route_idx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,10 +162,11 @@ impl ModelPickerView {
|
||||
self.visible_model_ids().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.
|
||||
/// 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()
|
||||
@@ -143,13 +178,59 @@ 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 {
|
||||
@@ -168,6 +249,14 @@ 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);
|
||||
@@ -285,7 +374,9 @@ impl ModalView for ModelPickerView {
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => {
|
||||
self.toggle_focus();
|
||||
if !self.show_whale_routes {
|
||||
self.toggle_focus();
|
||||
}
|
||||
ViewAction::None
|
||||
}
|
||||
_ => ViewAction::None,
|
||||
@@ -293,6 +384,88 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
let popup_area = Rect {
|
||||
@@ -455,12 +628,7 @@ mod tests {
|
||||
app.auto_model = true;
|
||||
app.reasoning_effort = ReasoningEffort::Off;
|
||||
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
view.selected_model_idx = 0;
|
||||
view.selected_effort_idx = PICKER_EFFORTS
|
||||
.iter()
|
||||
.position(|effort| *effort == ReasoningEffort::Max)
|
||||
.expect("max effort row");
|
||||
let view = ModelPickerView::new(&app);
|
||||
|
||||
assert_eq!(view.resolved_model(), "auto");
|
||||
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
|
||||
@@ -505,53 +673,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_within_focused_pane() {
|
||||
fn arrow_keys_move_within_whale_routes() {
|
||||
let (app, _lock) = create_test_app();
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
// Default focus is Model; move down then up.
|
||||
let initial = view.selected_model_idx;
|
||||
assert!(view.show_whale_routes);
|
||||
let initial = view.selected_route_idx;
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert_eq!(view.selected_model_idx, initial + 1);
|
||||
assert_eq!(view.selected_route_idx, initial + 1);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Up,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert_eq!(view.selected_model_idx, initial);
|
||||
assert_eq!(view.selected_route_idx, initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_switches_focus_and_arrow_now_moves_effort() {
|
||||
let (mut app, _lock) = create_test_app();
|
||||
// Default is Max; pin to Off so the Down arrow has
|
||||
// somewhere to go.
|
||||
app.reasoning_effort = ReasoningEffort::Off;
|
||||
fn tab_is_noop_in_whale_route_mode() {
|
||||
let (app, _lock) = create_test_app();
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
let initial_effort_idx = view.selected_effort_idx;
|
||||
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.focus, Pane::Effort);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert!(view.selected_effort_idx > initial_effort_idx);
|
||||
assert_eq!(view.selected_route_idx, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_emits_apply_event_with_selection() {
|
||||
fn enter_with_whale_routes_emits_apply_event() {
|
||||
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);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Tab,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
// 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)
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
@@ -568,13 +729,91 @@ mod tests {
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(model, "deepseek-v4-pro");
|
||||
assert_eq!(effort, ReasoningEffort::Max);
|
||||
assert_eq!(effort, ReasoningEffort::Off);
|
||||
assert_eq!(previous_effort, ReasoningEffort::High);
|
||||
}
|
||||
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_routes_initial_selection_matches_app_state() {
|
||||
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.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.
|
||||
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_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() {
|
||||
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.resolved_model(), "auto");
|
||||
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_routes_custom_model_falls_back() {
|
||||
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_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() {
|
||||
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;
|
||||
let result = view.move_down();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_routes_up_from_first_is_noop() {
|
||||
let (app, _lock) = create_test_app();
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
view.selected_route_idx = 0;
|
||||
let result = view.move_up();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immediate_esc_applies_current_selection() {
|
||||
let (app, _lock) = create_test_app();
|
||||
@@ -592,9 +831,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_after_selection_move_applies_highlighted_model() {
|
||||
let (app, _lock) = create_test_app();
|
||||
fn esc_after_selection_move_applies_highlighted_route() {
|
||||
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,
|
||||
@@ -608,13 +850,15 @@ mod tests {
|
||||
match action {
|
||||
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
|
||||
model,
|
||||
previous_model,
|
||||
effort,
|
||||
previous_effort,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(previous_model, "deepseek-v4-pro");
|
||||
assert_eq!(model, "deepseek-v4-flash");
|
||||
assert_eq!(model, "deepseek-v4-pro");
|
||||
assert_eq!(effort, ReasoningEffort::Off);
|
||||
assert_eq!(previous_effort, ReasoningEffort::High);
|
||||
}
|
||||
other => panic!("expected Esc to apply highlighted model, got {other:?}"),
|
||||
other => panic!("expected Esc to apply highlighted route, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Whale-size route taxonomy for model + thinking-effort combinations (#2026).
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! ## Route ordering (size → speed)
|
||||
//!
|
||||
//! 1. Blue Whale — Pro + max thinking (largest, deepest)
|
||||
//! 2. Fin Whale — Pro + high thinking
|
||||
//! 3. Sperm Whale — Pro + no thinking
|
||||
//! 4. Humpback — Flash + max thinking
|
||||
//! 5. Minke Whale — Flash + high thinking
|
||||
//! 6. Beluga — Flash + no thinking (smallest, fastest)
|
||||
//!
|
||||
//! Unknown or non-DeepSeek models fall back to the raw model id without
|
||||
//! fake whale labeling.
|
||||
|
||||
use crate::tui::app::ReasoningEffort;
|
||||
|
||||
/// One whale-sized route: a model + thinking-effort combination with
|
||||
/// a friendly label, sort order, and descriptive hint.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WhaleRoute {
|
||||
/// Whale-species label, e.g. "Blue Whale".
|
||||
pub label: &'static str,
|
||||
/// Model id, e.g. "deepseek-v4-pro".
|
||||
pub model: &'static str,
|
||||
/// Reasoning effort tier.
|
||||
pub effort: ReasoningEffort,
|
||||
/// Sort index (0 = largest / deepest).
|
||||
pub sort_order: usize,
|
||||
/// Short inline hint, e.g. "Pro + max thinking".
|
||||
pub hint: &'static str,
|
||||
/// Longer description for tooltips / route receipts.
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
/// Six canonical routes, sorted largest → smallest.
|
||||
pub const WHALE_ROUTES: &[WhaleRoute] = &[
|
||||
WhaleRoute {
|
||||
label: "Blue Whale",
|
||||
model: "deepseek-v4-pro",
|
||||
effort: ReasoningEffort::Max,
|
||||
sort_order: 0,
|
||||
hint: "Pro + max thinking",
|
||||
description: "Flagship reasoning at maximum depth — architecture, debugging, security reviews",
|
||||
},
|
||||
WhaleRoute {
|
||||
label: "Fin Whale",
|
||||
model: "deepseek-v4-pro",
|
||||
effort: ReasoningEffort::High,
|
||||
sort_order: 1,
|
||||
hint: "Pro + high thinking",
|
||||
description: "Deep reasoning for complex tasks — multi-file refactors, careful planning",
|
||||
},
|
||||
WhaleRoute {
|
||||
label: "Sperm Whale",
|
||||
model: "deepseek-v4-pro",
|
||||
effort: ReasoningEffort::Off,
|
||||
sort_order: 2,
|
||||
hint: "Pro + no thinking",
|
||||
description: "Full model power without reasoning overhead — straightforward code generation",
|
||||
},
|
||||
WhaleRoute {
|
||||
label: "Humpback",
|
||||
model: "deepseek-v4-flash",
|
||||
effort: ReasoningEffort::Max,
|
||||
sort_order: 3,
|
||||
hint: "Flash + max thinking",
|
||||
description: "Fast model with reasoning depth — lightweight analysis, first-pass reviews",
|
||||
},
|
||||
WhaleRoute {
|
||||
label: "Minke Whale",
|
||||
model: "deepseek-v4-flash",
|
||||
effort: ReasoningEffort::High,
|
||||
sort_order: 4,
|
||||
hint: "Flash + high thinking",
|
||||
description: "Fast model, moderate reasoning — tool execution, read-only scouting",
|
||||
},
|
||||
WhaleRoute {
|
||||
label: "Beluga",
|
||||
model: "deepseek-v4-flash",
|
||||
effort: ReasoningEffort::Off,
|
||||
sort_order: 5,
|
||||
hint: "Flash + no thinking",
|
||||
description: "Fastest and cheapest — lookups, searches, simple edits",
|
||||
},
|
||||
];
|
||||
|
||||
impl WhaleRoute {
|
||||
/// Look up the whale route for a given model id and reasoning effort.
|
||||
/// Returns `None` for non-DeepSeek models or unrecognized combinations.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn for_model_effort(model: &str, effort: ReasoningEffort) -> Option<&'static WhaleRoute> {
|
||||
WHALE_ROUTES
|
||||
.iter()
|
||||
.find(|r| r.model.eq_ignore_ascii_case(model) && r.effort == effort)
|
||||
}
|
||||
|
||||
/// Look up a whale route by its sort-order index.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn by_sort_order(index: usize) -> Option<&'static WhaleRoute> {
|
||||
WHALE_ROUTES.iter().find(|r| r.sort_order == index)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn routes_are_sorted_by_size() {
|
||||
for window in WHALE_ROUTES.windows(2) {
|
||||
assert!(
|
||||
window[0].sort_order < window[1].sort_order,
|
||||
"{} should sort before {}",
|
||||
window[0].label,
|
||||
window[1].label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_blue_whale_for_pro_max() {
|
||||
let route = WhaleRoute::for_model_effort("deepseek-v4-pro", ReasoningEffort::Max)
|
||||
.expect("blue whale route exists");
|
||||
assert_eq!(route.label, "Blue Whale");
|
||||
assert_eq!(route.model, "deepseek-v4-pro");
|
||||
assert_eq!(route.effort, ReasoningEffort::Max);
|
||||
assert_eq!(route.sort_order, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_beluga_for_flash_off() {
|
||||
let route = WhaleRoute::for_model_effort("deepseek-v4-flash", ReasoningEffort::Off)
|
||||
.expect("beluga route exists");
|
||||
assert_eq!(route.label, "Beluga");
|
||||
assert_eq!(route.sort_order, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_case_insensitive_model() {
|
||||
let route = WhaleRoute::for_model_effort("DeepSeek-V4-Pro", ReasoningEffort::High)
|
||||
.expect("case-insensitive match");
|
||||
assert_eq!(route.label, "Fin Whale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_model_returns_none() {
|
||||
assert!(WhaleRoute::for_model_effort("gpt-4o", ReasoningEffort::High).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_effort_with_valid_model_returns_none() {
|
||||
// ReasoningEffort::Auto is not in any whale route
|
||||
assert!(WhaleRoute::for_model_effort("deepseek-v4-pro", ReasoningEffort::Auto).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn by_sort_order_finds_correct_routes() {
|
||||
assert_eq!(WhaleRoute::by_sort_order(0).unwrap().label, "Blue Whale");
|
||||
assert_eq!(WhaleRoute::by_sort_order(5).unwrap().label, "Beluga");
|
||||
assert!(WhaleRoute::by_sort_order(99).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_route_has_unique_sort_order() {
|
||||
let orders: Vec<usize> = WHALE_ROUTES.iter().map(|r| r.sort_order).collect();
|
||||
let mut sorted = orders.clone();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
assert_eq!(orders.len(), sorted.len(), "duplicate sort orders detected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_route_has_unique_label() {
|
||||
let labels: Vec<&str> = WHALE_ROUTES.iter().map(|r| r.label).collect();
|
||||
let mut sorted = labels.clone();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
assert_eq!(labels.len(), sorted.len(), "duplicate labels detected");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user