feat(tui): split sidebar 'Model reasoning' from 'Background commands'; provider-aware effort labels

- Add TaskPanelEntryKind enum (Background, ModelReasoning) to app.rs
- Add ReasoningEffort::display_label_for_provider() — maps Max→xhigh for Codex
- Sidebar: new 'Model reasoning' section above 'Background commands'
- Transcript: rename 'thinking' → 'reasoning' label
- Model picker: effort rows use provider-aware labels
- Subagent routing: tag entries with kind::Background
- Tests: kind assertions in task entry + effort display label tests

Refs: #2984 (sidebar+reasoning surface for Codex and all-models theme)
This commit is contained in:
Hunter Bown
2026-06-10 15:30:41 -07:00
parent 4d4cbd36a2
commit 5bea28e4e2
6 changed files with 191 additions and 12 deletions
+56 -4
View File
@@ -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<u64>,
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();
+5 -5
View File
@@ -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::<String>();
assert!(text.contains("Full reasoning in Ctrl+O"));
assert!(text.contains("thinking"));
assert!(text.contains("reasoning"));
}
#[test]
+4 -1
View File
@@ -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(),
+121 -1
View File
@@ -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<Lin
push_tool_rows(&mut lines, &active_rows, content_width, max_rows, theme);
}
let reasoning_rows = reasoning_task_rows(app);
if !reasoning_rows.is_empty() && lines.len() < max_rows {
push_sidebar_label_theme(&mut lines, "Model reasoning", theme);
push_reasoning_rows(&mut lines, &reasoning_rows, content_width, max_rows, theme);
}
let background_rows = background_task_rows(app, &active_rows);
if !background_rows.is_empty() && lines.len() < max_rows {
let running = background_rows
@@ -878,6 +885,7 @@ fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec<Lin
|| (lines.len() == 1
&& app.runtime_turn_id.is_some()
&& active_rows.is_empty()
&& reasoning_rows.is_empty()
&& background_rows.is_empty())
{
lines.push(Line::from(Span::styled(
@@ -903,6 +911,12 @@ fn task_panel_hover_texts(app: &App, max_rows: usize) -> Vec<String> {
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<String> {
|| (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<String>, rows: &[SidebarToolRow], m
}
}
fn push_reasoning_rows(
lines: &mut Vec<Line<'static>>,
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<String>,
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<TaskPa
let mut rows: Vec<TaskPanelEntry> = 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<TaskPa
rows
}
fn reasoning_task_rows(app: &App) -> Vec<TaskPanelEntry> {
let mut rows: Vec<TaskPanelEntry> = 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();
+2 -1
View File
@@ -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,
}
}
+3
View File
@@ -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");