Skip to content
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/cortex-tui/src/commands/executor/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
29 changes: 29 additions & 0 deletions src/cortex-tui/src/commands/executor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
);
}
4 changes: 2 additions & 2 deletions src/cortex-tui/src/commands/registry/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
41 changes: 39 additions & 2 deletions src/cortex-tui/src/interactive/builders/model.rs
Original file line number Diff line number Diff line change
@@ -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<ModelInfo>,
current_model: Option<&str>,
current_effort: Option<&str>,
) -> InteractiveState {
let mut items: Vec<InteractiveItem> = models
.iter()
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"));
}
}
26 changes: 12 additions & 14 deletions src/cortex-tui/src/interactive/builders/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}");
}
}
31 changes: 31 additions & 0 deletions src/cortex-tui/src/interactive/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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());
}
}
2 changes: 1 addition & 1 deletion src/cortex-tui/src/interactive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
76 changes: 73 additions & 3 deletions src/cortex-tui/src/interactive/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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}"
);
}
}
Loading
Loading