diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 37bf84c5..89164b4b 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -178,7 +178,8 @@ pub struct TurnCacheRecord { /// /// The config file accepts all five string values for forward-compat with /// providers that expose the full spectrum; DeepSeek currently collapses -/// `Low`/`Medium` → `high` and `Max` → `max` at the API boundary. The +/// `Low`/`Medium` → `high`. OpenAI Codex displays and sends `Max` as +/// `xhigh` at the provider boundary. The /// keyboard cycler (Shift+Tab) walks only the three behaviorally distinct /// tiers: `Off` → `High` → `Max` → `Off`. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -234,6 +235,15 @@ impl ReasoningEffort { } } + /// Provider-facing label for user-visible surfaces. + #[must_use] + pub fn display_label_for_provider(self, provider: ApiProvider) -> &'static str { + match (provider, self) { + (ApiProvider::OpenaiCodex, Self::Max) => "xhigh", + (_, effort) => effort.short_label(), + } + } + /// Value forwarded to the engine/client. `None` means "provider default" /// (for `Off` we still emit `"off"` so the client can inject /// `thinking = {"type": "disabled"}`). @@ -1769,6 +1779,13 @@ pub struct TaskPanelEntry { pub status: String, pub prompt_summary: String, pub duration_ms: Option, + pub kind: TaskPanelEntryKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskPanelEntryKind { + Background, + ModelReasoning, } impl QueuedMessage { @@ -2443,7 +2460,11 @@ impl App { self.last_effective_reasoning_effort = None; self.needs_redraw = true; self.push_status_toast( - format!("Thinking: {}", self.reasoning_effort.short_label()), + format!( + "Thinking: {}", + self.reasoning_effort + .display_label_for_provider(self.api_provider) + ), StatusToastLevel::Info, Some(1_500), ); @@ -4995,11 +5016,16 @@ impl App { pub fn reasoning_effort_display_label(&self) -> String { if self.auto_model || self.reasoning_effort == ReasoningEffort::Auto { if let Some(effective) = self.last_effective_reasoning_effort { - return format!("auto: {}", effective.short_label()); + return format!( + "auto: {}", + effective.display_label_for_provider(self.api_provider) + ); } return "auto".to_string(); } - self.reasoning_effort.short_label().to_string() + self.reasoning_effort + .display_label_for_provider(self.api_provider) + .to_string() } pub fn compaction_config(&self) -> CompactionConfig { @@ -5306,6 +5332,32 @@ mod tests { assert!(app.trust_mode); } + #[test] + fn reasoning_effort_display_label_uses_codex_xhigh() { + assert_eq!( + ReasoningEffort::Max.display_label_for_provider(ApiProvider::OpenaiCodex), + "xhigh" + ); + assert_eq!( + ReasoningEffort::Max.display_label_for_provider(ApiProvider::Deepseek), + "max" + ); + assert_eq!( + ReasoningEffort::High.display_label_for_provider(ApiProvider::OpenaiCodex), + "high" + ); + + let mut app = App::new(test_options(false), &Config::default()); + app.api_provider = ApiProvider::OpenaiCodex; + app.reasoning_effort = ReasoningEffort::Max; + app.auto_model = false; + assert_eq!(app.reasoning_effort_display_label(), "xhigh"); + + app.reasoning_effort = ReasoningEffort::Auto; + app.last_effective_reasoning_effort = Some(ReasoningEffort::Max); + assert_eq!(app.reasoning_effort_display_label(), "auto: xhigh"); + } + #[test] fn settings_default_provider_auth_check_uses_provider_scoped_key() { let _lock = lock_test_env(); diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 9ca7fb54..b192a39c 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -2377,13 +2377,13 @@ fn render_thinking( let mut lines = Vec::new(); // Header: `…` opener (replaces the spinner; reasoning isn't a tool, it's - // a slow exhale) followed by the `thinking` label and live status. + // a slow exhale) followed by the reasoning label and live status. let mut header_spans = vec![ Span::styled( format!("{REASONING_OPENER} "), Style::default().fg(thinking_state_accent(state)), ), - Span::styled("thinking", thinking_title_style()), + Span::styled("reasoning", thinking_title_style()), ]; header_spans.push(Span::styled(" ", Style::default())); header_spans.push(Span::styled( @@ -2449,7 +2449,7 @@ fn render_thinking( if rendered.is_empty() && streaming { let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)]; - spans.push(Span::styled("thinking...", body_style.italic())); + spans.push(Span::styled("reasoning...", body_style.italic())); if !low_motion { spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style)); } @@ -2507,7 +2507,7 @@ fn render_hidden_thinking_activity( format!("{REASONING_OPENER} "), Style::default().fg(thinking_state_accent(state)), ), - Span::styled("thinking", thinking_title_style()), + Span::styled("reasoning", thinking_title_style()), Span::styled(" ", Style::default()), Span::styled(thinking_status_label(state), thinking_status_style(state)), ]; @@ -4185,7 +4185,7 @@ mod tests { .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref())) .collect::(); assert!(text.contains("Full reasoning in Ctrl+O")); - assert!(text.contains("thinking")); + assert!(text.contains("reasoning")); } #[test] diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index a310c264..eb3149ac 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -604,10 +604,13 @@ impl ModelPickerView { self.focus == Pane::Model, ); + let effort_provider = self.resolved_provider().unwrap_or(self.initial_provider); let effort_rows: Vec<(String, String)> = PICKER_EFFORTS .iter() .map(|effort| { - let label = effort.short_label().to_string(); + let label = effort + .display_label_for_provider(effort_provider) + .to_string(); let hint = match effort { ReasoningEffort::Auto => "choose per turn".to_string(), ReasoningEffort::Off => "no extra reasoning".to_string(), diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 65e10b9b..08422d8a 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -26,6 +26,7 @@ use crate::tools::todo::TodoStatus; use super::app::{ App, SidebarFocus, SidebarHoverRow, SidebarHoverSection, SidebarHoverState, TaskPanelEntry, + TaskPanelEntryKind, }; use super::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus, summarize_tool_output}; use super::subagent_routing::active_fanout_counts; @@ -792,6 +793,12 @@ fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec Vec Vec { push_tool_row_hover_texts(&mut texts, &active_rows, max_rows); } + let reasoning_rows = reasoning_task_rows(app); + if !reasoning_rows.is_empty() && texts.len() < max_rows { + texts.push("Model reasoning".to_string()); + push_reasoning_row_hover_texts(&mut texts, &reasoning_rows, max_rows); + } + let background_rows = background_task_rows(app, &active_rows); if !background_rows.is_empty() && texts.len() < max_rows { let running = background_rows @@ -961,6 +975,7 @@ fn task_panel_hover_texts(app: &App, max_rows: usize) -> Vec { || (texts.len() == 1 && app.runtime_turn_id.is_some() && active_rows.is_empty() + && reasoning_rows.is_empty() && background_rows.is_empty()) { texts.push("No live tools or background jobs".to_string()); @@ -994,6 +1009,69 @@ fn push_tool_row_hover_texts(texts: &mut Vec, rows: &[SidebarToolRow], m } } +fn push_reasoning_rows( + lines: &mut Vec>, + rows: &[TaskPanelEntry], + content_width: usize, + max_rows: usize, + theme: &palette::UiTheme, +) { + for task in rows { + if lines.len() >= max_rows { + break; + } + let color = match task.status.as_str() { + "running" => theme.warning, + "completed" => theme.success, + "failed" => theme.error_fg, + _ => theme.text_muted, + }; + let duration = task + .duration_ms + .map(format_duration_ms) + .unwrap_or_else(|| "-".to_string()); + lines.push(Line::from(Span::styled( + truncate_line_to_width( + &format!("thinking {} {duration}", task.status), + content_width, + ), + Style::default().fg(color), + ))); + if !task.prompt_summary.trim().is_empty() && lines.len() < max_rows { + lines.push(Line::from(Span::styled( + format!( + " {}", + truncate_line_to_width( + &task.prompt_summary, + content_width.saturating_sub(2).max(1) + ) + ), + Style::default().fg(theme.text_dim), + ))); + } + } +} + +fn push_reasoning_row_hover_texts( + texts: &mut Vec, + rows: &[TaskPanelEntry], + max_rows: usize, +) { + for task in rows { + if texts.len() >= max_rows { + break; + } + let duration = task + .duration_ms + .map(format_duration_ms) + .unwrap_or_else(|| "-".to_string()); + texts.push(format!("thinking {} {duration}", task.status)); + if !task.prompt_summary.trim().is_empty() && texts.len() < max_rows { + texts.push(format!(" {}", task.prompt_summary)); + } + } +} + fn background_task_labels(task: &TaskPanelEntry, duration: &str) -> (String, String) { if let Some(command) = task.prompt_summary.strip_prefix("shell: ") { let command = concise_shell_command_label(command, 96); @@ -1377,6 +1455,7 @@ fn background_task_rows(app: &App, active_rows: &[SidebarToolRow]) -> Vec = app .task_panel .iter() + .filter(|task| task.kind == TaskPanelEntryKind::Background) .filter(|task| !background_task_duplicates_live_tool(task, active_rows)) .cloned() .collect(); @@ -1384,6 +1463,17 @@ fn background_task_rows(app: &App, active_rows: &[SidebarToolRow]) -> Vec Vec { + let mut rows: Vec = app + .task_panel + .iter() + .filter(|task| task.kind == TaskPanelEntryKind::ModelReasoning) + .cloned() + .collect(); + rows.sort_by_key(|task| (task_status_rank(task.status.as_str()), task.id.clone())); + rows +} + fn background_task_duplicates_live_tool( task: &TaskPanelEntry, active_rows: &[SidebarToolRow], @@ -2364,7 +2454,7 @@ mod tests { use crate::tools::plan::StepStatus; use crate::tools::todo::TodoStatus; use crate::tui::active_cell::ActiveCell; - use crate::tui::app::{App, HuntVerdict, TaskPanelEntry, TuiOptions}; + use crate::tui::app::{App, HuntVerdict, TaskPanelEntry, TaskPanelEntryKind, TuiOptions}; use crate::tui::history::{ ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus, }; @@ -2923,6 +3013,7 @@ mod tests { status: "running".to_string(), prompt_summary: "shell: cargo test --workspace".to_string(), duration_ms: Some(12_000), + kind: TaskPanelEntryKind::Background, }); let text = lines_to_text(&task_panel_lines(&app, 80, 10)); @@ -2954,6 +3045,7 @@ mod tests { prompt_summary: "shell: cd /tmp/repo && cargo test --workspace --all-features" .to_string(), duration_ms: Some(178_000), + kind: TaskPanelEntryKind::Background, }); let text = lines_to_text(&task_panel_lines(&app, 96, 8)); @@ -2969,6 +3061,34 @@ mod tests { ); } + #[test] + fn tasks_panel_renders_model_reasoning_outside_background_commands() { + let mut app = create_test_app(); + app.task_panel.push(TaskPanelEntry { + id: "reasoning-1".to_string(), + status: "running".to_string(), + prompt_summary: "model reasoning".to_string(), + duration_ms: Some(4_200), + kind: TaskPanelEntryKind::ModelReasoning, + }); + + let text = lines_to_text(&task_panel_lines(&app, 80, 8)); + + assert!( + text.iter().any(|line| line == "Model reasoning"), + "reasoning section missing: {text:?}" + ); + assert!( + text.iter() + .any(|line| line.contains("thinking running 4.2s")), + "reasoning row should show live thinking duration: {text:?}" + ); + assert!( + !text.iter().any(|line| line.contains("Background commands")), + "reasoning must not be counted as a background command: {text:?}" + ); + } + #[test] fn tasks_panel_collapses_repeated_low_value_recent_tools_after_failures() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index 8aa67639..69ea98f2 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -4,7 +4,7 @@ use std::time::Instant; use crate::task_manager::{TaskRecord, TaskStatus, TaskSummary}; use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus}; -use crate::tui::app::{App, AppMode, TaskPanelEntry}; +use crate::tui::app::{App, AppMode, TaskPanelEntry, TaskPanelEntryKind}; use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output}; use crate::tui::pager::PagerView; use crate::tui::tool_routing::refreshes_workspace_context_on_completion; @@ -204,6 +204,7 @@ pub(super) fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntr status: task_status_label(summary.status).to_string(), prompt_summary: summary.prompt_summary, duration_ms: summary.duration_ms, + kind: TaskPanelEntryKind::Background, } } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 832a8f9c..4f69f157 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5608,6 +5608,7 @@ fn active_rlm_task_entries_surface_foreground_rlm_work() { assert_eq!(entries[0].id, "rlm-1"); assert_eq!(entries[0].status, "running"); assert_eq!(entries[0].prompt_summary, "RLM: file_path: Cargo.lock"); + assert_eq!(entries[0].kind, TaskPanelEntryKind::Background); assert!(entries[0].duration_ms.unwrap_or_default() >= 3000); } @@ -5628,6 +5629,7 @@ fn active_reasoning_task_entries_surface_reasoning_only_turns() { assert_eq!(entries[0].id, "reasoning-1"); assert_eq!(entries[0].status, "running"); assert_eq!(entries[0].prompt_summary, "model reasoning"); + assert_eq!(entries[0].kind, TaskPanelEntryKind::ModelReasoning); assert!(entries[0].duration_ms.unwrap_or_default() >= 2000); } @@ -9298,6 +9300,7 @@ mod work_sidebar_projection_tests { status: "completed".to_string(), prompt_summary: "echo hello".to_string(), duration_ms: Some(100), + kind: crate::tui::app::TaskPanelEntryKind::Background, }; assert_eq!(entry.status, "completed"); assert_ne!(entry.status, "running");