From 6b0dd1220cac55dea0e80bda68d7ae733df04649 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 17:05:48 +0000 Subject: [PATCH] fix(tui): put effort radios on /model, drop picker Live /model now paints Low|Medium|High radios and Tab cycles them. /effort opens that picker instead of a standalone A-star list. Co-authored-by: Mathis --- CHANGELOG.md | 1 + .../src/commands/executor/dispatch.rs | 3 +- src/cortex-tui/src/commands/executor/tests.rs | 29 ++++++ .../src/commands/registry/builtin.rs | 4 +- .../src/interactive/builders/model.rs | 41 ++++++++- .../src/interactive/builders/session.rs | 26 +++--- src/cortex-tui/src/interactive/handlers.rs | 31 +++++++ src/cortex-tui/src/interactive/mod.rs | 2 +- src/cortex-tui/src/interactive/renderer.rs | 76 +++++++++++++++- src/cortex-tui/src/interactive/state.rs | 90 +++++++++++++++++++ .../src/runner/event_loop/commands.rs | 9 +- src/cortex-tui/src/runner/event_loop/modal.rs | 8 ++ 12 files changed, 290 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f7f9eb..b64fc30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Changed +- `/model` shows Effort radios **Low | Medium | High**; **Tab** cycles them. `/effort` opens that same picker (no A★ / standalone effort list). - The TUI enters the **alternate screen** by default (`alternate_screen` always). Interactive launch takes the full viewport. Opt out with `cortex --no-alternate-screen` or `[tui] alternate_screen = false` to stay inline. - Empty-session splash is `Welcome to Cortex, the coding agent CLI` plus `v{package version} · / commands · @ files · ! shell · & cloud`. After the first user turn the splash is dropped (composer + footer only). No mascot, no painted `> cortex` shell lines. - Composer lock: empty is `> ` + white block at input col 0 + dim `Plan, search, build anything` after that cell (never a white rect after the placeholder). Blink-off (~530ms) hides the block so the placeholder starts at col 0. Typed copy is `#F5F5F5` with the block at the caret. diff --git a/src/cortex-tui/src/commands/executor/dispatch.rs b/src/cortex-tui/src/commands/executor/dispatch.rs index f34f7ac4..0a3abdfb 100644 --- a/src/cortex-tui/src/commands/executor/dispatch.rs +++ b/src/cortex-tui/src/commands/executor/dispatch.rs @@ -24,7 +24,8 @@ impl CommandExecutor { "mode" => CommandResult::OpenModal(ModalType::Mode), "permissions" | "perms" => CommandResult::OpenModal(ModalType::Permissions), "plan" => CommandResult::OpenModal(ModalType::Plan), - "effort" => CommandResult::OpenModal(ModalType::Effort), + // Effort radios live on `/model` (Tab). `/effort` is an alias. + "effort" => CommandResult::OpenModal(ModalType::ModelPicker), "btw" => CommandResult::Message("Side note captured for this turn.".to_string()), "jobs" | "bg" | "background" | "tasks" => CommandResult::OpenModal(ModalType::Tasks), "skills" | "sk" => CommandResult::OpenModal(ModalType::Skills), diff --git a/src/cortex-tui/src/commands/executor/tests.rs b/src/cortex-tui/src/commands/executor/tests.rs index 795d6565..31877237 100644 --- a/src/cortex-tui/src/commands/executor/tests.rs +++ b/src/cortex-tui/src/commands/executor/tests.rs @@ -520,3 +520,32 @@ fn test_share_command() { CommandResult::Async(ref s) if s == "share:7d" )); } + +#[test] +fn effort_opens_model_picker_not_a_star_picker() { + let executor = CommandExecutor::new(); + let result = executor.execute_str("/effort"); + assert!( + matches!(result, CommandResult::OpenModal(ModalType::ModelPicker)), + "expected /effort to open /model radios, got {result:?}" + ); + assert!(!matches!( + result, + CommandResult::OpenModal(ModalType::Effort) + )); +} + +#[test] +fn plugins_command_is_registered() { + let executor = CommandExecutor::new(); + let result = executor.execute_str("/plugins"); + assert!( + matches!(result, CommandResult::Async(ref s) if s == "plugins:list"), + "got {result:?}" + ); + let result = executor.execute_str("/plugins list"); + assert!( + matches!(result, CommandResult::Async(ref s) if s == "plugins:list"), + "got {result:?}" + ); +} diff --git a/src/cortex-tui/src/commands/registry/builtin.rs b/src/cortex-tui/src/commands/registry/builtin.rs index 3afa6c95..e8df6430 100644 --- a/src/cortex-tui/src/commands/registry/builtin.rs +++ b/src/cortex-tui/src/commands/registry/builtin.rs @@ -87,10 +87,10 @@ pub fn register_builtin_commands(registry: &mut CommandRegistry) { registry.register(CommandDef::new( "effort", &[], - "Tune reasoning effort for the current model", + "Tune reasoning effort on the model picker (Tab)", "/effort", CommandCategory::Model, - true, + false, )); registry.register(CommandDef::new( diff --git a/src/cortex-tui/src/interactive/builders/model.rs b/src/cortex-tui/src/interactive/builders/model.rs index 2bb61a78..b364f2ce 100644 --- a/src/cortex-tui/src/interactive/builders/model.rs +++ b/src/cortex-tui/src/interactive/builders/model.rs @@ -1,13 +1,19 @@ //! Builder for model selection. -use crate::interactive::state::{InteractiveAction, InteractiveItem, InteractiveState}; +use crate::interactive::state::{ + EffortLevel, InteractiveAction, InteractiveItem, InteractiveState, +}; use crate::providers::models::ModelInfo; /// Build an interactive state for model selection. /// Models should be passed from ProviderManager.available_models(). +/// +/// Effort is Low / Medium / High radios on this surface. Tab cycles them. +/// There is no separate A★ `/effort` picker. pub fn build_model_selector( models: Vec, current_model: Option<&str>, + current_effort: Option<&str>, ) -> InteractiveState { let mut items: Vec = models .iter() @@ -39,6 +45,13 @@ pub fn build_model_selector( InteractiveState::new(title, items, InteractiveAction::SetModel) .with_search() .with_max_visible(15) + .with_effort(EffortLevel::parse(current_effort)) + .with_hints(vec![ + ("↑↓".into(), "select".into()), + ("↵".into(), "confirm".into()), + ("tab".into(), "effort".into()), + ("esc".into(), "close".into()), + ]) } /// Format a model description showing context window and other info. @@ -73,9 +86,33 @@ mod tests { #[test] fn test_build_model_selector() { - let state = build_model_selector(Vec::new(), None); + let state = build_model_selector(Vec::new(), None, None); // May be empty if no models configured, but should not panic assert_eq!(state.title, "Select Model"); assert!(state.searchable); + assert_eq!(state.effort, Some(EffortLevel::Medium)); + let hints = state.hints.expect("tab effort hints"); + assert!( + hints.iter().any(|(k, a)| k == "tab" && a == "effort"), + "{hints:?}" + ); + } + + #[test] + fn model_selector_honors_current_effort() { + let state = build_model_selector(Vec::new(), None, Some("high")); + assert_eq!(state.effort, Some(EffortLevel::High)); + assert_eq!( + state.effort.expect("effort").radios_line(), + "○ Low ○ Medium ● High" + ); + } + + #[test] + fn model_selector_has_no_star_effort_picker() { + let state = build_model_selector(Vec::new(), None, Some("low")); + let line = state.effort.expect("effort").radios_line(); + assert!(!line.contains('★') && !line.contains("A★"), "{line}"); + assert!(line.contains("Low") && line.contains("Medium") && line.contains("High")); } } diff --git a/src/cortex-tui/src/interactive/builders/session.rs b/src/cortex-tui/src/interactive/builders/session.rs index 6b2f3289..37e93f60 100644 --- a/src/cortex-tui/src/interactive/builders/session.rs +++ b/src/cortex-tui/src/interactive/builders/session.rs @@ -19,21 +19,10 @@ pub fn build_mode_selector(current: &str) -> InteractiveState { InteractiveState::new("Mode", items, InteractiveAction::Custom("mode".to_string())) } -/// Build Low / Medium / High / MAX effort picker. +/// Build Low / Medium / High effort radios for tests that still construct +/// a standalone effort list. Live `/effort` opens `/model` instead. pub fn build_effort_selector(current: Option<&str>) -> InteractiveState { - let current = current.unwrap_or("Medium"); - let items = vec![ - InteractiveItem::new("Low", "Low").with_current(current.eq_ignore_ascii_case("low")), - InteractiveItem::new("Medium", "Medium") - .with_current(current.eq_ignore_ascii_case("medium")), - InteractiveItem::new("High", "High").with_current(current.eq_ignore_ascii_case("high")), - InteractiveItem::new("MAX", "MAX").with_current(current.eq_ignore_ascii_case("max")), - ]; - InteractiveState::new( - "Effort", - items, - InteractiveAction::Custom("effort".to_string()), - ) + crate::interactive::builders::build_model_selector(Vec::new(), None, current) } /// Build sandbox on/off picker. @@ -101,4 +90,13 @@ mod tests { let state = build_skills_selector(&[]); assert!(state.items[0].disabled); } + + #[test] + fn effort_alias_opens_model_radios_not_a_star_picker() { + let state = build_effort_selector(Some("low")); + assert_eq!(state.effort, Some(crate::interactive::EffortLevel::Low)); + let line = state.effort.expect("effort").radios_line(); + assert_eq!(line, "● Low ○ Medium ○ High"); + assert!(!line.contains('★') && !line.contains("MAX"), "{line}"); + } } diff --git a/src/cortex-tui/src/interactive/handlers.rs b/src/cortex-tui/src/interactive/handlers.rs index ae1ab7e0..70b083e2 100644 --- a/src/cortex-tui/src/interactive/handlers.rs +++ b/src/cortex-tui/src/interactive/handlers.rs @@ -72,6 +72,12 @@ pub fn handle_interactive_key(state: &mut InteractiveState, key: KeyEvent) -> In KeyCode::Left if !state.tabs.is_empty() => InteractiveResult::SwitchTab { direction: -1 }, KeyCode::Right if !state.tabs.is_empty() => InteractiveResult::SwitchTab { direction: 1 }, + // `/model` Effort radios: Tab cycles Low → Medium → High. + KeyCode::Tab if state.effort.is_some() => { + state.cycle_effort(); + InteractiveResult::Continue + } + // Selection KeyCode::Enter => { if let Some(item) = state.selected_item() { @@ -321,4 +327,29 @@ mod tests { assert!(matches!(result, InteractiveResult::Cancelled)); } + + #[test] + fn tab_cycles_model_effort_radios() { + let items = vec![InteractiveItem::new("mini", "Cortex Mini 1")]; + let mut state = InteractiveState::new("Model", items, InteractiveAction::SetModel) + .with_effort(crate::interactive::EffortLevel::Medium); + assert_eq!(state.effort, Some(crate::interactive::EffortLevel::Medium)); + + let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + handle_interactive_key(&mut state, tab); + assert_eq!(state.effort, Some(crate::interactive::EffortLevel::High)); + handle_interactive_key(&mut state, tab); + assert_eq!(state.effort, Some(crate::interactive::EffortLevel::Low)); + handle_interactive_key(&mut state, tab); + assert_eq!(state.effort, Some(crate::interactive::EffortLevel::Medium)); + } + + #[test] + fn tab_is_a_no_op_without_effort_radios() { + let mut state = create_test_state(); + let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + let result = handle_interactive_key(&mut state, tab); + assert!(matches!(result, InteractiveResult::Continue)); + assert!(state.effort.is_none()); + } } diff --git a/src/cortex-tui/src/interactive/mod.rs b/src/cortex-tui/src/interactive/mod.rs index ba77c8a2..6d9b0511 100644 --- a/src/cortex-tui/src/interactive/mod.rs +++ b/src/cortex-tui/src/interactive/mod.rs @@ -54,6 +54,6 @@ pub mod state; pub use handlers::handle_interactive_key; pub use renderer::InteractiveWidget; pub use state::{ - InlineFormField, InlineFormState, InputMode, InteractiveAction, InteractiveItem, + EffortLevel, InlineFormField, InlineFormState, InputMode, InteractiveAction, InteractiveItem, InteractiveResult, InteractiveState, }; diff --git a/src/cortex-tui/src/interactive/renderer.rs b/src/cortex-tui/src/interactive/renderer.rs index 1fdec1be..06f68bd7 100644 --- a/src/cortex-tui/src/interactive/renderer.rs +++ b/src/cortex-tui/src/interactive/renderer.rs @@ -88,7 +88,10 @@ impl<'a> InteractiveWidget<'a> { 0 }; let hints_height = 1; - let items_height = inner.height.saturating_sub(search_height + hints_height); + let effort_height = if state.effort.is_some() { 2 } else { 0 }; + let items_height = inner + .height + .saturating_sub(search_height + hints_height + effort_height); let items_y = inner.y + search_height; let items_area = Rect::new(inner.x, items_y, inner.width, items_height); @@ -135,8 +138,9 @@ impl<'a> InteractiveWidget<'a> { 0 }; let hints_height = 1; + let effort_height = if self.state.effort.is_some() { 2 } else { 0 }; - (items_count as u16) + header_height + search_height + hints_height + (items_count as u16) + header_height + search_height + hints_height + effort_height } } @@ -202,12 +206,16 @@ impl<'a> Widget for InteractiveWidget<'a> { return; } - // Layout: search (optional, framed by two hairlines) + items + hints + // Layout: search (optional, framed by two hairlines) + items + + // optional Effort radios + hints let mut constraints = Vec::new(); if self.state.searchable { constraints.push(Constraint::Length(SEARCH_FIELD_ROWS)); } constraints.push(Constraint::Min(1)); // Items + if self.state.effort.is_some() { + constraints.push(Constraint::Length(2)); // Effort title + radios + } constraints.push(Constraint::Length(1)); // Hints let chunks = Layout::vertical(constraints).split(inner); @@ -227,6 +235,12 @@ impl<'a> Widget for InteractiveWidget<'a> { self.render_items(items_area, buf); + if self.state.effort.is_some() { + let effort_area = chunks[chunk_idx]; + chunk_idx += 1; + self.render_effort_radios(effort_area, buf); + } + // Render hints let hints_area = chunks[chunk_idx]; self.render_hints(hints_area, buf); @@ -448,6 +462,30 @@ impl<'a> InteractiveWidget<'a> { } } + /// Paint `Effort` plus `○ Low ● Medium ○ High`. No A★ / star picker. + fn render_effort_radios(&self, area: Rect, buf: &mut Buffer) { + let Some(effort) = self.state.effort else { + return; + }; + if area.height < 1 { + return; + } + buf.set_string( + area.x, + area.y, + "Effort", + Style::default().fg(TEXT).add_modifier(Modifier::BOLD), + ); + if area.height >= 2 { + buf.set_string( + area.x, + area.y + 1, + effort.radios_line(), + Style::default().fg(TEXT), + ); + } + } + /// Render the key hints at the bottom: `↑↓ select · ↵ confirm · esc close`. fn render_hints(&self, area: Rect, buf: &mut Buffer) { let hint_text = if let Some(ref custom) = self.state.hints { @@ -716,4 +754,36 @@ mod tests { assert!(rows[7].contains("↑↓ select · ↵ confirm"), "{text}"); assert!(rows[7].contains("esc close"), "{text}"); } + + #[test] + fn model_picker_paints_effort_radios_and_tab_hint() { + let items = vec![ + InteractiveItem::new("mini", "Cortex Mini 1") + .with_description("Fast default for everyday coding."), + InteractiveItem::new("one", "Cortex 1") + .with_description("Deeper reasoning for hard changes."), + ]; + let state = InteractiveState::new("Model", items, InteractiveAction::SetModel) + .with_search() + .with_effort(crate::interactive::EffortLevel::Medium) + .with_hints(vec![ + ("↑↓".into(), "select".into()), + ("↵".into(), "confirm".into()), + ("tab".into(), "effort".into()), + ("esc".into(), "close".into()), + ]); + let widget = InteractiveWidget::new(&state); + let area = Rect::new(0, 0, 72, 12); + let mut buf = Buffer::empty(area); + widget.render(area, &mut buf); + let text = buffer_text(&buf); + + assert!(text.contains("Effort"), "{text}"); + assert!(text.contains("○ Low ● Medium ○ High"), "{text}"); + assert!(text.contains("tab effort"), "{text}"); + assert!( + !text.contains('★') && !text.contains("A★") && !text.contains("/effort"), + "model picker must not be an A★ /effort picker:\n{text}" + ); + } } diff --git a/src/cortex-tui/src/interactive/state.rs b/src/cortex-tui/src/interactive/state.rs index 957c0c5b..7e628463 100644 --- a/src/cortex-tui/src/interactive/state.rs +++ b/src/cortex-tui/src/interactive/state.rs @@ -210,6 +210,60 @@ pub struct InteractiveState { pub hovered_tab: Option, /// Click zones for tabs (calculated during render). pub tab_click_zones: Vec<(ratatui::layout::Rect, usize)>, + /// Optional Low / Medium / High effort radios (used by `/model`). + /// Tab cycles the selection. Absent on pickers that are not the model list. + pub effort: Option, +} + +/// Reasoning effort shown as `/model` radios: `○ Low ● Medium ○ High`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffortLevel { + Low, + Medium, + High, +} + +impl EffortLevel { + /// Parse a stored budget label. Unknown / empty values are Medium. + pub fn parse(value: Option<&str>) -> Self { + match value + .unwrap_or("medium") + .trim() + .to_ascii_lowercase() + .as_str() + { + "low" => Self::Low, + "high" => Self::High, + _ => Self::Medium, + } + } + + /// Product label stored on the session (`Low` / `Medium` / `High`). + pub fn as_str(self) -> &'static str { + match self { + Self::Low => "Low", + Self::Medium => "Medium", + Self::High => "High", + } + } + + /// Cycle Low → Medium → High → Low (Tab). + pub fn cycle(self) -> Self { + match self { + Self::Low => Self::Medium, + Self::Medium => Self::High, + Self::High => Self::Low, + } + } + + /// Lock-board radio row: filled `●` on the current level. + pub fn radios_line(self) -> &'static str { + match self { + Self::Low => "● Low ○ Medium ○ High", + Self::Medium => "○ Low ● Medium ○ High", + Self::High => "○ Low ○ Medium ● High", + } + } } impl InteractiveState { @@ -240,6 +294,20 @@ impl InteractiveState { active_tab: 0, hovered_tab: None, tab_click_zones: Vec::new(), + effort: None, + } + } + + /// Show Low / Medium / High effort radios and bind Tab to cycling them. + pub fn with_effort(mut self, effort: EffortLevel) -> Self { + self.effort = Some(effort); + self + } + + /// Advance the effort radio (Low → Medium → High → Low). + pub fn cycle_effort(&mut self) { + if let Some(current) = self.effort { + self.effort = Some(current.cycle()); } } @@ -803,4 +871,26 @@ mod tests { state.select_prev(); assert_eq!(state.selected, 2); } + + #[test] + fn effort_level_cycles_low_medium_high() { + assert_eq!(EffortLevel::parse(None), EffortLevel::Medium); + assert_eq!(EffortLevel::parse(Some("LOW")), EffortLevel::Low); + assert_eq!(EffortLevel::parse(Some("max")), EffortLevel::Medium); + assert_eq!(EffortLevel::Low.cycle(), EffortLevel::Medium); + assert_eq!(EffortLevel::Medium.cycle(), EffortLevel::High); + assert_eq!(EffortLevel::High.cycle(), EffortLevel::Low); + assert_eq!( + EffortLevel::Medium.radios_line(), + "○ Low ● Medium ○ High" + ); + for level in [EffortLevel::Low, EffortLevel::Medium, EffortLevel::High] { + let line = level.radios_line(); + assert!(!line.contains('★') && !line.contains("A★"), "{line}"); + } + let mut state = InteractiveState::new("Model", Vec::new(), InteractiveAction::SetModel) + .with_effort(EffortLevel::Low); + state.cycle_effort(); + assert_eq!(state.effort, Some(EffortLevel::Medium)); + } } diff --git a/src/cortex-tui/src/runner/event_loop/commands.rs b/src/cortex-tui/src/runner/event_loop/commands.rs index 578f6263..68376567 100644 --- a/src/cortex-tui/src/runner/event_loop/commands.rs +++ b/src/cortex-tui/src/runner/event_loop/commands.rs @@ -210,7 +210,7 @@ impl EventLoop { let interactive = build_settings_selector(snapshot, Some(terminal_height)); self.app_state.enter_interactive_mode(interactive); } - ModalType::ModelPicker => { + ModalType::ModelPicker | ModalType::Effort => { let (models, current_model) = if let Some(ref pm) = self.provider_manager { if let Ok(manager) = pm.try_read() { let models = manager.available_models(); @@ -241,6 +241,7 @@ impl EventLoop { let interactive = crate::interactive::builders::build_model_selector( models, current_model.as_deref(), + self.app_state.thinking_budget.as_deref(), ); self.app_state.enter_interactive_mode(interactive); } @@ -386,12 +387,6 @@ impl EventLoop { self.sync_agent_mode_harness(); self.app_state.toasts.info("Mode: Plan"); } - ModalType::Effort => { - let interactive = crate::interactive::builders::build_effort_selector( - self.app_state.thinking_budget.as_deref(), - ); - self.app_state.enter_interactive_mode(interactive); - } ModalType::Skills => { let interactive = self.build_skills_picker().await; self.app_state.enter_interactive_mode(interactive); diff --git a/src/cortex-tui/src/runner/event_loop/modal.rs b/src/cortex-tui/src/runner/event_loop/modal.rs index bdf3db97..500b27e1 100644 --- a/src/cortex-tui/src/runner/event_loop/modal.rs +++ b/src/cortex-tui/src/runner/event_loop/modal.rs @@ -607,6 +607,14 @@ impl EventLoop { let _ = config.save_last_model(&self.app_state.provider, &item_id); } self.update_session_model(&item_id); + if let Some(effort) = self + .app_state + .get_interactive_state() + .and_then(|state| state.effort) + { + self.app_state + .set_thinking_budget(Some(effort.as_str().to_string())); + } return false; } InteractiveAction::ToggleSetting => {