use crate::snapshot::SnapshotRepo;
use crate::tui::app::App;
use crate::tui::footer_ui::one_line_summary;
use crate::tui::history::{HistoryCell, ToolCell, ToolStatus};
use crate::tui::pager::PagerView;
use crate::tui::ui_text::{
history_cell_to_clipboard_text, history_cell_to_text, truncate_line_to_width,
};
fn selected_transcript_cell_index(app: &App) -> Option<usize> {
app.viewport
.transcript_selection
.ordered_endpoints()
.and_then(|(start, _)| {
app.viewport
.transcript_cache
.line_meta()
.get(start.line_index)
.and_then(|meta| meta.cell_line())
.map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index))
})
}
pub(super) fn open_reasoning_detail_pager(app: &mut App) -> bool {
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let Some(text) = reasoning_detail_text(app) else {
app.status_message = Some("No reasoning detail available".to_string());
return true;
};
app.view_stack.push(PagerView::from_text(
"Reasoning Detail",
&text,
width.saturating_sub(2),
));
true
}
fn turn_range_for_index(app: &App, index: usize) -> (usize, usize) {
let end = app.virtual_cell_count();
let start = (0..index.saturating_add(1))
.rev()
.find(|&idx| {
matches!(
app.cell_at_virtual_index(idx),
Some(HistoryCell::User { .. })
)
})
.unwrap_or(0);
let turn_end = (index..end)
.find(|&idx| {
idx > index
&& matches!(
app.cell_at_virtual_index(idx),
Some(HistoryCell::User { .. })
)
})
.unwrap_or(end);
(start, turn_end)
}
pub(super) fn reasoning_detail_text(app: &App) -> Option<String> {
let selected = selected_transcript_cell_index(app).filter(|&idx| {
matches!(
app.cell_at_virtual_index(idx),
Some(HistoryCell::Thinking { .. })
)
});
let (start, end) = selected
.map(|idx| turn_range_for_index(app, idx))
.unwrap_or_else(|| current_turn_range(app));
reasoning_timeline_text(app, selected, start, end)
}
pub(super) fn reasoning_timeline_text(
app: &App,
selected_cell_index: Option<usize>,
start: usize,
end: usize,
) -> Option<String> {
let thinking_indices: Vec<usize> = (start..end)
.filter(|&idx| {
matches!(
app.cell_at_virtual_index(idx),
Some(HistoryCell::Thinking { .. })
)
})
.collect();
if thinking_indices.is_empty() {
return None;
}
let selected_position = selected_cell_index.and_then(|selected| {
thinking_indices
.iter()
.position(|&idx| idx == selected)
.map(|idx| idx + 1)
});
let total = thinking_indices.len();
let running = thinking_indices.iter().any(|&idx| {
matches!(
app.cell_at_virtual_index(idx),
Some(HistoryCell::Thinking {
streaming: true,
..
})
)
});
let mut sections = Vec::new();
if let Some(turn_id) = app.runtime_turn_id.as_ref() {
let status = humanized_turn_status(app);
sections.push(format!("Turn {} \u{00B7} {status}", short_turn_id(turn_id)));
}
sections.push("Activity: reasoning timeline".to_string());
sections.push(format!(
"Status: {} · {total} chunk{}",
if running { "running" } else { "done" },
if total == 1 { "" } else { "s" }
));
if let Some(position) = selected_position {
sections.push(format!("Selected chunk: {position} of {total}"));
if position > 1 {
let previous_index = thinking_indices[position - 2];
let preview = thinking_chunk_preview(app, previous_index);
sections.push(format!(
"Previous chunk: {} of {total} - {preview}",
position - 1
));
}
if position < total {
let next_index = thinking_indices[position];
let preview = thinking_chunk_preview(app, next_index);
sections.push(format!(
"Next chunk: {} of {total} - {preview}",
position + 1
));
}
}
sections.push(String::new());
for (position, cell_index) in thinking_indices.iter().copied().enumerate() {
let Some(HistoryCell::Thinking {
content,
streaming,
duration_secs,
}) = app.cell_at_virtual_index(cell_index)
else {
continue;
};
let position = position + 1;
let marker = if Some(position) == selected_position {
" (selected)"
} else {
""
};
let mut status = if *streaming {
"running".to_string()
} else {
"done".to_string()
};
if let Some(duration_secs) = duration_secs {
status.push_str(" · ");
status.push_str(&crate::elapsed::format_elapsed_ms(
(duration_secs * 1000.0) as u64,
));
}
sections.push(format!("Thinking chunk {position} of {total}{marker}"));
sections.push(format!("Status: {status}"));
let body = content.trim();
if body.is_empty() {
sections.push("(no reasoning text recorded)".to_string());
} else {
sections.push(body.to_string());
}
sections.push(String::new());
}
Some(sections.join("\n"))
}
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();
};
let preview = one_line_summary(content, 64);
if preview.is_empty() {
"thinking".to_string()
} else {
preview
}
}
fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> String {
match cell {
HistoryCell::Thinking { .. } => "thinking".to_string(),
HistoryCell::Error { .. } => "error".to_string(),
HistoryCell::SubAgent(_) => "sub-agent".to_string(),
HistoryCell::Tool(ToolCell::Generic(generic)) => {
crate::tui::widgets::tool_card::tool_activity_label_for_name(
&generic.name,
app.ui_locale,
)
}
HistoryCell::Tool(_) => {
detail_target_label(app, cell_index).unwrap_or_else(|| "tool activity".to_string())
}
_ => "message".to_string(),
}
}
fn tool_status_for_activity(tool: &ToolCell) -> Option<ToolStatus> {
match tool {
ToolCell::Exec(cell) => Some(cell.status),
ToolCell::Exploring(cell) => {
if cell
.entries
.iter()
.any(|entry| entry.status == ToolStatus::Running)
{
Some(ToolStatus::Running)
} else if cell
.entries
.iter()
.any(|entry| entry.status == ToolStatus::Failed)
{
Some(ToolStatus::Failed)
} else if cell
.entries
.iter()
.any(|entry| entry.status == ToolStatus::Hydrated)
{
Some(ToolStatus::Hydrated)
} else {
Some(ToolStatus::Success)
}
}
ToolCell::PlanUpdate(cell) => Some(cell.status),
ToolCell::PatchSummary(cell) => Some(cell.status),
ToolCell::Review(cell) => Some(cell.status),
ToolCell::Mcp(cell) => Some(cell.status),
ToolCell::ViewImage(_) => Some(ToolStatus::Success),
ToolCell::WebSearch(cell) => Some(cell.status),
ToolCell::Generic(cell) => Some(cell.status),
}
}
fn tool_duration_for_activity(tool: &ToolCell) -> Option<u64> {
match tool {
ToolCell::Exec(cell) => cell.duration_ms.or_else(|| {
(cell.status == ToolStatus::Running).then(|| {
u64::try_from(
cell.started_at
.map(|started| started.elapsed().as_millis())
.unwrap_or_default(),
)
.unwrap_or(u64::MAX)
})
}),
_ => None,
}
}
fn activity_status_label(status: ToolStatus) -> &'static str {
match status {
ToolStatus::Running => "running",
ToolStatus::Success => "done",
ToolStatus::Hydrated => "tool loaded - retry required",
ToolStatus::Failed => "failed",
}
}
const NO_RAW_DETAIL_HINT: &str =
"No raw detail for this item — press Ctrl+Alt+O for the turn overview.";
const RAW_DETAIL_PAGER_INTRO: &str =
"Raw detail for the selected item — press Ctrl+Alt+O for the whole-turn overview.";
pub(super) fn open_tool_details_pager(app: &mut App) -> bool {
let target_cell = detail_target_cell_index(app);
let Some(cell_index) = target_cell else {
app.status_message = Some(NO_RAW_DETAIL_HINT.to_string());
return false;
};
open_details_pager_for_cell(app, cell_index)
}
pub(super) fn spillover_pager_section(app: &App, cell_index: usize) -> Option<String> {
use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell};
let cell = app.cell_at_virtual_index(cell_index)?;
let current_session = app.current_session_id.as_deref();
let session_artifact = app
.tool_detail_record_for_cell(cell_index)
.and_then(|detail| {
app.session_artifacts.iter().find(|artifact| {
artifact.kind == crate::artifacts::ArtifactKind::ToolOutput
&& artifact.tool_call_id == detail.tool_id
&& current_session == Some(artifact.session_id.as_str())
})
});
let legacy_path = match cell {
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
spillover_path: Some(path),
..
})) => Some(path.clone()),
_ => None,
};
if session_artifact.is_none() && legacy_path.is_none() {
return None;
}
let body = session_artifact
.and_then(read_owned_session_artifact)
.or_else(|| {
legacy_path.as_deref().and_then(|path| {
current_session.and_then(|session_id| read_owned_legacy_spillover(path, session_id))
})
})
.unwrap_or_else(|| "(retained output is unavailable)".to_string());
Some(format!("── Full output ──\n\n{body}"))
}
fn read_owned_session_artifact(artifact: &crate::artifacts::ArtifactRecord) -> Option<String> {
if artifact.storage_path.is_absolute() {
return None;
}
let root = crate::artifacts::session_artifact_absolute_path(
&artifact.session_id,
std::path::Path::new(crate::artifacts::ARTIFACTS_DIR_NAME),
)?;
let candidate = crate::artifacts::session_artifact_absolute_path(
&artifact.session_id,
&artifact.storage_path,
)?;
let path = canonical_owned_file(&candidate, &root)?;
std::fs::read_to_string(path).ok()
}
fn read_owned_legacy_spillover(path: &std::path::Path, session_id: &str) -> Option<String> {
let root = crate::tools::truncate::spillover_root()?;
let path = canonical_owned_file(path, &root)?;
let ownership = crate::tools::truncate::read_legacy_spillover_ownership(&path).ok()?;
if ownership.origin_session != session_id {
return None;
}
let bytes = std::fs::read(path).ok()?;
if ownership.size_bytes != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
|| ownership.digest != crate::hashing::sha256_hex(&bytes)
{
return None;
}
String::from_utf8(bytes).ok()
}
fn canonical_owned_file(
candidate: &std::path::Path,
root: &std::path::Path,
) -> Option<std::path::PathBuf> {
if std::fs::symlink_metadata(candidate)
.ok()?
.file_type()
.is_symlink()
{
return None;
}
let root = root.canonicalize().ok()?;
let candidate = candidate.canonicalize().ok()?;
(candidate.is_file() && candidate.starts_with(root)).then_some(candidate)
}
pub(crate) fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> bool {
if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
let input = serde_json::to_string_pretty(&detail.input)
.unwrap_or_else(|_| detail.input.to_string());
let output = detail.output.as_deref().map_or(
"(not available)".to_string(),
std::string::ToString::to_string,
);
let spillover_section = spillover_pager_section(app, cell_index);
let mutation_section = match app.cell_at_virtual_index(cell_index) {
Some(HistoryCell::Tool(ToolCell::PatchSummary(cell))) => cell
.receipt
.as_ref()
.map(|receipt| format!("── Exact File change ──\n{}", receipt.inspect_text())),
_ => None,
};
let trailing_sections = [mutation_section, spillover_section]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n\n");
let content = if !trailing_sections.is_empty() {
format!(
"{RAW_DETAIL_PAGER_INTRO}\n\nTool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}\n\n{}",
detail.tool_id, detail.tool_name, input, output, trailing_sections
)
} else {
format!(
"{RAW_DETAIL_PAGER_INTRO}\n\nTool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}",
detail.tool_id, detail.tool_name, input, output
)
};
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
app.view_stack.push(PagerView::from_text(
format!("Raw detail — {}", detail.tool_name),
&content,
width.saturating_sub(2),
));
return true;
}
let Some(cell) = app.cell_at_virtual_index(cell_index) else {
app.status_message = Some(NO_RAW_DETAIL_HINT.to_string());
return false;
};
let title = match cell {
HistoryCell::User { .. } => "You".to_string(),
HistoryCell::Assistant { .. } => "Assistant".to_string(),
HistoryCell::System { .. } => "Note".to_string(),
HistoryCell::Error { .. } => "Error".to_string(),
HistoryCell::Thinking { .. } => "Reasoning".to_string(),
HistoryCell::Tool(_) => "Message".to_string(),
HistoryCell::SubAgent(_) => "Sub-agent".to_string(),
HistoryCell::ArchivedContext { .. } => "Archived Context".to_string(),
};
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let content = history_cell_to_text(cell, width);
app.view_stack.push(PagerView::from_text(
title,
&content,
width.saturating_sub(2),
));
true
}
pub(super) fn copy_focused_cell(app: &mut App) -> bool {
let cell_index = detail_target_cell_index(app);
let Some(index) = cell_index else {
return false;
};
copy_cell_to_clipboard(app, index)
}
pub(crate) fn copy_cell_to_clipboard(app: &mut App, cell_index: usize) -> bool {
let Some(cell) = app.cell_at_virtual_index(cell_index) else {
app.status_message = Some("No message at that line".to_string());
return false;
};
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
let text = history_cell_to_clipboard_text(cell, width);
if text.trim().is_empty() {
app.status_message = Some("Message is empty".to_string());
return false;
}
if app.clipboard.write_text(&text).is_ok() {
app.status_message = Some("Message copied".to_string());
true
} else {
app.status_message = Some("Copy failed".to_string());
false
}
}
pub(super) fn detail_target_cell_index(app: &App) -> Option<usize> {
if let Some((start, _)) = app.viewport.transcript_selection.ordered_endpoints() {
return app
.viewport
.transcript_cache
.line_meta()
.get(start.line_index)
.and_then(|meta| meta.cell_line())
.map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index));
}
app.detail_cell_index_for_viewport(
app.viewport.last_transcript_top,
app.viewport.last_transcript_visible.max(1),
app.viewport.transcript_cache.line_meta(),
)
.or_else(|| app.virtual_cell_count().checked_sub(1))
}
pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option<String> {
if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
return Some(detail.tool_name.clone());
}
let cell = app.cell_at_virtual_index(cell_index)?;
match cell {
HistoryCell::Tool(ToolCell::Exec(exec)) => {
Some(format!("run {}", one_line_summary(&exec.command, 80)))
}
HistoryCell::Tool(ToolCell::Exploring(explore)) => Some(format!(
"workspace {} item{}",
explore.entries.len(),
if explore.entries.len() == 1 { "" } else { "s" }
)),
HistoryCell::Tool(ToolCell::PlanUpdate(_)) => Some("legacy plan update".to_string()),
HistoryCell::Tool(ToolCell::PatchSummary(patch)) => Some(format!("patch {}", patch.path)),
HistoryCell::Tool(ToolCell::Review(review)) => {
let target = one_line_summary(&review.target, 80);
Some(if target.is_empty() {
"review".to_string()
} else {
format!("review {target}")
})
}
HistoryCell::Tool(ToolCell::Mcp(mcp)) => Some(format!("tool {}", mcp.tool)),
HistoryCell::Tool(ToolCell::ViewImage(image)) => {
Some(format!("image {}", image.path.display()))
}
HistoryCell::Tool(ToolCell::WebSearch(search)) => Some(format!("search {}", search.query)),
HistoryCell::Tool(ToolCell::Generic(generic)) => Some(
crate::tui::widgets::tool_card::tool_activity_label_for_name(
&generic.name,
app.ui_locale,
),
),
HistoryCell::SubAgent(_) => Some("sub-agent".to_string()),
HistoryCell::Error { .. } => Some("full error message".to_string()),
_ => None,
}
}
pub(super) fn extract_reasoning_header(text: &str) -> Option<String> {
let start = text.find("**")?;
let rest = &text[start + 2..];
let end = rest.find("**")?;
let header = rest[..end].trim().trim_end_matches(':');
if header.is_empty() {
None
} else {
Some(header.to_string())
}
}
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);
let handoff = turn_handoff_markdown(app);
app.view_stack.push(
PagerView::from_text("Turn Inspector", &text, width.saturating_sub(2))
.with_copy_text(text)
.with_export_markdown(handoff),
);
true
}
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)
}
fn humanized_turn_status(app: &App) -> &str {
match app.runtime_turn_status.as_deref() {
Some("in_progress") | None => "in progress",
Some(other) => other,
}
}
fn short_turn_id(turn_id: &str) -> &str {
turn_id.get(..12).unwrap_or(turn_id)
}
pub(super) fn turn_inspector_text(app: &App) -> String {
let (start, end) = current_turn_range(app);
let mut out: Vec<String> = Vec::new();
let status = humanized_turn_status(app);
if app.turn_counter > 0 {
let mut line = format!("Turn #{} \u{00B7} {status}", app.turn_counter);
if let Some(turn_id) = app.runtime_turn_id.as_ref() {
line.push_str(&format!(" \u{00B7} id {}", short_turn_id(turn_id)));
}
out.push(line);
} else if let Some(turn_id) = app.runtime_turn_id.as_ref() {
out.push(format!("Turn {} \u{00B7} {status}", short_turn_id(turn_id)));
} else {
out.push("Turn: \u{2014} (no turn recorded yet)".to_string());
}
let details = crate::tui::shell_key_routing::display_chord(
crate::tui::shell_key_routing::binding(
crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
)
.footer_chord,
);
out.push(format!(
"Overview of the current/latest turn · press {details} for the selected item's raw detail"
));
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, "To-do", turn_todo_lines(app));
push_section(
&mut out,
"Turn timeline",
turn_timeline_lines(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, ResultDetail::Full),
);
out.join("\n")
}
pub(crate) fn turn_handoff_markdown(app: &App) -> String {
let (start, end) = current_turn_range(app);
let mut out: Vec<String> = Vec::new();
let heading = if app.turn_counter > 0 {
format!("# Turn handoff — Turn #{}", app.turn_counter)
} else if let Some(turn_id) = app.runtime_turn_id.as_ref() {
format!("# Turn handoff — {}", short_turn_id(turn_id))
} else {
"# Turn handoff".to_string()
};
out.push(heading);
let status = match app.runtime_turn_status.as_deref() {
Some("in_progress") => "in progress",
Some(other) => other,
None => "idle",
};
out.push(format!(
"_Status: {status} · generated {}_",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
));
push_md_section(&mut out, "Intent", vec![turn_intent_line(app, start)]);
let todos = turn_todo_lines(app);
if !todos.is_empty() {
push_md_section(&mut out, "To-do", md_bullets(todos));
}
push_md_section(
&mut out,
"Files changed",
md_bullets(turn_files_changed(app, start, end)),
);
push_md_section(
&mut out,
"Turn timeline",
md_bullets(turn_timeline_lines(app, start, end)),
);
push_md_section(
&mut out,
"Tests / verifier",
md_bullets(turn_verifier_lines(app, start, end)),
);
push_md_section(
&mut out,
"Model route + tokens/cost",
md_bullets(turn_route_lines(app)),
);
push_md_section(
&mut out,
"Result / status",
md_bullets(turn_result_lines(app, start, end, ResultDetail::Compact)),
);
out.push(String::new());
out.join("\n")
}
fn push_md_section(out: &mut Vec<String>, title: &str, body: Vec<String>) {
out.push(String::new());
out.push(format!("## {title}"));
if body.is_empty() {
out.push("—".to_string());
} else {
out.extend(body);
}
}
fn md_bullets(lines: Vec<String>) -> Vec<String> {
lines
.into_iter()
.map(|line| {
let body = line.strip_prefix("• ").unwrap_or(line.as_str());
format!("- {body}")
})
.collect()
}
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);
}
}
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()
}
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) {
let details = crate::tui::shell_key_routing::display_chord(
crate::tui::shell_key_routing::binding(
crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
)
.footer_chord,
);
if matches!(cell, HistoryCell::Error { .. }) {
format!(" · {details} opens the full error")
} else {
format!(" · {details} opens its raw detail")
}
} else {
String::new()
};
Some(format!("{label}{hint}"))
}
fn turn_todo_lines(app: &App) -> Vec<String> {
let mut lines = Vec::new();
if let Ok(todos) = app.todos.try_lock() {
let snapshot = todos.snapshot();
if !snapshot.items.is_empty() {
lines.push(format!("To-do: {}% settled", 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 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 => "[ ]",
crate::tools::todo::TodoStatus::Cancelled => "[-]",
}
}
fn turn_timeline_lines(app: &App, start: usize, end: usize) -> Vec<String> {
let mut rows = Vec::new();
for idx in start..end {
let Some(cell) = app.cell_at_virtual_index(idx) else {
continue;
};
match cell {
HistoryCell::User { content } => {
let summary = one_line_summary(content, 96);
rows.push(timeline_row("user prompt", &summary, None, None, &[]));
}
HistoryCell::Thinking {
content,
streaming,
duration_secs,
} => {
let summary = one_line_summary(content, 88);
let status = streaming.then_some("running").unwrap_or("done");
let duration = duration_secs
.map(|secs| crate::elapsed::format_elapsed_ms((secs * 1000.0) as u64));
let actions = timeline_cell_actions(app, idx, cell);
rows.push(timeline_row(
"reasoning",
&summary,
Some(status),
duration.as_deref(),
&actions,
));
}
HistoryCell::Tool(tool) => {
let (kind, summary) = timeline_tool_summary(app, idx, tool);
let duration =
tool_duration_for_activity(tool).map(crate::elapsed::format_elapsed_ms);
let status = tool_status_for_activity(tool).map(activity_status_label);
let actions = timeline_cell_actions(app, idx, cell);
rows.push(timeline_row(
kind,
&summary,
status,
duration.as_deref(),
&actions,
));
}
HistoryCell::SubAgent(_) => {
let summary = detail_target_label(app, idx).unwrap_or_else(|| "sub-agent".into());
let actions = timeline_cell_actions(app, idx, cell);
rows.push(timeline_row("sub-agent", &summary, None, None, &actions));
}
HistoryCell::Assistant { content, streaming } => {
let summary = one_line_summary(content, 96);
let status = streaming.then_some("streaming").unwrap_or("done");
rows.push(timeline_row(
"assistant result",
&summary,
Some(status),
None,
&[],
));
}
HistoryCell::Error { message, severity } => {
let summary = one_line_summary(message, 96);
let status = severity.to_string();
rows.push(timeline_row("error", &summary, Some(&status), None, &[]));
}
HistoryCell::System { content }
| HistoryCell::ArchivedContext {
summary: content, ..
} => {
let summary = one_line_summary(content, 96);
rows.push(timeline_row("system note", &summary, None, None, &[]));
}
}
}
rows.push(turn_checkpoint_timeline_row(app));
rows.into_iter()
.enumerate()
.map(|(idx, row)| format!("{}. {row}", idx + 1))
.collect()
}
fn timeline_tool_summary(app: &App, idx: usize, tool: &ToolCell) -> (&'static str, String) {
match tool {
ToolCell::Exec(exec) if command_looks_like_verifier(&exec.command) => {
("test/verifier", truncate_line_to_width(&exec.command, 88))
}
ToolCell::Exec(exec) => ("shell command", truncate_line_to_width(&exec.command, 88)),
ToolCell::Exploring(explore) => (
"read/search",
format!(
"{} item{}",
explore.entries.len(),
if explore.entries.len() == 1 { "" } else { "s" }
),
),
ToolCell::PlanUpdate(_) => ("legacy plan", "Legacy plan metadata replayed".to_string()),
ToolCell::PatchSummary(patch) => {
let summary = one_line_summary(&patch.summary, 72);
if summary.is_empty() {
("edit", truncate_line_to_width(&patch.path, 88))
} else {
(
"edit",
truncate_line_to_width(&format!("{} — {summary}", patch.path), 88),
)
}
}
ToolCell::Review(review) => {
let target = one_line_summary(&review.target, 88);
(
"review",
if target.is_empty() {
"code review".to_string()
} else {
target
},
)
}
ToolCell::Mcp(mcp) => ("MCP tool", truncate_line_to_width(&mcp.tool, 88)),
ToolCell::ViewImage(image) => (
"image",
truncate_line_to_width(&image.path.display().to_string(), 88),
),
ToolCell::WebSearch(search) => ("web search", truncate_line_to_width(&search.query, 88)),
ToolCell::Generic(generic) => {
let mut label =
detail_target_label(app, idx).unwrap_or_else(|| generic.name.replace('_', " "));
if let Some(input) = generic.input_summary.as_deref().map(str::trim)
&& !input.is_empty()
{
label.push_str(" · ");
label.push_str(input);
}
(
generic_tool_timeline_kind(generic),
truncate_line_to_width(&label, 88),
)
}
}
}
fn generic_tool_timeline_kind(generic: &crate::tui::history::GenericToolCell) -> &'static str {
let name = generic.name.as_str();
if generic.is_diff || name.contains("diff") {
"diff"
} else if matches!(name, "read_file" | "list_files" | "glob" | "grep_files")
|| name.contains("read")
|| name.contains("search")
|| name.contains("grep")
{
"read/search"
} else if matches!(name, "apply_patch" | "edit_file" | "write_file")
|| name.contains("patch")
|| name.contains("edit")
|| name.contains("write")
{
"edit"
} else if name.contains("approval") {
"approval"
} else if name.contains("diagnostic") || name.contains("lsp") {
"diagnostics"
} else {
"tool"
}
}
fn timeline_cell_actions(app: &App, idx: usize, cell: &HistoryCell) -> Vec<String> {
let mut actions = Vec::new();
if app.cell_has_detail_target(idx) {
let details = crate::tui::shell_key_routing::display_chord(
crate::tui::shell_key_routing::binding(
crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
)
.footer_chord,
);
let is_diff = matches!(cell, HistoryCell::Tool(ToolCell::PatchSummary(_)))
|| matches!(
cell,
HistoryCell::Tool(ToolCell::Generic(generic)) if generic.is_diff
);
if is_diff {
actions.push(format!("{details} diff"));
} else if matches!(cell, HistoryCell::Error { .. }) {
actions.push(format!("{details} full error"));
} else {
actions.push(format!("{details} raw detail"));
}
}
actions
}
fn timeline_row(
kind: &str,
summary: &str,
status: Option<&str>,
duration: Option<&str>,
actions: &[String],
) -> String {
let mut line = if summary.trim().is_empty() {
kind.to_string()
} else {
format!("{kind}: {}", summary.trim())
};
if let Some(status) = status.filter(|s| !s.trim().is_empty()) {
line.push_str(" — ");
line.push_str(status);
}
if let Some(duration) = duration.filter(|s| !s.trim().is_empty()) {
line.push_str(" · ");
line.push_str(duration);
}
if !actions.is_empty() {
line.push_str(" · actions: ");
line.push_str(&actions.join(", "));
}
line
}
fn turn_checkpoint_timeline_row(app: &App) -> String {
if app.turn_counter == 0 {
return "checkpoint: unavailable — no numbered turn snapshot yet · action: e export handoff"
.to_string();
}
let repo = match SnapshotRepo::open_existing(&app.workspace) {
Ok(Some(repo)) => repo,
Ok(None) => {
return "checkpoint: unavailable — no snapshot repo found · action: e export handoff"
.to_string();
}
Err(err) => {
return format!(
"checkpoint: unknown — snapshot repo could not be opened ({}) · action: e export handoff",
truncate_line_to_width(&err.to_string(), 72)
);
}
};
let snapshots = match repo.list(20) {
Ok(snapshots) => snapshots,
Err(err) => {
return format!(
"checkpoint: unknown — snapshot list failed ({}) · action: e export handoff",
truncate_line_to_width(&err.to_string(), 72)
);
}
};
let prefix = format!("pre-turn:{}", app.turn_counter);
let matching = snapshots
.iter()
.find(|snapshot| {
snapshot.label == prefix || snapshot.label.starts_with(&format!("{prefix}:"))
})
.or_else(|| {
snapshots
.iter()
.find(|snapshot| snapshot.label.starts_with("pre-turn:"))
});
if let Some(snapshot) = matching {
let short = &snapshot.id.as_str()[..snapshot.id.as_str().len().min(8)];
format!(
"checkpoint: {} ({short}) available · actions: r restore via /restore (guarded), e export handoff",
truncate_line_to_width(&snapshot.label, 72)
)
} else {
"checkpoint: unavailable — no pre-turn snapshot found · action: e export handoff"
.to_string()
}
}
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)
));
}
_ => {}
}
}
lines
}
fn turn_diagnostics_lines(app: &App) -> Vec<String> {
if !app.lsp_enabled {
return Vec::new();
}
let repair = &app.lsp_repair;
if repair.diagnostics_found == 0 && !repair.injected && !repair.repair_attempted {
return Vec::new();
}
let mut lines = Vec::new();
if repair.diagnostics_found > 0 {
lines.push(format!(
"Found {} diagnostic{} across {} file{}",
repair.diagnostics_found,
if repair.diagnostics_found == 1 {
""
} else {
"s"
},
repair.files_touched.max(1),
if repair.files_touched == 1 { "" } else { "s" },
));
}
lines.push(if repair.injected {
"Injected into the next model request".to_string()
} else {
"Queued — not yet injected".to_string()
});
if repair.repair_attempted {
lines.push("Model attempted a repair after injection".to_string());
}
let latest = match repair.latest {
"resolved" => "Latest: resolved",
"still_failing" => "Latest: still failing",
"unavailable" => "Latest: unavailable",
_ => "Latest: unknown",
};
lines.push(latest.to_string());
lines
}
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))
}
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
}
fn turn_route_lines(app: &App) -> Vec<String> {
let mut lines = Vec::new();
let (provider, model) = if let Some(route) = app
.active_turn
.as_ref()
.and_then(|turn| turn.route.as_ref())
{
let provider = if route.provider == crate::config::ApiProvider::Custom {
route.provider_identity.clone()
} else {
route.provider.display_name().to_string()
};
(provider, route.model.clone())
} else {
app.effective_route_identity_display()
};
lines.push(format!("Route: {provider} · {model}"));
let auto_receipt = app
.active_turn
.as_ref()
.filter(|turn| turn.route.as_ref().is_some_and(|route| route.auto_model))
.and_then(|turn| turn.auto_route_receipt.as_ref())
.or_else(|| {
app.pending_turn_route
.as_ref()
.filter(|(_, _, auto_model)| *auto_model)
.and(app.pending_auto_route_receipt.as_ref())
})
.or_else(|| {
app.auto_model
.then_some(app.last_auto_route_receipt.as_ref())
.flatten()
});
if let Some(receipt) = auto_receipt {
lines.push(format!(
"Auto decision: {} · {}",
receipt.tier.label(),
receipt.reason.label()
));
let pair = receipt.pair.fast.as_deref().map_or_else(
|| format!("{} (no runnable fast sibling)", receipt.pair.strong),
|fast| format!("{} strong · {fast} fast", receipt.pair.strong),
);
lines.push(format!("Auto pair: {pair}"));
lines.push(format!("Auto scope: {}", receipt.scope.label()));
lines.push(format!("Auto data: {}", receipt.data_path.label()));
}
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 chip = app.cumulative_usage_chip();
match &chip {
crate::route_billing::UsageChip::Money(amount) => {
lines.push(format!("Cost (session): {amount}"));
}
crate::route_billing::UsageChip::PricedSubtotal { .. } => {
lines.push(format!(
"Cost (session): {}",
crate::route_billing::format_usage_chip(&chip).unwrap_or_default()
));
}
crate::route_billing::UsageChip::Allowance { label, used_pct } => {
lines.push(match used_pct {
Some(pct) => format!("Usage plan: {label} ({pct:.0}% used)"),
None => format!("Usage plan: {label}"),
});
}
crate::route_billing::UsageChip::Local => {
lines.push("Cost: local".to_string());
}
crate::route_billing::UsageChip::Unknown => {
lines.push("Cost: unknown".to_string());
}
crate::route_billing::UsageChip::Hidden => {}
}
lines
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResultDetail {
Full,
Compact,
}
fn cleaned_turn_text(text: &str, detail: ResultDetail, max_width: usize) -> String {
if detail == ResultDetail::Compact {
return one_line_summary(text, max_width);
}
let mut cleaned = String::with_capacity(text.len());
crate::tui::osc8::strip_ansi_into(text, &mut cleaned);
cleaned.trim().to_string()
}
fn turn_result_lines(app: &App, start: usize, end: usize, detail: ResultDetail) -> 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 text = cleaned_turn_text(content, detail, 200);
(!text.is_empty()).then_some(text)
}
_ => 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 text = cleaned_turn_text(message, detail, 160);
(!text.is_empty()).then_some(text)
}
_ => None,
});
if let Some(err) = error_text {
lines.push(format!("Error: {err}"));
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, LspRepairState, TuiOptions};
use std::path::PathBuf;
fn test_app() -> App {
let options = TuiOptions {
model: "deepseek-v4-flash".to_string(),
start_in_agent_mode: true,
..crate::test_support::test_tui_options(PathBuf::from("."))
};
App::new(options, &Config::default())
}
#[test]
fn turn_diagnostics_lines_quiet_when_no_activity() {
let mut app = test_app();
app.lsp_enabled = true;
assert!(turn_diagnostics_lines(&app).is_empty());
app.lsp_enabled = false;
assert!(turn_diagnostics_lines(&app).is_empty());
}
#[test]
fn turn_diagnostics_lines_summarize_repair_loop() {
let mut app = test_app();
app.lsp_enabled = true;
app.lsp_repair = LspRepairState {
diagnostics_found: 2,
files_touched: 1,
injected: true,
repair_attempted: true,
latest: "still_failing",
};
let joined = turn_diagnostics_lines(&app).join("\n");
assert!(joined.contains("Found 2 diagnostics"), "{joined}");
assert!(
joined.contains("Injected into the next model request"),
"{joined}"
);
assert!(joined.contains("Model attempted a repair"), "{joined}");
assert!(joined.contains("still failing"), "{joined}");
}
#[test]
fn turn_route_lines_include_truthful_auto_receipt() {
let mut app = test_app();
app.auto_model = true;
app.last_effective_provider = Some(crate::config::ApiProvider::Zai);
app.last_effective_model = Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string());
app.last_auto_route_receipt = Some(crate::model_routing::AutoRouteReceipt {
tier: crate::model_routing::AutoRouteTier::Fast,
pair: crate::model_routing::AutoRoutePair {
strong: crate::config::ZAI_GLM_5_2_MODEL.to_string(),
fast: Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()),
},
scope: crate::model_routing::AutoRouteScope::RunnableProviders,
data_path: crate::model_routing::AutoRouteDataPath::Classifier {
provider: crate::config::ApiProvider::Deepseek,
model: "deepseek-v4-flash".to_string(),
},
reason: crate::model_routing::AutoRouteReason::ClassifierRecommendation,
});
let joined = turn_route_lines(&app).join("\n");
assert!(joined.contains("Route: Zhipu AI / Z.ai · GLM-5-Turbo"));
assert!(joined.contains("Auto decision: fast · classifier recommendation"));
assert!(joined.contains("GLM-5.2 strong · GLM-5-Turbo fast"));
assert!(joined.contains("Auto scope: runnable providers"));
assert!(joined.contains(
"Auto data: latest request + bounded recent context -> DeepSeek / deepseek-v4-flash"
));
assert!(!joined.contains("API_KEY"));
}
#[test]
fn reasoning_detail_text_empty_when_no_thinking() {
let app = test_app();
assert!(reasoning_detail_text(&app).is_none());
}
#[test]
fn reasoning_detail_text_includes_active_cell_reasoning() {
let mut app = test_app();
let mut active = crate::tui::active_cell::ActiveCell::new();
active.push_thinking(HistoryCell::Thinking {
content: "active reasoning one".to_string(),
streaming: true,
duration_secs: None,
});
active.push_thinking(HistoryCell::Thinking {
content: "active reasoning two".to_string(),
streaming: false,
duration_secs: Some(1.0),
});
app.active_cell = Some(active);
app.runtime_turn_id = Some("turn-active-123".to_string());
app.runtime_turn_status = Some("in_progress".to_string());
let body = reasoning_detail_text(&app).expect("active reasoning should produce detail");
assert!(body.contains("Thinking chunk 1 of 2"), "{body}");
assert!(body.contains("Thinking chunk 2 of 2"), "{body}");
assert!(body.contains("active reasoning one"), "{body}");
assert!(body.contains("active reasoning two"), "{body}");
assert!(body.contains("running"), "{body}");
}
#[test]
fn reasoning_detail_text_scopes_to_latest_turn_without_selection() {
let mut app = test_app();
app.history = vec![
HistoryCell::User {
content: "first prompt".to_string(),
},
HistoryCell::Thinking {
content: "first turn reasoning".to_string(),
streaming: false,
duration_secs: Some(1.0),
},
HistoryCell::Assistant {
content: "first reply".to_string(),
streaming: false,
},
HistoryCell::User {
content: "second prompt".to_string(),
},
HistoryCell::Thinking {
content: "second turn reasoning".to_string(),
streaming: false,
duration_secs: Some(1.0),
},
HistoryCell::Assistant {
content: "second reply".to_string(),
streaming: false,
},
];
app.resync_history_revisions();
let body =
reasoning_detail_text(&app).expect("latest turn reasoning should produce detail");
assert!(body.contains("second turn reasoning"), "{body}");
assert!(
!body.contains("first turn reasoning"),
"reasoning detail without selection must scope to the latest turn: {body}"
);
}
#[test]
fn turn_range_for_index_scopes_to_containing_turn() {
let mut app = test_app();
app.history = vec![
HistoryCell::User {
content: "first prompt".to_string(),
},
HistoryCell::Thinking {
content: "first turn reasoning".to_string(),
streaming: false,
duration_secs: Some(1.0),
},
HistoryCell::Assistant {
content: "first reply".to_string(),
streaming: false,
},
HistoryCell::User {
content: "second prompt".to_string(),
},
HistoryCell::Thinking {
content: "second turn reasoning".to_string(),
streaming: false,
duration_secs: Some(1.0),
},
HistoryCell::Assistant {
content: "second reply".to_string(),
streaming: false,
},
];
app.resync_history_revisions();
let (start, end) = turn_range_for_index(&app, 1);
assert_eq!(start, 0, "first turn should start at user cell 0");
assert_eq!(end, 3, "first turn should end before second user cell");
let (start, end) = turn_range_for_index(&app, 4);
assert_eq!(start, 3, "second turn should start at user cell 3");
assert_eq!(end, 6, "second turn should run to end of transcript");
}
#[test]
fn open_reasoning_detail_pager_pushes_reasoning_detail_pager() {
let mut app = test_app();
app.history = vec![HistoryCell::Thinking {
content: "recorded reasoning".to_string(),
streaming: false,
duration_secs: Some(1.0),
}];
app.resync_history_revisions();
let revisions = app.history_revisions.clone();
app.viewport.transcript_cache.ensure(
&app.history,
&revisions,
100,
app.transcript_render_options(),
);
app.viewport.last_transcript_area = Some(ratatui::layout::Rect {
x: 0,
y: 0,
width: 80,
height: 24,
});
assert!(open_reasoning_detail_pager(&mut app));
let top = app.view_stack.top_kind();
assert_eq!(top, Some(crate::tui::views::ModalKind::Pager));
}
#[test]
fn copy_cell_to_clipboard_uses_canonical_assistant_source() {
let mut app = test_app();
let content = "A long response with literal ● and ▏ glyphs that wraps visually.";
app.history = vec![HistoryCell::Assistant {
content: content.to_string(),
streaming: false,
}];
app.resync_history_revisions();
app.viewport.last_transcript_area = Some(ratatui::layout::Rect {
x: 0,
y: 0,
width: 12,
height: 24,
});
assert!(copy_cell_to_clipboard(&mut app, 0));
assert_eq!(app.clipboard.last_written_text(), Some(content));
}
}