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:
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
"KbLastMessagePager": "最後のメッセージのページャーを開く(入力が空の時)",
|
||||
"KbSelectedDetails": "選択中のツールまたはメッセージの詳細を開く(入力が空の時)",
|
||||
"KbToolDetailsPager": "ツール詳細のページャーを開く",
|
||||
"KbThinkingPager": "Activity Detail を開く",
|
||||
"KbThinkingPager": "ターン インスペクターを開く",
|
||||
"KbLiveTranscript": "ライブ会話履歴オーバーレイを開く(自動追尾スクロール)",
|
||||
"KbBacktrackMessage": "前のユーザーメッセージに戻る(左右でステップ、Enter で巻き戻し)",
|
||||
"KbCompleteCycleModes": "/command を補完、実行中ターンのフォローアップをキュー、モードを切り替え;Shift+Tab で推論強度を切り替え",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
"KbLastMessagePager": "打开最后一条消息的分页器(输入框为空时)",
|
||||
"KbSelectedDetails": "打开选中工具或消息的详情(输入框为空时)",
|
||||
"KbToolDetailsPager": "打开工具详情分页器",
|
||||
"KbThinkingPager": "打开 Activity Detail",
|
||||
"KbThinkingPager": "打开回合检查器 (Turn Inspector)",
|
||||
"KbLiveTranscript": "打开实时对话覆盖层(自动滚动尾随)",
|
||||
"KbBacktrackMessage": "回退到之前的用户消息(左右键步进,Enter 回退)",
|
||||
"KbCompleteCycleModes": "补全 /command、排队运行轮次跟进、切换模式;Shift+Tab 切换推理强度",
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user