feat(tui): Ctrl-O opens a turn-level inspector (#4104)

Rebind Ctrl-O from the single-cell Activity Detail pager to a whole-turn
Turn Inspector: a turn-level overview of the current in-flight turn, or the
latest completed turn when idle. v / Alt-V remain the raw leaf-detail command
for the selected item.

The inspector scopes a turn as the run of cells from the last user prompt
through the end of the transcript (active_cell entries included), so it works
during an active turn and after a completed one. Nine overview sections render
from reachable app/turn/cell state and degrade unavailable ones to a short
"none"/"—" line rather than a blank void.

Footer and help text now read Ctrl-O as "Turn Inspector", distinct from v
"raw details". The single-cell Activity Detail helper chain is retained under
cfg(test) for the existing tests and #4106/#4107/#4108 follow-ups.

Implemented with agent assistance.
This commit is contained in:
Hunter B
2026-07-08 03:48:53 -07:00
parent 6a80dc099f
commit c6080ee786
10 changed files with 659 additions and 19 deletions
+1 -1
View File
@@ -255,7 +255,7 @@
"KbLastMessagePager": "Open pager for the last message (when input is empty)",
"KbSelectedDetails": "Open details for the selected tool or message (when input is empty)",
"KbToolDetailsPager": "Open tool-details pager",
"KbThinkingPager": "Open Activity Detail",
"KbThinkingPager": "Open Turn Inspector",
"KbLiveTranscript": "Open live transcript overlay (Ctrl+Shift+T; sticky-tail auto-scroll)",
"KbBacktrackMessage": "Backtrack to a previous user message (Left/Right step, Enter to rewind)",
"KbCompleteCycleModes": "Complete /command, queue running-turn follow-up, cycle modes (Plan → Act → Multitask → Operate)",
+1 -1
View File
@@ -255,7 +255,7 @@
"KbLastMessagePager": "Abrir paginador para el último mensaje (cuando la entrada está vacía)",
"KbSelectedDetails": "Abrir detalles de la herramienta o mensaje seleccionado (cuando la entrada está vacía)",
"KbToolDetailsPager": "Abrir paginador de detalles de la herramienta",
"KbThinkingPager": "Abrir paginador de razonamiento",
"KbThinkingPager": "Abrir Inspector de turno",
"KbLiveTranscript": "Abrir superposición de transcripción en vivo (auto-scroll)",
"KbBacktrackMessage": "Retroceder al mensaje anterior del usuario (izquierda/derecha, Enter para rebobinar)",
"KbCompleteCycleModes": "Completar /command, encolar follow-up, ciclar modos; Shift+Tab cicla esfuerzo de razonamiento",
+1 -1
View File
@@ -255,7 +255,7 @@
"KbLastMessagePager": "最後のメッセージのページャーを開く(入力が空の時)",
"KbSelectedDetails": "選択中のツールまたはメッセージの詳細を開く(入力が空の時)",
"KbToolDetailsPager": "ツール詳細のページャーを開く",
"KbThinkingPager": "Activity Detail を開く",
"KbThinkingPager": "ターン インスペクターを開く",
"KbLiveTranscript": "ライブ会話履歴オーバーレイを開く(自動追尾スクロール)",
"KbBacktrackMessage": "前のユーザーメッセージに戻る(左右でステップ、Enter で巻き戻し)",
"KbCompleteCycleModes": "/command を補完、実行中ターンのフォローアップをキュー、モードを切り替え;Shift+Tab で推論強度を切り替え",
+1 -1
View File
@@ -255,7 +255,7 @@
"KbLastMessagePager": "Abrir paginador para última mensagem (quando entrada vazia)",
"KbSelectedDetails": "Abrir detalhes da ferramenta ou mensagem selecionada (quando entrada vazia)",
"KbToolDetailsPager": "Abrir paginador de detalhes da ferramenta",
"KbThinkingPager": "Abrir Activity Detail",
"KbThinkingPager": "Abrir Inspetor de turno",
"KbLiveTranscript": "Abrir sobreposição de transcrição ao vivo (auto-scroll)",
"KbBacktrackMessage": "Retroceder para mensagem anterior do usuário (esquerda/direita, Enter para rebobinar)",
"KbCompleteCycleModes": "Completar /command, enfileirar follow-up, ciclar modos; Shift+Tab cicla esforço de raciocínio",
+1 -1
View File
@@ -255,7 +255,7 @@
"KbLastMessagePager": "Mở trang xem cho tin nhắn cuối cùng (khi khung nhập trống)",
"KbSelectedDetails": "Mở chi tiết cho công cụ hoặc tin nhắn được chọn (khi khung nhập trống)",
"KbToolDetailsPager": "Mở trang xem chi tiết công cụ",
"KbThinkingPager": "Mở Chi Tiết Hoạt Động (Activity Detail)",
"KbThinkingPager": "Mở Trình kiểm tra lượt (Turn Inspector)",
"KbLiveTranscript": "Mở lớp phủ bản ghi trực tiếp (tự động cuộn theo đuôi)",
"KbBacktrackMessage": "Quay lại tin nhắn trước đó của người dùng (nhấn Trái/Phải để chuyển bước, Enter để lùi lại)",
"KbCompleteCycleModes": "Hoàn thành /command, xếp hàng theo dõi lượt đang chạy, chuyển đổi chế độ; Shift+Tab để chuyển đổi mức độ suy luận",
+1 -1
View File
@@ -255,7 +255,7 @@
"KbLastMessagePager": "打开最后一条消息的分页器(输入框为空时)",
"KbSelectedDetails": "打开选中工具或消息的详情(输入框为空时)",
"KbToolDetailsPager": "打开工具详情分页器",
"KbThinkingPager": "打开 Activity Detail",
"KbThinkingPager": "打开回合检查器 (Turn Inspector)",
"KbLiveTranscript": "打开实时对话覆盖层(自动滚动尾随)",
"KbBacktrackMessage": "回退到之前的用户消息(左右键步进,Enter 回退)",
"KbCompleteCycleModes": "补全 /command、排队运行轮次跟进、切换模式;Shift+Tab 切换推理强度",
+6 -2
View File
@@ -347,19 +347,23 @@ mod tests {
}
#[test]
fn ctrl_o_help_copy_matches_activity_detail_behavior() {
fn ctrl_o_help_copy_matches_turn_inspector_behavior() {
let ctrl_o = KEYBINDINGS
.iter()
.find(|entry| entry.chord == "Ctrl+O")
.expect("Ctrl+O keybinding should be documented");
// Ctrl+O now opens the whole-turn Turn Inspector (#4104), not the
// single-cell Activity Detail. The message id is intentionally kept
// (`KbThinkingPager`) to avoid an existing-symbol rename; only the
// copy changes.
assert_eq!(
ctrl_o.description_id,
crate::localization::MessageId::KbThinkingPager
);
assert_eq!(
crate::localization::tr(crate::localization::Locale::En, ctrl_o.description_id,),
"Open Activity Detail"
"Open Turn Inspector"
);
}
+11 -5
View File
@@ -154,9 +154,14 @@ pub(crate) use self::activity_detail::{
selected_detail_footer_label,
};
use self::activity_detail::{
copy_focused_cell, detail_target_cell_index, extract_reasoning_header,
open_activity_detail_pager, open_tool_details_pager,
copy_focused_cell, detail_target_cell_index, extract_reasoning_header, open_tool_details_pager,
open_turn_inspector_pager,
};
// Ctrl+O now opens the whole-turn Turn Inspector (#4104); the single-cell
// Activity Detail pager is no longer bound to a key, so it is only referenced
// from tests. (`v` raw leaf detail keeps using `open_tool_details_pager`.)
#[cfg(test)]
use self::activity_detail::open_activity_detail_pager;
// === Constants ===
@@ -4449,7 +4454,7 @@ async fn run_event_loop(
KeyCode::Char('o')
if key.modifiers.contains(KeyModifiers::CONTROL)
&& app.input.is_empty()
&& open_activity_detail_pager(app) =>
&& open_turn_inspector_pager(app) =>
{
continue;
}
@@ -12315,8 +12320,9 @@ fn open_pager_for_last_message(app: &mut App) -> bool {
true
}
/// Compatibility wrapper for the old test name. The user-facing Ctrl+O
/// surface is now Activity Detail, not a thinking-only pager.
/// Compatibility wrapper for the old test name. Exercises the single-cell
/// Activity Detail helper (still used by `v`-adjacent detail paths); the
/// user-facing Ctrl+O surface is now the whole-turn Turn Inspector (#4104).
#[cfg(test)]
fn open_thinking_pager(app: &mut App) -> bool {
open_activity_detail_pager(app)
+462 -3
View File
@@ -8,10 +8,16 @@
use crate::tui::app::App;
use crate::tui::footer_ui::one_line_summary;
use crate::tui::history::{HistoryCell, ToolCell, ToolStatus, TranscriptRenderOptions};
use crate::tui::history::{HistoryCell, ToolCell, ToolStatus};
use crate::tui::key_shortcuts;
use crate::tui::pager::PagerView;
use crate::tui::ui_text::{history_cell_to_text, line_to_plain, truncate_line_to_width};
use crate::tui::ui_text::{history_cell_to_text, truncate_line_to_width};
// Only the test-gated single-cell Activity Detail renderer needs these
// (Ctrl+O now opens the Turn Inspector, #4104).
#[cfg(test)]
use crate::tui::history::TranscriptRenderOptions;
#[cfg(test)]
use crate::tui::ui_text::line_to_plain;
/// Open a pager for the activity the user is most likely asking about.
///
@@ -21,6 +27,11 @@ use crate::tui::ui_text::{history_cell_to_text, line_to_plain, truncate_line_to_
/// rendered through the compact live view so Activity Detail does not become
/// an accidental raw-output dump; `v` remains the direct full tool-detail
/// surface.
///
/// Ctrl+O now opens the whole-turn Turn Inspector (#4104), so this single-cell
/// pager and its private helper chain are retained for tests (and potential
/// reuse by the #4106/#4107/#4108 follow-ups) but are no longer bound to a key.
#[cfg(test)]
pub(super) fn open_activity_detail_pager(app: &mut App) -> bool {
let Some(idx) = activity_target_cell_index(app) else {
app.status_message = Some("No activity detail available".to_string());
@@ -120,6 +131,7 @@ fn activity_cell_rank(cell: &HistoryCell) -> Option<u8> {
}
}
#[cfg(test)]
fn activity_detail_text(app: &App, cell_index: usize, width: u16) -> Option<String> {
let cell = app.cell_at_virtual_index(cell_index)?;
if matches!(cell, HistoryCell::Thinking { .. }) {
@@ -167,6 +179,7 @@ fn activity_detail_text(app: &App, cell_index: usize, width: u16) -> Option<Stri
Some(sections.join("\n"))
}
#[cfg(test)]
fn reasoning_timeline_text(app: &App, selected_cell_index: usize) -> Option<String> {
let thinking_indices: Vec<usize> = (0..app.virtual_cell_count())
.filter(|&idx| {
@@ -268,6 +281,7 @@ fn reasoning_timeline_text(app: &App, selected_cell_index: usize) -> Option<Stri
Some(sections.join("\n"))
}
#[cfg(test)]
fn thinking_chunk_preview(app: &App, cell_index: usize) -> String {
let Some(HistoryCell::Thinking { content, .. }) = app.cell_at_virtual_index(cell_index) else {
return "thinking".to_string();
@@ -298,6 +312,7 @@ fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> Stri
}
}
#[cfg(test)]
fn activity_status_line(cell: &HistoryCell) -> Option<String> {
match cell {
HistoryCell::Thinking {
@@ -401,6 +416,7 @@ fn format_activity_duration_ms(ms: u64) -> String {
}
}
#[cfg(test)]
fn activity_indices(app: &App) -> Vec<usize> {
(0..app.virtual_cell_count())
.filter(|&idx| {
@@ -410,6 +426,7 @@ fn activity_indices(app: &App) -> Vec<usize> {
.collect()
}
#[cfg(test)]
fn activity_navigation_lines(
app: &App,
position: usize,
@@ -442,6 +459,7 @@ fn activity_navigation_lines(
lines
}
#[cfg(test)]
fn activity_detail_handle_line(app: &App, cell_index: usize, cell: &HistoryCell) -> Option<String> {
if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
if let Some(artifact) = app
@@ -467,6 +485,7 @@ fn activity_detail_handle_line(app: &App, cell_index: usize, cell: &HistoryCell)
}
}
#[cfg(test)]
fn activity_input_summary_line(cell: &HistoryCell) -> Option<String> {
let HistoryCell::Tool(ToolCell::Generic(generic)) = cell else {
return None;
@@ -479,6 +498,7 @@ fn activity_input_summary_line(cell: &HistoryCell) -> Option<String> {
}
}
#[cfg(test)]
fn activity_cell_to_text(cell: &HistoryCell, width: u16) -> String {
let lines = match cell {
HistoryCell::Tool(_) => cell.lines_with_options(
@@ -679,7 +699,7 @@ pub(crate) fn selected_detail_footer_label(app: &App) -> Option<String> {
String::new()
};
Some(format!(
"{} Activity: {label}{detail_hint}",
"{} Turn Inspector · {label}{detail_hint}",
key_shortcuts::activity_shortcut_label()
))
}
@@ -761,3 +781,442 @@ pub(super) fn extract_reasoning_header(text: &str) -> Option<String> {
Some(header.to_string())
}
}
// ============================================================================
// Turn Inspector (issue #4104)
//
// Ctrl+O opens a *turn-level* overview of the current in-flight turn — or the
// latest completed turn when idle — rather than the single-cell Activity
// Detail. `v` / `Alt+V` remain the raw leaf-detail command for the selected
// item; this surface never dumps a single tool's raw output.
//
// Each of the nine overview sections renders from whatever turn/cell/app state
// is cleanly reachable and DEGRADES the rest gracefully to a short "none"/"—"
// line — never a mysterious blank. The thinner sections (diagnostics loop,
// tests/verifier) are intentionally heuristic in this first pass; the leaf
// issues #4106/#4107/#4108 flesh them out with structured data later.
// ============================================================================
/// Open the whole-turn Turn Inspector pager (Ctrl+O).
///
/// Reuses the same `PagerView` text-section machinery as the Activity Detail
/// pager — no new modal system. Always succeeds: an empty transcript still
/// yields a coherent (degraded) overview rather than a dead keypress.
pub(super) fn open_turn_inspector_pager(app: &mut App) -> bool {
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let text = turn_inspector_text(app);
app.view_stack.push(PagerView::from_text(
"Turn Inspector",
&text,
width.saturating_sub(2),
));
true
}
/// Virtual-cell range `[start, end)` of the turn under inspection.
///
/// The turn is the run of cells from the last user prompt through the end of
/// the transcript. Because `virtual_cell_count()` includes still-in-flight
/// `active_cell` entries, this scopes to the current in-flight turn during a
/// turn, and to the latest completed turn once the active cell has flushed to
/// history. When no user prompt exists yet the whole transcript is used.
fn current_turn_range(app: &App) -> (usize, usize) {
let end = app.virtual_cell_count();
let start = (0..end)
.rev()
.find(|&idx| {
matches!(
app.cell_at_virtual_index(idx),
Some(HistoryCell::User { .. })
)
})
.unwrap_or(0);
(start, end)
}
/// Assemble the Turn Inspector overview text from all available turn data.
pub(super) fn turn_inspector_text(app: &App) -> String {
let (start, end) = current_turn_range(app);
let mut out: Vec<String> = Vec::new();
// Turn identity header.
if let Some(turn_id) = app.runtime_turn_id.as_ref() {
let status = app.runtime_turn_status.as_deref().unwrap_or("in progress");
out.push(format!(
"Turn: {} ({status})",
truncate_line_to_width(turn_id, 32)
));
} else if app.turn_counter > 0 {
out.push(format!("Turn #{}", app.turn_counter));
} else {
out.push("Turn: — (no turn recorded yet)".to_string());
}
// Restate the Ctrl+O (overview) vs. `v` (raw leaf detail) contract so the
// two surfaces never get confused.
out.push(
"Overview of the current/latest turn · press v for the selected item's raw detail"
.to_string(),
);
push_section(&mut out, "Intent", vec![turn_intent_line(app, start)]);
if let Some(line) = selected_item_context_line(app) {
push_section(&mut out, "Selected item", vec![line]);
}
push_section(&mut out, "Plan / checklist", turn_plan_lines(app));
push_section(
&mut out,
"Tool timeline",
turn_tool_timeline(app, start, end),
);
push_section(
&mut out,
"Files changed",
turn_files_changed(app, start, end),
);
push_section(&mut out, "Diagnostics loop", turn_diagnostics_lines(app));
push_section(
&mut out,
"Tests / verifier",
turn_verifier_lines(app, start, end),
);
push_section(&mut out, "Approvals / denials", turn_approvals_lines(app));
push_section(&mut out, "Model route + tokens/cost", turn_route_lines(app));
push_section(
&mut out,
"Final result / status",
turn_result_lines(app, start, end),
);
out.join("\n")
}
/// Append a `── Title ──` section. An empty body degrades to a single
/// `none` line so the section header is never followed by a blank void.
fn push_section(out: &mut Vec<String>, title: &str, body: Vec<String>) {
out.push(String::new());
out.push(format!("── {title} ──"));
if body.is_empty() {
out.push("none".to_string());
} else {
out.extend(body);
}
}
/// Section 1 — intent / user-prompt summary for the turn.
fn turn_intent_line(app: &App, start: usize) -> String {
if let Some(HistoryCell::User { content }) = app.cell_at_virtual_index(start) {
let summary = one_line_summary(content, 240);
if !summary.is_empty() {
return summary;
}
}
if let Some(prompt) = app.last_submitted_prompt.as_deref() {
let summary = one_line_summary(prompt, 240);
if !summary.is_empty() {
return summary;
}
}
"".to_string()
}
/// Optional selected-item context. The first view is the turn overview, but
/// when the user has an activity cell selected we surface it plus the `v`
/// affordance so the Ctrl+O / `v` split stays discoverable.
fn selected_item_context_line(app: &App) -> Option<String> {
let idx = selected_transcript_cell_index(app)?;
let cell = app.cell_at_virtual_index(idx)?;
let label = truncate_line_to_width(&activity_cell_label(app, idx, cell), 48);
let hint = if app.cell_has_detail_target(idx) {
" · v opens its raw detail"
} else {
""
};
Some(format!("{label}{hint}"))
}
/// Section 2 — plan and/or checklist state, when a plan/todo tool has run.
fn turn_plan_lines(app: &App) -> Vec<String> {
let mut lines = Vec::new();
if let Ok(plan) = app.plan_state.try_lock()
&& !plan.is_empty()
{
let snapshot = plan.snapshot();
let headline = snapshot
.title
.as_deref()
.or(snapshot.objective.as_deref())
.map(str::trim)
.filter(|s: &&str| !s.is_empty());
if let Some(headline) = headline {
lines.push(format!("Plan: {}", truncate_line_to_width(headline, 64)));
}
let (pending, in_progress, completed) = plan.counts();
let total = pending + in_progress + completed;
if total > 0 {
lines.push(format!(
"Steps: {completed}/{total} done ({}%)",
plan.progress_percent()
));
}
for item in &snapshot.items {
lines.push(format!(
"{} {}",
step_status_glyph(&item.status),
truncate_line_to_width(&item.step, 72)
));
}
}
if let Ok(todos) = app.todos.try_lock() {
let snapshot = todos.snapshot();
if !snapshot.items.is_empty() {
lines.push(format!("Checklist: {}% complete", snapshot.completion_pct));
for item in &snapshot.items {
lines.push(format!(
"{} {}",
todo_status_glyph(&item.status),
truncate_line_to_width(&item.content, 72)
));
}
}
}
lines
}
fn step_status_glyph(status: &crate::tools::plan::StepStatus) -> &'static str {
match status {
crate::tools::plan::StepStatus::Completed => "[x]",
crate::tools::plan::StepStatus::InProgress => "[~]",
crate::tools::plan::StepStatus::Pending => "[ ]",
}
}
fn todo_status_glyph(status: &crate::tools::todo::TodoStatus) -> &'static str {
match status {
crate::tools::todo::TodoStatus::Completed => "[x]",
crate::tools::todo::TodoStatus::InProgress => "[~]",
crate::tools::todo::TodoStatus::Pending => "[ ]",
}
}
/// Section 3 — the turn's tool calls with status and (where known) duration.
fn turn_tool_timeline(app: &App, start: usize, end: usize) -> Vec<String> {
let mut lines = Vec::new();
for idx in start..end {
let Some(HistoryCell::Tool(tool)) = app.cell_at_virtual_index(idx) else {
continue;
};
let label = detail_target_label(app, idx).unwrap_or_else(|| "tool".to_string());
let mut line = format!("{}", truncate_line_to_width(&label, 64));
if let Some(status) = tool_status_for_activity(tool) {
line.push_str("");
line.push_str(activity_status_label(status));
}
if let Some(ms) = tool_duration_for_activity(tool) {
line.push_str(" · ");
line.push_str(&format_activity_duration_ms(ms));
}
lines.push(line);
}
lines
}
/// Section 4 — files touched by patch/diff tool cells in the turn.
fn turn_files_changed(app: &App, start: usize, end: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut seen = std::collections::HashSet::new();
for idx in start..end {
let Some(HistoryCell::Tool(tool)) = app.cell_at_virtual_index(idx) else {
continue;
};
match tool {
ToolCell::PatchSummary(patch) => {
if seen.insert(patch.path.clone()) {
lines.push(format!(
"• {} — {}",
truncate_line_to_width(&patch.path, 60),
activity_status_label(patch.status)
));
}
}
ToolCell::DiffPreview(diff) => {
if seen.insert(diff.title.clone()) {
lines.push(format!(
"• {} (diff)",
truncate_line_to_width(&diff.title, 60)
));
}
}
_ => {}
}
}
lines
}
/// Section 5 — diagnostics / LSP repair loop.
///
/// Structured per-turn LSP repair state is not surfaced to the TUI yet
/// (issue #4106), so this degrades to the coarse enable flag rather than a
/// blank section.
fn turn_diagnostics_lines(app: &App) -> Vec<String> {
if app.lsp_enabled {
vec!["LSP enabled — no repair-loop details surfaced yet (see #4106)".to_string()]
} else {
vec!["LSP disabled".to_string()]
}
}
/// Section 6 — tests / verifier results.
///
/// Heuristic first pass (issue #4107): scans the turn's exec/review tool cells
/// for verifier-shaped commands and reports their status. Degrades to `none`
/// when nothing test-shaped ran.
fn turn_verifier_lines(app: &App, start: usize, end: usize) -> Vec<String> {
let mut lines = Vec::new();
for idx in start..end {
let Some(HistoryCell::Tool(tool)) = app.cell_at_virtual_index(idx) else {
continue;
};
match tool {
ToolCell::Exec(exec) if command_looks_like_verifier(&exec.command) => {
lines.push(format!(
"• {} — {}",
truncate_line_to_width(&exec.command, 56),
activity_status_label(exec.status)
));
}
ToolCell::Review(review) => {
let target = truncate_line_to_width(review.target.trim(), 48);
let target = if target.is_empty() {
"review".to_string()
} else {
format!("review {target}")
};
lines.push(format!(
"• {target} — {}",
activity_status_label(review.status)
));
}
_ => {}
}
}
lines
}
fn command_looks_like_verifier(command: &str) -> bool {
let lower = command.to_lowercase();
[
"test",
"pytest",
"jest",
"cargo check",
"cargo clippy",
"verif",
"lint",
]
.iter()
.any(|needle| lower.contains(needle))
}
/// Section 7 — approvals / denials.
///
/// The approval allow/deny sets are session-scoped (not per-turn), so the
/// counts are labelled `(session)` to avoid implying turn precision.
fn turn_approvals_lines(app: &App) -> Vec<String> {
let mut lines = Vec::new();
let approved = app.approval_session_approved.len();
let denied = app.approval_session_denied.len();
if approved > 0 {
lines.push(format!("Approved (session): {approved}"));
}
if denied > 0 {
lines.push(format!("Denied (session): {denied}"));
}
lines
}
/// Section 8 — model route plus token/cost accounting.
fn turn_route_lines(app: &App) -> Vec<String> {
let mut lines = Vec::new();
let (provider, model) = match app.pending_turn_route.as_ref() {
Some((provider, model, _passthrough)) => (provider.display_name(), model.clone()),
None => (app.api_provider.display_name(), app.model.clone()),
};
lines.push(format!("Route: {provider} · {model}"));
let session = &app.session;
match (session.last_prompt_tokens, session.last_completion_tokens) {
(Some(prompt), Some(completion)) => {
lines.push(format!(
"Tokens (last turn): {prompt} in · {completion} out"
));
}
(Some(prompt), None) => lines.push(format!("Tokens (last turn): {prompt} in")),
(None, Some(completion)) => lines.push(format!("Tokens (last turn): {completion} out")),
(None, None) => {
if session.total_tokens > 0 {
lines.push(format!("Tokens (session): {}", session.total_tokens));
}
}
}
let cost = app.displayed_session_cost_for_currency(app.cost_currency);
if cost > 0.0 {
lines.push(format!("Cost (session): {}", app.format_cost_amount(cost)));
}
lines
}
/// Section 9 — final result / current status.
fn turn_result_lines(app: &App, start: usize, end: usize) -> Vec<String> {
let mut lines = Vec::new();
let status = match app.runtime_turn_status.as_deref() {
Some("in_progress") => "in progress",
Some(other) => other,
None => "idle",
};
lines.push(format!("Status: {status}"));
let final_text = (start..end)
.rev()
.find_map(|idx| match app.cell_at_virtual_index(idx) {
Some(HistoryCell::Assistant { content, .. }) => {
let summary = one_line_summary(content, 200);
(!summary.is_empty()).then_some(summary)
}
_ => None,
});
if let Some(text) = final_text {
lines.push(format!("Result: {text}"));
} else if status == "in progress" {
lines.push("Result: turn still running".to_string());
} else {
lines.push("Result: —".to_string());
}
let error_text = (start..end)
.rev()
.find_map(|idx| match app.cell_at_virtual_index(idx) {
Some(HistoryCell::Error { message, .. }) => {
let summary = one_line_summary(message, 160);
(!summary.is_empty()).then_some(summary)
}
_ => None,
});
if let Some(err) = error_text {
lines.push(format!("Error: {err}"));
}
lines
}
+174 -3
View File
@@ -7965,7 +7965,7 @@ fn detail_target_prefers_visible_tool_card() {
assert_eq!(detail_target_cell_index(&app), Some(1));
let expected = format!(
"{} Activity: find · {}",
"{} Turn Inspector · find · {}",
crate::tui::key_shortcuts::activity_shortcut_label(),
crate::tui::key_shortcuts::tool_details_shortcut_action_hint("raw details")
);
@@ -7996,7 +7996,7 @@ fn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {
assert_eq!(
selected_detail_footer_label(&app).as_deref(),
Some("Ctrl+O Activity: thinking")
Some("Ctrl+O Turn Inspector · thinking")
);
}
@@ -8020,7 +8020,7 @@ fn activity_footer_hint_uses_details_for_subagent_cards() {
app.viewport.last_transcript_visible = 4;
let expected = format!(
"{} Activity: sub-agent · {}",
"{} Turn Inspector · sub-agent · {}",
crate::tui::key_shortcuts::activity_shortcut_label(),
crate::tui::key_shortcuts::tool_details_shortcut_action_hint("details")
);
@@ -10006,6 +10006,177 @@ fn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_du
);
}
#[test]
fn turn_inspector_renders_overview_sections_for_active_turn() {
let mut app = create_test_app();
// A committed turn: user prompt, a tool call, a patch, and a final reply.
app.history = vec![
HistoryCell::User {
content: "Fix the flaky login test".to_string(),
},
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: "cargo test login".to_string(),
status: ToolStatus::Success,
output: Some("ok".to_string()),
live_output: None,
shell_task_id: None,
owner_agent_id: None,
owner_agent_name: None,
started_at: None,
duration_ms: Some(2400),
source: ExecSource::Assistant,
interaction: None,
output_summary: None,
})),
HistoryCell::Tool(ToolCell::PatchSummary(
crate::tui::history::PatchSummaryCell {
path: "src/login.rs".to_string(),
summary: "guard against empty token".to_string(),
status: ToolStatus::Success,
error: None,
},
)),
HistoryCell::Assistant {
content: "Fixed the race in the login test.".to_string(),
streaming: false,
},
];
app.runtime_turn_id = Some("turn_abc123456789".to_string());
app.runtime_turn_status = Some("completed".to_string());
let body = turn_inspector_text(&app);
// Overview framing + Ctrl+O vs. v contract.
assert!(
body.contains("Turn: turn_abc123456789 (completed)"),
"{body}"
);
assert!(
body.contains("press v for the selected item's raw detail"),
"{body}"
);
// Section headers for all nine sections must be present.
for header in [
"── Intent ──",
"── Plan / checklist ──",
"── Tool timeline ──",
"── Files changed ──",
"── Diagnostics loop ──",
"── Tests / verifier ──",
"── Approvals / denials ──",
"── Model route + tokens/cost ──",
"── Final result / status ──",
] {
assert!(body.contains(header), "missing section {header}: {body}");
}
// Intent + tool timeline + files + result are the must-have populated ones.
assert!(body.contains("Fix the flaky login test"), "{body}");
assert!(body.contains("cargo test login"), "{body}");
assert!(body.contains("2.4s"), "duration missing: {body}");
assert!(body.contains("src/login.rs"), "{body}");
assert!(
body.contains("cargo test login — done"),
"verifier section should surface the test run: {body}"
);
assert!(body.contains("Route: DeepSeek"), "route missing: {body}");
assert!(
body.contains("Result: Fixed the race in the login test."),
"{body}"
);
assert!(body.contains("Status: completed"), "{body}");
}
#[test]
fn turn_inspector_degrades_empty_sections_without_panic() {
// A minimal turn: only a user prompt, nothing else has happened yet.
let mut app = create_test_app();
app.history = vec![HistoryCell::User {
content: "hello".to_string(),
}];
app.runtime_turn_status = Some("in_progress".to_string());
let body = turn_inspector_text(&app);
// Unavailable sections degrade to `none`, never a blank void.
assert!(body.contains("── Plan / checklist ──\nnone"), "{body}");
assert!(body.contains("── Files changed ──\nnone"), "{body}");
assert!(body.contains("── Tests / verifier ──\nnone"), "{body}");
assert!(body.contains("── Approvals / denials ──\nnone"), "{body}");
// Intent still resolves from the prompt; status reflects the live turn.
assert!(body.contains("hello"), "{body}");
assert!(body.contains("Status: in progress"), "{body}");
assert!(body.contains("Result: turn still running"), "{body}");
}
#[test]
fn turn_inspector_scopes_to_latest_turn_only() {
// Two turns in history: the inspector must scope to the second (latest).
let mut app = create_test_app();
app.history = vec![
HistoryCell::User {
content: "first turn prompt".to_string(),
},
HistoryCell::Assistant {
content: "first turn answer".to_string(),
streaming: false,
},
HistoryCell::User {
content: "second turn prompt".to_string(),
},
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "read_file".to_string(),
status: ToolStatus::Success,
input_summary: Some("src/lib.rs".to_string()),
output: Some("done".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})),
];
let body = turn_inspector_text(&app);
assert!(body.contains("second turn prompt"), "intent scope: {body}");
assert!(
!body.contains("first turn prompt"),
"inspector leaked prior turn intent: {body}"
);
}
#[test]
fn ctrl_o_open_turn_inspector_pager_opens_turn_overview_not_single_cell() {
// The Ctrl+O handler dispatches to open_turn_inspector_pager; assert that
// helper opens the turn overview rather than the single-cell detail.
let mut app = create_test_app();
app.history = vec![
HistoryCell::User {
content: "do the thing".to_string(),
},
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "grep_files".to_string(),
status: ToolStatus::Success,
input_summary: Some("TODO".to_string()),
output: Some("summary".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})),
];
assert!(open_turn_inspector_pager(&mut app));
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));
let body = pop_pager_body(&mut app);
// Turn overview markers present; NOT the single-cell "Activity:" body.
assert!(body.contains("── Tool timeline ──"), "{body}");
assert!(body.contains("do the thing"), "{body}");
assert!(
!body.contains("Activity chunk:"),
"Ctrl+O must open the turn overview, not the single-cell detail: {body}"
);
}
#[test]
fn engine_error_finalizes_active_thinking_block() {
use crate::error_taxonomy::StreamError;