Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions crates/tui/src/tui/model_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//!
//! 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.
//! both and closes the modal. Esc closes immediately when nothing moved; after
//! a selection move it keeps the highlighted choice.
//!
//! The effort pane intentionally only exposes `Off / High / Max`. Per
//! DeepSeek's [Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model),
Expand Down Expand Up @@ -61,6 +62,7 @@ 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,
Expand Down Expand Up @@ -108,6 +110,7 @@ impl ModelPickerView {
selected_model_idx,
selected_effort_idx,
focus: Pane::Model,
selection_touched: false,
show_custom_model_row,
hide_deepseek_models,
}
Expand Down Expand Up @@ -146,36 +149,42 @@ impl ModelPickerView {
PICKER_EFFORTS[self.selected_effort_idx]
}

fn move_up(&mut self) {
fn move_up(&mut self) -> bool {
match self.focus {
Pane::Model => {
if self.selected_model_idx > 0 {
self.selected_model_idx -= 1;
return true;
}
}
Pane::Effort => {
if self.selected_effort_idx > 0 {
self.selected_effort_idx -= 1;
return true;
}
}
}
false
}

fn move_down(&mut self) {
fn move_down(&mut self) -> bool {
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;
return true;
}
}
Pane::Effort => {
let max = PICKER_EFFORTS.len().saturating_sub(1);
if self.selected_effort_idx < max {
self.selected_effort_idx += 1;
return true;
}
}
}
false
}

fn toggle_focus(&mut self) {
Expand Down Expand Up @@ -265,14 +274,15 @@ impl ModalView for ModelPickerView {

fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc if self.selection_touched => ViewAction::EmitAndClose(self.build_event()),
KeyCode::Esc => ViewAction::Close,
Comment on lines +277 to 278
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This change makes Esc behave identically to Enter once the selection has been moved. While this addresses the issue of accidental cancellations, it removes the ability for a user to explicitly discard changes if they change their mind after moving the highlight.

Consider if there should be a way to still cancel (e.g., a different key combination or checking if the selection actually changed from the initial state) or if this 'sticky' behavior is the desired UX for all cases where the cursor moved.

KeyCode::Enter => ViewAction::EmitAndClose(self.build_event()),
KeyCode::Up => {
self.move_up();
self.selection_touched |= self.move_up();
ViewAction::None
}
KeyCode::Down => {
self.move_down();
self.selection_touched |= self.move_down();
ViewAction::None
}
KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => {
Expand Down Expand Up @@ -567,7 +577,7 @@ mod tests {
}

#[test]
fn esc_closes_without_emitting() {
fn immediate_esc_closes_without_emitting() {
let (app, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app);
let action = view.handle_key(KeyEvent::new(
Expand All @@ -577,6 +587,33 @@ mod tests {
assert!(matches!(action, ViewAction::Close));
}

#[test]
fn esc_after_selection_move_applies_highlighted_model() {
let (app, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app);
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));

let action = view.handle_key(KeyEvent::new(
KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
));

match action {
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
model,
previous_model,
..
}) => {
assert_eq!(previous_model, "deepseek-v4-pro");
assert_eq!(model, "deepseek-v4-flash");
}
other => panic!("expected Esc to apply highlighted model, got {other:?}"),
}
}

#[test]
fn picker_only_exposes_auto_off_high_max() {
let labels: Vec<&str> = PICKER_EFFORTS
Expand Down
Loading