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:
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user