返回 CodeWhale
activity_detail.rs
根目录 / crates / tui / src / tui / ui / activity_detail.rs
1 //! Reasoning Detail, Turn Inspector, raw tool-detail, and pager-text helpers
2 //! extracted from `ui.rs` (issue #4103).
3 //!
4 //! Ctrl+O opens the full recorded Reasoning Detail timeline for the selected
5 //! reasoning block or the current/latest turn. The whole-turn Turn Inspector
6 //! moved to a dedicated surface (Ctrl+Alt+O and `/turn inspect`). The `v` raw
7 //! tool-details pager (including #500 spillover folding), copy-cell actions, and
8 //! footer detail labels live here too.
9
10 use crate::snapshot::SnapshotRepo;
11 use crate::tui::app::App;
12 use crate::tui::footer_ui::one_line_summary;
13 use crate::tui::history::{HistoryCell, ToolCell, ToolStatus};
14 use crate::tui::pager::PagerView;
15 use crate::tui::ui_text::{history_cell_to_text, truncate_line_to_width};
16
17 fn selected_transcript_cell_index(app: &App) -> Option<usize> {
18 app.viewport
19 .transcript_selection
20 .ordered_endpoints()
21 .and_then(|(start, _)| {
22 app.viewport
23 .transcript_cache
24 .line_meta()
25 .get(start.line_index)
26 .and_then(|meta| meta.cell_line())
27 .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index))
28 })
29 }
30
31 /// Open the full recorded-reasoning detail pager for the selected thinking
32 /// block, or for the current/latest turn when no reasoning block is selected.
33 /// Ctrl+O routes here; only provider-supplied reasoning is shown.
34 pub(super) fn open_reasoning_detail_pager(app: &mut App) -> bool {
35 let width = app
36 .viewport
37 .last_transcript_area
38 .map(|area| area.width)
39 .unwrap_or(80);
40 let Some(text) = reasoning_detail_text(app) else {
41 app.status_message = Some("No reasoning detail available".to_string());
42 return true;
43 };
44 app.view_stack.push(PagerView::from_text(
45 "Reasoning Detail",
46 &text,
47 width.saturating_sub(2),
48 ));
49 true
50 }
51
52 /// Resolve the turn range that contains the given virtual cell index.
53 /// The turn starts at the most recent user cell at or before the index and
54 /// ends at the next user cell after the index, or the end of the transcript.
55 fn turn_range_for_index(app: &App, index: usize) -> (usize, usize) {
56 let end = app.virtual_cell_count();
57 let start = (0..index.saturating_add(1))
58 .rev()
59 .find(|&idx| {
60 matches!(
61 app.cell_at_virtual_index(idx),
62 Some(HistoryCell::User { .. })
63 )
64 })
65 .unwrap_or(0);
66 let turn_end = (index..end)
67 .find(|&idx| {
68 idx > index
69 && matches!(
70 app.cell_at_virtual_index(idx),
71 Some(HistoryCell::User { .. })
72 )
73 })
74 .unwrap_or(end);
75 (start, turn_end)
76 }
77
78 /// Assemble the full recorded reasoning for the selected thinking block's
79 /// turn, or for the current/latest turn when nothing is selected. Empty
80 /// chunks are surfaced as "(no reasoning text recorded)" rather than invented.
81 pub(super) fn reasoning_detail_text(app: &App) -> Option<String> {
82 let selected = selected_transcript_cell_index(app).filter(|&idx| {
83 matches!(
84 app.cell_at_virtual_index(idx),
85 Some(HistoryCell::Thinking { .. })
86 )
87 });
88 let (start, end) = selected
89 .map(|idx| turn_range_for_index(app, idx))
90 .unwrap_or_else(|| current_turn_range(app));
91 reasoning_timeline_text(app, selected, start, end)
92 }
93
94 /// Build the full recorded-reasoning text for a turn-scoped set of thinking
95 /// cells. Only provider-supplied reasoning Codewhale actually recorded is
96 /// shown; nothing is fabricated when a chunk is empty.
97 pub(super) fn reasoning_timeline_text(
98 app: &App,
99 selected_cell_index: Option<usize>,
100 start: usize,
101 end: usize,
102 ) -> Option<String> {
103 let thinking_indices: Vec<usize> = (start..end)
104 .filter(|&idx| {
105 matches!(
106 app.cell_at_virtual_index(idx),
107 Some(HistoryCell::Thinking { .. })
108 )
109 })
110 .collect();
111 if thinking_indices.is_empty() {
112 return None;
113 }
114
115 let selected_position = selected_cell_index.and_then(|selected| {
116 thinking_indices
117 .iter()
118 .position(|&idx| idx == selected)
119 .map(|idx| idx + 1)
120 });
121 let total = thinking_indices.len();
122 let running = thinking_indices.iter().any(|&idx| {
123 matches!(
124 app.cell_at_virtual_index(idx),
125 Some(HistoryCell::Thinking {
126 streaming: true,
127 ..
128 })
129 )
130 });
131
132 let mut sections = Vec::new();
133 if let Some(turn_id) = app.runtime_turn_id.as_ref() {
134 let status = humanized_turn_status(app);
135 sections.push(format!("Turn {} \u{00B7} {status}", short_turn_id(turn_id)));
136 }
137 sections.push("Activity: reasoning timeline".to_string());
138 sections.push(format!(
139 "Status: {} · {total} chunk{}",
140 if running { "running" } else { "done" },
141 if total == 1 { "" } else { "s" }
142 ));
143 if let Some(position) = selected_position {
144 sections.push(format!("Selected chunk: {position} of {total}"));
145 if position > 1 {
146 let previous_index = thinking_indices[position - 2];
147 let preview = thinking_chunk_preview(app, previous_index);
148 sections.push(format!(
149 "Previous chunk: {} of {total} - {preview}",
150 position - 1
151 ));
152 }
153 if position < total {
154 let next_index = thinking_indices[position];
155 let preview = thinking_chunk_preview(app, next_index);
156 sections.push(format!(
157 "Next chunk: {} of {total} - {preview}",
158 position + 1
159 ));
160 }
161 }
162 sections.push(String::new());
163
164 for (position, cell_index) in thinking_indices.iter().copied().enumerate() {
165 let Some(HistoryCell::Thinking {
166 content,
167 streaming,
168 duration_secs,
169 }) = app.cell_at_virtual_index(cell_index)
170 else {
171 continue;
172 };
173 let position = position + 1;
174 let marker = if Some(position) == selected_position {
175 " (selected)"
176 } else {
177 ""
178 };
179 let mut status = if *streaming {
180 "running".to_string()
181 } else {
182 "done".to_string()
183 };
184 if let Some(duration_secs) = duration_secs {
185 status.push_str(" · ");
186 status.push_str(&crate::elapsed::format_elapsed_ms(
187 (duration_secs * 1000.0) as u64,
188 ));
189 }
190 sections.push(format!("Thinking chunk {position} of {total}{marker}"));
191 sections.push(format!("Status: {status}"));
192 let body = content.trim();
193 if body.is_empty() {
194 sections.push("(no reasoning text recorded)".to_string());
195 } else {
196 sections.push(body.to_string());
197 }
198 sections.push(String::new());
199 }
200
201 Some(sections.join("\n"))
202 }
203
204 fn thinking_chunk_preview(app: &App, cell_index: usize) -> String {
205 let Some(HistoryCell::Thinking { content, .. }) = app.cell_at_virtual_index(cell_index) else {
206 return "thinking".to_string();
207 };
208 let preview = one_line_summary(content, 64);
209 if preview.is_empty() {
210 "thinking".to_string()
211 } else {
212 preview
213 }
214 }
215
216 fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> String {
217 match cell {
218 HistoryCell::Thinking { .. } => "thinking".to_string(),
219 HistoryCell::Error { .. } => "error".to_string(),
220 HistoryCell::SubAgent(_) => "sub-agent".to_string(),
221 HistoryCell::Tool(ToolCell::Generic(generic)) => {
222 crate::tui::widgets::tool_card::tool_activity_label_for_name(
223 &generic.name,
224 app.ui_locale,
225 )
226 }
227 HistoryCell::Tool(_) => {
228 detail_target_label(app, cell_index).unwrap_or_else(|| "tool activity".to_string())
229 }
230 _ => "message".to_string(),
231 }
232 }
233
234 fn tool_status_for_activity(tool: &ToolCell) -> Option<ToolStatus> {
235 match tool {
236 ToolCell::Exec(cell) => Some(cell.status),
237 ToolCell::Exploring(cell) => {
238 if cell
239 .entries
240 .iter()
241 .any(|entry| entry.status == ToolStatus::Running)
242 {
243 Some(ToolStatus::Running)
244 } else if cell
245 .entries
246 .iter()
247 .any(|entry| entry.status == ToolStatus::Failed)
248 {
249 Some(ToolStatus::Failed)
250 } else if cell
251 .entries
252 .iter()
253 .any(|entry| entry.status == ToolStatus::Hydrated)
254 {
255 Some(ToolStatus::Hydrated)
256 } else {
257 Some(ToolStatus::Success)
258 }
259 }
260 ToolCell::PlanUpdate(cell) => Some(cell.status),
261 ToolCell::PatchSummary(cell) => Some(cell.status),
262 ToolCell::Review(cell) => Some(cell.status),
263 ToolCell::DiffPreview(_) => Some(ToolStatus::Success),
264 ToolCell::Mcp(cell) => Some(cell.status),
265 ToolCell::ViewImage(_) => Some(ToolStatus::Success),
266 ToolCell::WebSearch(cell) => Some(cell.status),
267 ToolCell::Generic(cell) => Some(cell.status),
268 }
269 }
270
271 fn tool_duration_for_activity(tool: &ToolCell) -> Option<u64> {
272 match tool {
273 ToolCell::Exec(cell) => cell.duration_ms.or_else(|| {
274 (cell.status == ToolStatus::Running).then(|| {
275 u64::try_from(
276 cell.started_at
277 .map(|started| started.elapsed().as_millis())
278 .unwrap_or_default(),
279 )
280 .unwrap_or(u64::MAX)
281 })
282 }),
283 _ => None,
284 }
285 }
286
287 fn activity_status_label(status: ToolStatus) -> &'static str {
288 match status {
289 ToolStatus::Running => "running",
290 ToolStatus::Success => "done",
291 ToolStatus::Hydrated => "tool loaded - retry required",
292 ToolStatus::Failed => "failed",
293 }
294 }
295
296 /// Empty-state hint shown when the selection has no raw leaf detail to open.
297 /// `v` / `Alt+V` only ever surface the raw detail of the ONE selected
298 /// tool/card/leaf, so when there is nothing leaf-level to show we point the
299 /// user at Ctrl+Alt+O for the whole-turn context instead of failing silently
300 /// (#4105).
301 const NO_RAW_DETAIL_HINT: &str =
302 "No raw detail for this item — press Ctrl+Alt+O for the turn overview.";
303
304 /// Intro line prepended to the raw tool-detail pager body so the surface reads
305 /// as the raw detail of the single selected item — not the whole turn.
306 /// Ctrl+Alt+O is now the whole-turn Turn Inspector (#v092-reasoning-fix).
307 const RAW_DETAIL_PAGER_INTRO: &str =
308 "Raw detail for the selected item — press Ctrl+Alt+O for the whole-turn overview.";
309
310 pub(super) fn open_tool_details_pager(app: &mut App) -> bool {
311 let target_cell = detail_target_cell_index(app);
312
313 let Some(cell_index) = target_cell else {
314 app.status_message = Some(NO_RAW_DETAIL_HINT.to_string());
315 return false;
316 };
317 open_details_pager_for_cell(app, cell_index)
318 }
319
320 /// Build the trailing "Spillover" section for the tool-details pager
321 /// (#500). Session artifact records are authoritative for every tool family
322 /// (including specialized Bash and MCP cells); the historical generic-cell
323 /// path is only a UI compatibility fallback. The pager deliberately keeps the
324 /// backing path and operating-system error private: a detail surface may be
325 /// captured or shared, and neither is useful evidence for the user.
326 pub(super) fn spillover_pager_section(app: &App, cell_index: usize) -> Option<String> {
327 use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell};
328
329 let cell = app.cell_at_virtual_index(cell_index)?;
330 let current_session = app.current_session_id.as_deref();
331 let session_artifact = app
332 .tool_detail_record_for_cell(cell_index)
333 .and_then(|detail| {
334 app.session_artifacts.iter().find(|artifact| {
335 artifact.kind == crate::artifacts::ArtifactKind::ToolOutput
336 && artifact.tool_call_id == detail.tool_id
337 && current_session == Some(artifact.session_id.as_str())
338 })
339 });
340 let legacy_path = match cell {
341 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
342 spillover_path: Some(path),
343 ..
344 })) => Some(path.clone()),
345 _ => None,
346 };
347 if session_artifact.is_none() && legacy_path.is_none() {
348 return None;
349 }
350 let body = session_artifact
351 .and_then(read_owned_session_artifact)
352 .or_else(|| {
353 legacy_path.as_deref().and_then(|path| {
354 current_session.and_then(|session_id| read_owned_legacy_spillover(path, session_id))
355 })
356 })
357 .unwrap_or_else(|| "(retained output is unavailable)".to_string());
358 Some(format!("── Full output ──\n\n{body}"))
359 }
360
361 fn read_owned_session_artifact(artifact: &crate::artifacts::ArtifactRecord) -> Option<String> {
362 if artifact.storage_path.is_absolute() {
363 return None;
364 }
365 let root = crate::artifacts::session_artifact_absolute_path(
366 &artifact.session_id,
367 std::path::Path::new(crate::artifacts::ARTIFACTS_DIR_NAME),
368 )?;
369 let candidate = crate::artifacts::session_artifact_absolute_path(
370 &artifact.session_id,
371 &artifact.storage_path,
372 )?;
373 let path = canonical_owned_file(&candidate, &root)?;
374 std::fs::read_to_string(path).ok()
375 }
376
377 fn read_owned_legacy_spillover(path: &std::path::Path, session_id: &str) -> Option<String> {
378 let root = crate::tools::truncate::spillover_root()?;
379 let path = canonical_owned_file(path, &root)?;
380 let ownership = crate::tools::truncate::read_legacy_spillover_ownership(&path).ok()?;
381 if ownership.origin_session != session_id {
382 return None;
383 }
384 let bytes = std::fs::read(path).ok()?;
385 if ownership.size_bytes != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
386 || ownership.digest != crate::hashing::sha256_hex(&bytes)
387 {
388 return None;
389 }
390 String::from_utf8(bytes).ok()
391 }
392
393 fn canonical_owned_file(
394 candidate: &std::path::Path,
395 root: &std::path::Path,
396 ) -> Option<std::path::PathBuf> {
397 if std::fs::symlink_metadata(candidate)
398 .ok()?
399 .file_type()
400 .is_symlink()
401 {
402 return None;
403 }
404 let root = root.canonicalize().ok()?;
405 let candidate = candidate.canonicalize().ok()?;
406 (candidate.is_file() && candidate.starts_with(root)).then_some(candidate)
407 }
408
409 pub(crate) fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> bool {
410 if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
411 let input = serde_json::to_string_pretty(&detail.input)
412 .unwrap_or_else(|_| detail.input.to_string());
413 let output = detail.output.as_deref().map_or(
414 "(not available)".to_string(),
415 std::string::ToString::to_string,
416 );
417
418 // #500: when the tool result was spilled to disk, fold the full
419 // file content into the pager body so the user can see what was
420 // elided (the model only ever saw the head). The truncated head
421 // stays above as `Output:` so the user can compare what the
422 // model received against the full payload.
423 let spillover_section = spillover_pager_section(app, cell_index);
424 let mutation_section = match app.cell_at_virtual_index(cell_index) {
425 Some(HistoryCell::Tool(ToolCell::PatchSummary(cell))) => cell
426 .receipt
427 .as_ref()
428 .map(|receipt| format!("── Exact File change ──\n{}", receipt.inspect_text())),
429 _ => None,
430 };
431
432 // Frame the body as leaf-level raw detail for the selected item. The
433 // Tool ID / Input / Output / spillover content below is unchanged — only
434 // the leading intro line is new, so existing raw-output visibility is
435 // preserved (#4105).
436 let trailing_sections = [mutation_section, spillover_section]
437 .into_iter()
438 .flatten()
439 .collect::<Vec<_>>()
440 .join("\n\n");
441 let content = if !trailing_sections.is_empty() {
442 format!(
443 "{RAW_DETAIL_PAGER_INTRO}\n\nTool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}\n\n{}",
444 detail.tool_id, detail.tool_name, input, output, trailing_sections
445 )
446 } else {
447 format!(
448 "{RAW_DETAIL_PAGER_INTRO}\n\nTool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}",
449 detail.tool_id, detail.tool_name, input, output
450 )
451 };
452
453 let width = app
454 .viewport
455 .last_transcript_area
456 .map(|area| area.width)
457 .unwrap_or(80);
458 app.view_stack.push(PagerView::from_text(
459 format!("Raw detail — {}", detail.tool_name),
460 &content,
461 width.saturating_sub(2),
462 ));
463 return true;
464 }
465
466 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
467 app.status_message = Some(NO_RAW_DETAIL_HINT.to_string());
468 return false;
469 };
470 let title = match cell {
471 HistoryCell::User { .. } => "You".to_string(),
472 HistoryCell::Assistant { .. } => "Assistant".to_string(),
473 HistoryCell::System { .. } => "Note".to_string(),
474 HistoryCell::Error { .. } => "Error".to_string(),
475 HistoryCell::Thinking { .. } => "Reasoning".to_string(),
476 HistoryCell::Tool(_) => "Message".to_string(),
477 HistoryCell::SubAgent(_) => "Sub-agent".to_string(),
478 HistoryCell::ArchivedContext { .. } => "Archived Context".to_string(),
479 };
480 let width = app
481 .viewport
482 .last_transcript_area
483 .map(|area| area.width)
484 .unwrap_or(80);
485 let content = history_cell_to_text(cell, width);
486 app.view_stack.push(PagerView::from_text(
487 title,
488 &content,
489 width.saturating_sub(2),
490 ));
491 true
492 }
493
494 /// Copy the "focused" transcript cell to the system clipboard.
495 /// The focused cell is determined by the detail-target heuristic
496 /// (viewport centre or most recent cell). Returns true when text
497 /// was actually copied.
498 pub(super) fn copy_focused_cell(app: &mut App) -> bool {
499 let cell_index = detail_target_cell_index(app);
500 let Some(index) = cell_index else {
501 return false;
502 };
503 copy_cell_to_clipboard(app, index)
504 }
505
506 pub(crate) fn copy_cell_to_clipboard(app: &mut App, cell_index: usize) -> bool {
507 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
508 app.status_message = Some("No message at that line".to_string());
509 return false;
510 };
511 let width = app
512 .viewport
513 .last_transcript_area
514 .map(|area| area.width)
515 .unwrap_or(80);
516 let text = history_cell_to_text(cell, width);
517 if text.trim().is_empty() {
518 app.status_message = Some("Message is empty".to_string());
519 return false;
520 }
521 if app.clipboard.write_text(&text).is_ok() {
522 app.status_message = Some("Message copied".to_string());
523 true
524 } else {
525 app.status_message = Some("Copy failed".to_string());
526 false
527 }
528 }
529
530 pub(super) fn detail_target_cell_index(app: &App) -> Option<usize> {
531 if let Some((start, _)) = app.viewport.transcript_selection.ordered_endpoints() {
532 return app
533 .viewport
534 .transcript_cache
535 .line_meta()
536 .get(start.line_index)
537 .and_then(|meta| meta.cell_line())
538 .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index));
539 }
540
541 app.detail_cell_index_for_viewport(
542 app.viewport.last_transcript_top,
543 app.viewport.last_transcript_visible.max(1),
544 app.viewport.transcript_cache.line_meta(),
545 )
546 .or_else(|| app.history.len().checked_sub(1))
547 }
548
549 pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option<String> {
550 if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
551 return Some(detail.tool_name.clone());
552 }
553 let cell = app.cell_at_virtual_index(cell_index)?;
554 match cell {
555 HistoryCell::Tool(ToolCell::Exec(exec)) => {
556 Some(format!("run {}", one_line_summary(&exec.command, 80)))
557 }
558 HistoryCell::Tool(ToolCell::Exploring(explore)) => Some(format!(
559 "workspace {} item{}",
560 explore.entries.len(),
561 if explore.entries.len() == 1 { "" } else { "s" }
562 )),
563 HistoryCell::Tool(ToolCell::PlanUpdate(_)) => Some("legacy plan update".to_string()),
564 HistoryCell::Tool(ToolCell::PatchSummary(patch)) => Some(format!("patch {}", patch.path)),
565 HistoryCell::Tool(ToolCell::Review(review)) => {
566 let target = one_line_summary(&review.target, 80);
567 Some(if target.is_empty() {
568 "review".to_string()
569 } else {
570 format!("review {target}")
571 })
572 }
573 HistoryCell::Tool(ToolCell::DiffPreview(diff)) => Some(format!("diff {}", diff.title)),
574 HistoryCell::Tool(ToolCell::Mcp(mcp)) => Some(format!("tool {}", mcp.tool)),
575 HistoryCell::Tool(ToolCell::ViewImage(image)) => {
576 Some(format!("image {}", image.path.display()))
577 }
578 HistoryCell::Tool(ToolCell::WebSearch(search)) => Some(format!("search {}", search.query)),
579 HistoryCell::Tool(ToolCell::Generic(generic)) => Some(
580 crate::tui::widgets::tool_card::tool_activity_label_for_name(
581 &generic.name,
582 app.ui_locale,
583 ),
584 ),
585 HistoryCell::SubAgent(_) => Some("sub-agent".to_string()),
586 _ => None,
587 }
588 }
589
590 pub(super) fn extract_reasoning_header(text: &str) -> Option<String> {
591 let start = text.find("**")?;
592 let rest = &text[start + 2..];
593 let end = rest.find("**")?;
594 let header = rest[..end].trim().trim_end_matches(':');
595 if header.is_empty() {
596 None
597 } else {
598 Some(header.to_string())
599 }
600 }
601
602 // ============================================================================
603 // Turn Inspector (issue #4104)
604 //
605 // Ctrl+O opens a *turn-level* overview of the current in-flight turn — or the
606 // latest completed turn when idle — rather than the single-cell Activity
607 // Detail. `v` / `Alt+V` remain the raw leaf-detail command for the selected
608 // item; this surface never dumps a single tool's raw output.
609 //
610 // Each of the nine overview sections renders from whatever turn/cell/app state
611 // is cleanly reachable and DEGRADES the rest gracefully to a short "none"/"—"
612 // line — never a mysterious blank. The thinner sections (diagnostics loop,
613 // tests/verifier) are intentionally heuristic in this first pass; the leaf
614 // issues #4106/#4107/#4108 flesh them out with structured data later.
615 // ============================================================================
616
617 /// Open the whole-turn Turn Inspector pager (Ctrl+O).
618 ///
619 /// Reuses the same `PagerView` text-section machinery as the Activity Detail
620 /// pager — no new modal system. Always succeeds: an empty transcript still
621 /// yields a coherent (degraded) overview rather than a dead keypress.
622 pub(super) fn open_turn_inspector_pager(app: &mut App) -> bool {
623 let width = app
624 .viewport
625 .last_transcript_area
626 .map(|area| area.width)
627 .unwrap_or(80);
628 let text = turn_inspector_text(app);
629 // Precompute the compact Markdown handoff (#4108) and attach it so the
630 // pager's `e` key can copy a pasteable artifact without reaching back into
631 // `app`. Reuses the same turn scope + section data as the overview above.
632 let handoff = turn_handoff_markdown(app);
633 app.view_stack.push(
634 PagerView::from_text("Turn Inspector", &text, width.saturating_sub(2))
635 .with_copy_text(text)
636 .with_export_markdown(handoff),
637 );
638 true
639 }
640
641 /// Virtual-cell range `[start, end)` of the turn under inspection.
642 ///
643 /// The turn is the run of cells from the last user prompt through the end of
644 /// the transcript. Because `virtual_cell_count()` includes still-in-flight
645 /// `active_cell` entries, this scopes to the current in-flight turn during a
646 /// turn, and to the latest completed turn once the active cell has flushed to
647 /// history. When no user prompt exists yet the whole transcript is used.
648 fn current_turn_range(app: &App) -> (usize, usize) {
649 let end = app.virtual_cell_count();
650 let start = (0..end)
651 .rev()
652 .find(|&idx| {
653 matches!(
654 app.cell_at_virtual_index(idx),
655 Some(HistoryCell::User { .. })
656 )
657 })
658 .unwrap_or(0);
659 (start, end)
660 }
661
662 /// Human form of the runtime turn status — raw enum-ish values like
663 /// "in_progress" must never reach the inspector (dogfood A6, #4102).
664 fn humanized_turn_status(app: &App) -> &str {
665 match app.runtime_turn_status.as_deref() {
666 Some("in_progress") | None => "in progress",
667 Some(other) => other,
668 }
669 }
670
671 /// Short display form of a runtime turn id. The full UUID reads as internal
672 /// state in the inspector header (dogfood A6); twelve characters is plenty
673 /// to correlate with logs.
674 fn short_turn_id(turn_id: &str) -> &str {
675 turn_id.get(..12).unwrap_or(turn_id)
676 }
677
678 /// Assemble the Turn Inspector overview text from all available turn data.
679 pub(super) fn turn_inspector_text(app: &App) -> String {
680 let (start, end) = current_turn_range(app);
681 let mut out: Vec<String> = Vec::new();
682
683 // Turn identity header. Lead with the human turn number and status; the
684 // id is a short correlation suffix, never a raw UUID dump (dogfood A6).
685 let status = humanized_turn_status(app);
686 if app.turn_counter > 0 {
687 let mut line = format!("Turn #{} \u{00B7} {status}", app.turn_counter);
688 if let Some(turn_id) = app.runtime_turn_id.as_ref() {
689 line.push_str(&format!(" \u{00B7} id {}", short_turn_id(turn_id)));
690 }
691 out.push(line);
692 } else if let Some(turn_id) = app.runtime_turn_id.as_ref() {
693 out.push(format!("Turn {} \u{00B7} {status}", short_turn_id(turn_id)));
694 } else {
695 out.push("Turn: \u{2014} (no turn recorded yet)".to_string());
696 }
697 // Restate the Ctrl+O (overview) vs. Alt+V/⌥V (raw leaf detail) contract so
698 // the two surfaces never get confused. Bare `v` is never a details shortcut.
699 let details = crate::tui::shell_key_routing::display_chord(
700 crate::tui::shell_key_routing::binding(
701 crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
702 )
703 .footer_chord,
704 );
705 out.push(format!(
706 "Overview of the current/latest turn · press {details} for the selected item's raw detail"
707 ));
708
709 push_section(&mut out, "Intent", vec![turn_intent_line(app, start)]);
710
711 if let Some(line) = selected_item_context_line(app) {
712 push_section(&mut out, "Selected item", vec![line]);
713 }
714
715 push_section(&mut out, "To-do", turn_todo_lines(app));
716 push_section(
717 &mut out,
718 "Turn timeline",
719 turn_timeline_lines(app, start, end),
720 );
721 push_section(
722 &mut out,
723 "Files changed",
724 turn_files_changed(app, start, end),
725 );
726 push_section(&mut out, "Diagnostics loop", turn_diagnostics_lines(app));
727 push_section(
728 &mut out,
729 "Tests / verifier",
730 turn_verifier_lines(app, start, end),
731 );
732 push_section(&mut out, "Approvals / denials", turn_approvals_lines(app));
733 push_section(&mut out, "Model route + tokens/cost", turn_route_lines(app));
734 push_section(
735 &mut out,
736 "Final result / status",
737 turn_result_lines(app, start, end, ResultDetail::Full),
738 );
739
740 out.join("\n")
741 }
742
743 /// Build a compact, pasteable Markdown handoff of the current/latest turn
744 /// (issue #4108).
745 ///
746 /// Reuses the exact same turn scope (`current_turn_range`) and the same
747 /// per-section data helpers as the Turn Inspector (#4104), so the handoff can
748 /// never drift from what Ctrl+O shows — it only re-renders that data as
749 /// Markdown headings + bullets instead of the inspector's box-drawn rules.
750 /// Unavailable sections degrade to a short `—` (and the optional Plan section
751 /// is dropped entirely when empty) so the artifact stays paste-ready without
752 /// leaving a heading over a blank void — the same graceful-degrade contract the
753 /// inspector already follows.
754 pub(crate) fn turn_handoff_markdown(app: &App) -> String {
755 let (start, end) = current_turn_range(app);
756 let mut out: Vec<String> = Vec::new();
757
758 // Title + identity — turn id when known, else the turn counter, else a
759 // bare heading so an empty transcript still yields a coherent artifact.
760 let heading = if app.turn_counter > 0 {
761 format!("# Turn handoff — Turn #{}", app.turn_counter)
762 } else if let Some(turn_id) = app.runtime_turn_id.as_ref() {
763 format!("# Turn handoff — {}", short_turn_id(turn_id))
764 } else {
765 "# Turn handoff".to_string()
766 };
767 out.push(heading);
768
769 let status = match app.runtime_turn_status.as_deref() {
770 Some("in_progress") => "in progress",
771 Some(other) => other,
772 None => "idle",
773 };
774 out.push(format!(
775 "_Status: {status} · generated {}_",
776 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
777 ));
778
779 push_md_section(&mut out, "Intent", vec![turn_intent_line(app, start)]);
780
781 // To-do is optional context: include it only when the canonical list has
782 // items, keeping the handoff compact without recreating a second plan.
783 let todos = turn_todo_lines(app);
784 if !todos.is_empty() {
785 push_md_section(&mut out, "To-do", md_bullets(todos));
786 }
787
788 push_md_section(
789 &mut out,
790 "Files changed",
791 md_bullets(turn_files_changed(app, start, end)),
792 );
793 push_md_section(
794 &mut out,
795 "Turn timeline",
796 md_bullets(turn_timeline_lines(app, start, end)),
797 );
798 push_md_section(
799 &mut out,
800 "Tests / verifier",
801 md_bullets(turn_verifier_lines(app, start, end)),
802 );
803 push_md_section(
804 &mut out,
805 "Model route + tokens/cost",
806 md_bullets(turn_route_lines(app)),
807 );
808 push_md_section(
809 &mut out,
810 "Result / status",
811 md_bullets(turn_result_lines(app, start, end, ResultDetail::Compact)),
812 );
813
814 // Trailing newline keeps the artifact clean when pasted into a PR body.
815 out.push(String::new());
816 out.join("\n")
817 }
818
819 /// Append a `## Title` Markdown section. An empty body degrades to a single
820 /// `—` line so a heading is never followed by a void — the Markdown analogue
821 /// of [`push_section`]'s `none` degrade.
822 fn push_md_section(out: &mut Vec<String>, title: &str, body: Vec<String>) {
823 out.push(String::new());
824 out.push(format!("## {title}"));
825 if body.is_empty() {
826 out.push("—".to_string());
827 } else {
828 out.extend(body);
829 }
830 }
831
832 /// Convert Turn Inspector section lines into Markdown bullet rows. Inspector
833 /// list helpers prefix rows with `• `; swap that for `- `, and bullet the
834 /// key/value rows (route, tokens, status) too so the whole section is valid
835 /// Markdown.
836 fn md_bullets(lines: Vec<String>) -> Vec<String> {
837 lines
838 .into_iter()
839 .map(|line| {
840 let body = line.strip_prefix("• ").unwrap_or(line.as_str());
841 format!("- {body}")
842 })
843 .collect()
844 }
845
846 /// Append a `── Title ──` section. An empty body degrades to a single
847 /// `none` line so the section header is never followed by a blank void.
848 fn push_section(out: &mut Vec<String>, title: &str, body: Vec<String>) {
849 out.push(String::new());
850 out.push(format!("── {title} ──"));
851 if body.is_empty() {
852 out.push("none".to_string());
853 } else {
854 out.extend(body);
855 }
856 }
857
858 /// Section 1 — intent / user-prompt summary for the turn.
859 fn turn_intent_line(app: &App, start: usize) -> String {
860 if let Some(HistoryCell::User { content }) = app.cell_at_virtual_index(start) {
861 let summary = one_line_summary(content, 240);
862 if !summary.is_empty() {
863 return summary;
864 }
865 }
866 if let Some(prompt) = app.last_submitted_prompt.as_deref() {
867 let summary = one_line_summary(prompt, 240);
868 if !summary.is_empty() {
869 return summary;
870 }
871 }
872 "—".to_string()
873 }
874
875 /// Optional selected-item context. The first view is the turn overview, but
876 /// when the user has an activity cell selected we surface it plus the Alt+V
877 /// affordance so the Ctrl+O / Alt+V split stays discoverable.
878 fn selected_item_context_line(app: &App) -> Option<String> {
879 let idx = selected_transcript_cell_index(app)?;
880 let cell = app.cell_at_virtual_index(idx)?;
881 let label = truncate_line_to_width(&activity_cell_label(app, idx, cell), 48);
882 let hint = if app.cell_has_detail_target(idx) {
883 let details = crate::tui::shell_key_routing::display_chord(
884 crate::tui::shell_key_routing::binding(
885 crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
886 )
887 .footer_chord,
888 );
889 format!(" · {details} opens its raw detail")
890 } else {
891 String::new()
892 };
893 Some(format!("{label}{hint}"))
894 }
895
896 /// Section 2 — canonical To-do state.
897 fn turn_todo_lines(app: &App) -> Vec<String> {
898 let mut lines = Vec::new();
899
900 if let Ok(todos) = app.todos.try_lock() {
901 let snapshot = todos.snapshot();
902 if !snapshot.items.is_empty() {
903 lines.push(format!("To-do: {}% settled", snapshot.completion_pct));
904 for item in &snapshot.items {
905 lines.push(format!(
906 "{} {}",
907 todo_status_glyph(&item.status),
908 truncate_line_to_width(&item.content, 72)
909 ));
910 }
911 }
912 }
913
914 lines
915 }
916
917 fn todo_status_glyph(status: &crate::tools::todo::TodoStatus) -> &'static str {
918 match status {
919 crate::tools::todo::TodoStatus::Completed => "[x]",
920 crate::tools::todo::TodoStatus::InProgress => "[~]",
921 crate::tools::todo::TodoStatus::Pending => "[ ]",
922 crate::tools::todo::TodoStatus::Cancelled => "[-]",
923 }
924 }
925
926 /// Section 3 — chronological turn timeline with compact action affordances.
927 fn turn_timeline_lines(app: &App, start: usize, end: usize) -> Vec<String> {
928 let mut rows = Vec::new();
929 for idx in start..end {
930 let Some(cell) = app.cell_at_virtual_index(idx) else {
931 continue;
932 };
933 match cell {
934 HistoryCell::User { content } => {
935 let summary = one_line_summary(content, 96);
936 rows.push(timeline_row("user prompt", &summary, None, None, &[]));
937 }
938 HistoryCell::Thinking {
939 content,
940 streaming,
941 duration_secs,
942 } => {
943 let summary = one_line_summary(content, 88);
944 let status = streaming.then_some("running").unwrap_or("done");
945 let duration = duration_secs
946 .map(|secs| crate::elapsed::format_elapsed_ms((secs * 1000.0) as u64));
947 let actions = timeline_cell_actions(app, idx, cell);
948 rows.push(timeline_row(
949 "reasoning",
950 &summary,
951 Some(status),
952 duration.as_deref(),
953 &actions,
954 ));
955 }
956 HistoryCell::Tool(tool) => {
957 let (kind, summary) = timeline_tool_summary(app, idx, tool);
958 let duration =
959 tool_duration_for_activity(tool).map(crate::elapsed::format_elapsed_ms);
960 let status = tool_status_for_activity(tool).map(activity_status_label);
961 let actions = timeline_cell_actions(app, idx, cell);
962 rows.push(timeline_row(
963 kind,
964 &summary,
965 status,
966 duration.as_deref(),
967 &actions,
968 ));
969 }
970 HistoryCell::SubAgent(_) => {
971 let summary = detail_target_label(app, idx).unwrap_or_else(|| "sub-agent".into());
972 let actions = timeline_cell_actions(app, idx, cell);
973 rows.push(timeline_row("sub-agent", &summary, None, None, &actions));
974 }
975 HistoryCell::Assistant { content, streaming } => {
976 let summary = one_line_summary(content, 96);
977 let status = streaming.then_some("streaming").unwrap_or("done");
978 rows.push(timeline_row(
979 "assistant result",
980 &summary,
981 Some(status),
982 None,
983 &[],
984 ));
985 }
986 HistoryCell::Error { message, severity } => {
987 let summary = one_line_summary(message, 96);
988 let status = severity.to_string();
989 rows.push(timeline_row("error", &summary, Some(&status), None, &[]));
990 }
991 HistoryCell::System { content }
992 | HistoryCell::ArchivedContext {
993 summary: content, ..
994 } => {
995 let summary = one_line_summary(content, 96);
996 rows.push(timeline_row("system note", &summary, None, None, &[]));
997 }
998 }
999 }
1000 rows.push(turn_checkpoint_timeline_row(app));
1001 rows.into_iter()
1002 .enumerate()
1003 .map(|(idx, row)| format!("{}. {row}", idx + 1))
1004 .collect()
1005 }
1006
1007 fn timeline_tool_summary(app: &App, idx: usize, tool: &ToolCell) -> (&'static str, String) {
1008 match tool {
1009 ToolCell::Exec(exec) if command_looks_like_verifier(&exec.command) => {
1010 ("test/verifier", truncate_line_to_width(&exec.command, 88))
1011 }
1012 ToolCell::Exec(exec) => ("shell command", truncate_line_to_width(&exec.command, 88)),
1013 ToolCell::Exploring(explore) => (
1014 "read/search",
1015 format!(
1016 "{} item{}",
1017 explore.entries.len(),
1018 if explore.entries.len() == 1 { "" } else { "s" }
1019 ),
1020 ),
1021 ToolCell::PlanUpdate(_) => ("legacy plan", "Legacy plan metadata replayed".to_string()),
1022 ToolCell::PatchSummary(patch) => {
1023 let summary = one_line_summary(&patch.summary, 72);
1024 if summary.is_empty() {
1025 ("edit", truncate_line_to_width(&patch.path, 88))
1026 } else {
1027 (
1028 "edit",
1029 truncate_line_to_width(&format!("{} — {summary}", patch.path), 88),
1030 )
1031 }
1032 }
1033 ToolCell::Review(review) => {
1034 let target = one_line_summary(&review.target, 88);
1035 (
1036 "review",
1037 if target.is_empty() {
1038 "code review".to_string()
1039 } else {
1040 target
1041 },
1042 )
1043 }
1044 ToolCell::DiffPreview(diff) => ("diff", truncate_line_to_width(&diff.title, 88)),
1045 ToolCell::Mcp(mcp) => ("MCP tool", truncate_line_to_width(&mcp.tool, 88)),
1046 ToolCell::ViewImage(image) => (
1047 "image",
1048 truncate_line_to_width(&image.path.display().to_string(), 88),
1049 ),
1050 ToolCell::WebSearch(search) => ("web search", truncate_line_to_width(&search.query, 88)),
1051 ToolCell::Generic(generic) => {
1052 let mut label =
1053 detail_target_label(app, idx).unwrap_or_else(|| generic.name.replace('_', " "));
1054 if let Some(input) = generic.input_summary.as_deref().map(str::trim)
1055 && !input.is_empty()
1056 {
1057 label.push_str(" · ");
1058 label.push_str(input);
1059 }
1060 (
1061 generic_tool_timeline_kind(generic),
1062 truncate_line_to_width(&label, 88),
1063 )
1064 }
1065 }
1066 }
1067
1068 fn generic_tool_timeline_kind(generic: &crate::tui::history::GenericToolCell) -> &'static str {
1069 let name = generic.name.as_str();
1070 if generic.is_diff || name.contains("diff") {
1071 "diff"
1072 } else if matches!(name, "read_file" | "list_files" | "glob" | "grep_files")
1073 || name.contains("read")
1074 || name.contains("search")
1075 || name.contains("grep")
1076 {
1077 "read/search"
1078 } else if matches!(name, "apply_patch" | "edit_file" | "write_file")
1079 || name.contains("patch")
1080 || name.contains("edit")
1081 || name.contains("write")
1082 {
1083 "edit"
1084 } else if name.contains("approval") {
1085 "approval"
1086 } else if name.contains("diagnostic") || name.contains("lsp") {
1087 "diagnostics"
1088 } else {
1089 "tool"
1090 }
1091 }
1092
1093 fn timeline_cell_actions(app: &App, idx: usize, cell: &HistoryCell) -> Vec<String> {
1094 let mut actions = Vec::new();
1095 if app.cell_has_detail_target(idx) {
1096 let details = crate::tui::shell_key_routing::display_chord(
1097 crate::tui::shell_key_routing::binding(
1098 crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
1099 )
1100 .footer_chord,
1101 );
1102 // Diff-bearing cells open their diff through the same details chord;
1103 // bare `v` / `d` always type text (TUI-DOG-002), so no bare-key claim.
1104 let is_diff = matches!(
1105 cell,
1106 HistoryCell::Tool(ToolCell::DiffPreview(_) | ToolCell::PatchSummary(_))
1107 ) || matches!(
1108 cell,
1109 HistoryCell::Tool(ToolCell::Generic(generic)) if generic.is_diff
1110 );
1111 if is_diff {
1112 actions.push(format!("{details} diff"));
1113 } else {
1114 actions.push(format!("{details} raw detail"));
1115 }
1116 }
1117 actions
1118 }
1119
1120 fn timeline_row(
1121 kind: &str,
1122 summary: &str,
1123 status: Option<&str>,
1124 duration: Option<&str>,
1125 actions: &[String],
1126 ) -> String {
1127 let mut line = if summary.trim().is_empty() {
1128 kind.to_string()
1129 } else {
1130 format!("{kind}: {}", summary.trim())
1131 };
1132 if let Some(status) = status.filter(|s| !s.trim().is_empty()) {
1133 line.push_str(" — ");
1134 line.push_str(status);
1135 }
1136 if let Some(duration) = duration.filter(|s| !s.trim().is_empty()) {
1137 line.push_str(" · ");
1138 line.push_str(duration);
1139 }
1140 if !actions.is_empty() {
1141 line.push_str(" · actions: ");
1142 line.push_str(&actions.join(", "));
1143 }
1144 line
1145 }
1146
1147 fn turn_checkpoint_timeline_row(app: &App) -> String {
1148 if app.turn_counter == 0 {
1149 return "checkpoint: unavailable — no numbered turn snapshot yet · action: e export handoff"
1150 .to_string();
1151 }
1152
1153 let repo = match SnapshotRepo::open_existing(&app.workspace) {
1154 Ok(Some(repo)) => repo,
1155 Ok(None) => {
1156 return "checkpoint: unavailable — no snapshot repo found · action: e export handoff"
1157 .to_string();
1158 }
1159 Err(err) => {
1160 return format!(
1161 "checkpoint: unknown — snapshot repo could not be opened ({}) · action: e export handoff",
1162 truncate_line_to_width(&err.to_string(), 72)
1163 );
1164 }
1165 };
1166 let snapshots = match repo.list(20) {
1167 Ok(snapshots) => snapshots,
1168 Err(err) => {
1169 return format!(
1170 "checkpoint: unknown — snapshot list failed ({}) · action: e export handoff",
1171 truncate_line_to_width(&err.to_string(), 72)
1172 );
1173 }
1174 };
1175 let prefix = format!("pre-turn:{}", app.turn_counter);
1176 let matching = snapshots
1177 .iter()
1178 .find(|snapshot| {
1179 snapshot.label == prefix || snapshot.label.starts_with(&format!("{prefix}:"))
1180 })
1181 .or_else(|| {
1182 snapshots
1183 .iter()
1184 .find(|snapshot| snapshot.label.starts_with("pre-turn:"))
1185 });
1186 if let Some(snapshot) = matching {
1187 let short = &snapshot.id.as_str()[..snapshot.id.as_str().len().min(8)];
1188 format!(
1189 "checkpoint: {} ({short}) available · actions: r restore via /restore (guarded), e export handoff",
1190 truncate_line_to_width(&snapshot.label, 72)
1191 )
1192 } else {
1193 "checkpoint: unavailable — no pre-turn snapshot found · action: e export handoff"
1194 .to_string()
1195 }
1196 }
1197
1198 /// Section 4 — files touched by patch/diff tool cells in the turn.
1199 fn turn_files_changed(app: &App, start: usize, end: usize) -> Vec<String> {
1200 let mut lines = Vec::new();
1201 let mut seen = std::collections::HashSet::new();
1202 for idx in start..end {
1203 let Some(HistoryCell::Tool(tool)) = app.cell_at_virtual_index(idx) else {
1204 continue;
1205 };
1206 match tool {
1207 ToolCell::PatchSummary(patch) if seen.insert(patch.path.clone()) => {
1208 lines.push(format!(
1209 "• {} — {}",
1210 truncate_line_to_width(&patch.path, 60),
1211 activity_status_label(patch.status)
1212 ));
1213 }
1214 ToolCell::DiffPreview(diff) if seen.insert(diff.title.clone()) => {
1215 lines.push(format!(
1216 "• {} (diff)",
1217 truncate_line_to_width(&diff.title, 60)
1218 ));
1219 }
1220 _ => {}
1221 }
1222 }
1223 lines
1224 }
1225
1226 /// Section 5 — diagnostics / LSP repair loop (#4107).
1227 ///
1228 /// Shows the observable repair loop when LSP produced diagnostics this turn.
1229 /// Stays quiet when LSP is disabled or no diagnostics were found.
1230 fn turn_diagnostics_lines(app: &App) -> Vec<String> {
1231 if !app.lsp_enabled {
1232 return Vec::new();
1233 }
1234 let repair = &app.lsp_repair;
1235 if repair.diagnostics_found == 0 && !repair.injected && !repair.repair_attempted {
1236 return Vec::new();
1237 }
1238 let mut lines = Vec::new();
1239 if repair.diagnostics_found > 0 {
1240 lines.push(format!(
1241 "Found {} diagnostic{} across {} file{}",
1242 repair.diagnostics_found,
1243 if repair.diagnostics_found == 1 {
1244 ""
1245 } else {
1246 "s"
1247 },
1248 repair.files_touched.max(1),
1249 if repair.files_touched == 1 { "" } else { "s" },
1250 ));
1251 }
1252 lines.push(if repair.injected {
1253 "Injected into the next model request".to_string()
1254 } else {
1255 "Queued — not yet injected".to_string()
1256 });
1257 if repair.repair_attempted {
1258 lines.push("Model attempted a repair after injection".to_string());
1259 }
1260 let latest = match repair.latest {
1261 "resolved" => "Latest: resolved",
1262 "still_failing" => "Latest: still failing",
1263 "unavailable" => "Latest: unavailable",
1264 _ => "Latest: unknown",
1265 };
1266 lines.push(latest.to_string());
1267 lines
1268 }
1269
1270 /// Section 6 — tests / verifier results.
1271 ///
1272 /// Heuristic first pass (issue #4107): scans the turn's exec/review tool cells
1273 /// for verifier-shaped commands and reports their status. Degrades to `none`
1274 /// when nothing test-shaped ran.
1275 fn turn_verifier_lines(app: &App, start: usize, end: usize) -> Vec<String> {
1276 let mut lines = Vec::new();
1277 for idx in start..end {
1278 let Some(HistoryCell::Tool(tool)) = app.cell_at_virtual_index(idx) else {
1279 continue;
1280 };
1281 match tool {
1282 ToolCell::Exec(exec) if command_looks_like_verifier(&exec.command) => {
1283 lines.push(format!(
1284 "• {} — {}",
1285 truncate_line_to_width(&exec.command, 56),
1286 activity_status_label(exec.status)
1287 ));
1288 }
1289 ToolCell::Review(review) => {
1290 let target = truncate_line_to_width(review.target.trim(), 48);
1291 let target = if target.is_empty() {
1292 "review".to_string()
1293 } else {
1294 format!("review {target}")
1295 };
1296 lines.push(format!(
1297 "• {target} — {}",
1298 activity_status_label(review.status)
1299 ));
1300 }
1301 _ => {}
1302 }
1303 }
1304 lines
1305 }
1306
1307 fn command_looks_like_verifier(command: &str) -> bool {
1308 let lower = command.to_lowercase();
1309 [
1310 "test",
1311 "pytest",
1312 "jest",
1313 "cargo check",
1314 "cargo clippy",
1315 "verif",
1316 "lint",
1317 ]
1318 .iter()
1319 .any(|needle| lower.contains(needle))
1320 }
1321
1322 /// Section 7 — approvals / denials.
1323 ///
1324 /// The approval allow/deny sets are session-scoped (not per-turn), so the
1325 /// counts are labelled `(session)` to avoid implying turn precision.
1326 fn turn_approvals_lines(app: &App) -> Vec<String> {
1327 let mut lines = Vec::new();
1328 let approved = app.approval_session_approved.len();
1329 let denied = app.approval_session_denied.len();
1330 if approved > 0 {
1331 lines.push(format!("Approved (session): {approved}"));
1332 }
1333 if denied > 0 {
1334 lines.push(format!("Denied (session): {denied}"));
1335 }
1336 lines
1337 }
1338
1339 /// Section 8 — model route plus token/cost accounting.
1340 fn turn_route_lines(app: &App) -> Vec<String> {
1341 let mut lines = Vec::new();
1342
1343 let (provider, model) = if let Some(route) = app
1344 .active_turn
1345 .as_ref()
1346 .and_then(|turn| turn.route.as_ref())
1347 {
1348 let provider = if route.provider == crate::config::ApiProvider::Custom {
1349 route.provider_identity.clone()
1350 } else {
1351 route.provider.display_name().to_string()
1352 };
1353 (provider, route.model.clone())
1354 } else {
1355 // Pending and last Auto routes use the same billing-authoritative
1356 // display contract as the header; do not fall back to `auto` after the
1357 // concrete turn route has resolved.
1358 app.effective_route_identity_display()
1359 };
1360 lines.push(format!("Route: {provider} · {model}"));
1361
1362 let auto_receipt = app
1363 .active_turn
1364 .as_ref()
1365 .filter(|turn| turn.route.as_ref().is_some_and(|route| route.auto_model))
1366 .and_then(|turn| turn.auto_route_receipt.as_ref())
1367 .or_else(|| {
1368 app.pending_turn_route
1369 .as_ref()
1370 .filter(|(_, _, auto_model)| *auto_model)
1371 .and(app.pending_auto_route_receipt.as_ref())
1372 })
1373 .or_else(|| {
1374 app.auto_model
1375 .then_some(app.last_auto_route_receipt.as_ref())
1376 .flatten()
1377 });
1378 if let Some(receipt) = auto_receipt {
1379 lines.push(format!(
1380 "Auto decision: {} · {}",
1381 receipt.tier.label(),
1382 receipt.reason.label()
1383 ));
1384 let pair = receipt.pair.fast.as_deref().map_or_else(
1385 || format!("{} (no runnable fast sibling)", receipt.pair.strong),
1386 |fast| format!("{} strong · {fast} fast", receipt.pair.strong),
1387 );
1388 lines.push(format!("Auto pair: {pair}"));
1389 lines.push(format!("Auto scope: {}", receipt.scope.label()));
1390 lines.push(format!("Auto data: {}", receipt.data_path.label()));
1391 }
1392
1393 let session = &app.session;
1394 match (session.last_prompt_tokens, session.last_completion_tokens) {
1395 (Some(prompt), Some(completion)) => {
1396 lines.push(format!(
1397 "Tokens (last turn): {prompt} in · {completion} out"
1398 ));
1399 }
1400 (Some(prompt), None) => lines.push(format!("Tokens (last turn): {prompt} in")),
1401 (None, Some(completion)) => lines.push(format!("Tokens (last turn): {completion} out")),
1402 (None, None) => {
1403 if session.total_tokens > 0 {
1404 lines.push(format!("Tokens (session): {}", session.total_tokens));
1405 }
1406 }
1407 }
1408
1409 let chip = app.cumulative_usage_chip();
1410 match &chip {
1411 crate::route_billing::UsageChip::Money(amount) => {
1412 lines.push(format!("Cost (session): {amount}"));
1413 }
1414 crate::route_billing::UsageChip::PricedSubtotal { .. } => {
1415 lines.push(format!(
1416 "Cost (session): {}",
1417 crate::route_billing::format_usage_chip(&chip).unwrap_or_default()
1418 ));
1419 }
1420 crate::route_billing::UsageChip::Allowance { label, used_pct } => {
1421 lines.push(match used_pct {
1422 Some(pct) => format!("Usage plan: {label} ({pct:.0}% used)"),
1423 None => format!("Usage plan: {label}"),
1424 });
1425 }
1426 crate::route_billing::UsageChip::Local => {
1427 lines.push("Cost: local".to_string());
1428 }
1429 crate::route_billing::UsageChip::Unknown => {
1430 lines.push("Cost: unknown".to_string());
1431 }
1432 crate::route_billing::UsageChip::Hidden => {}
1433 }
1434
1435 lines
1436 }
1437
1438 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1439 enum ResultDetail {
1440 /// Pager content is the review surface and must retain the complete final
1441 /// response. Width wrapping belongs to `PagerView`, not data assembly.
1442 Full,
1443 /// The exported handoff is intentionally a compact overview.
1444 Compact,
1445 }
1446
1447 fn cleaned_turn_text(text: &str, detail: ResultDetail, max_width: usize) -> String {
1448 if detail == ResultDetail::Compact {
1449 return one_line_summary(text, max_width);
1450 }
1451
1452 let mut cleaned = String::with_capacity(text.len());
1453 crate::tui::osc8::strip_ansi_into(text, &mut cleaned);
1454 cleaned.trim().to_string()
1455 }
1456
1457 /// Section 9 — final result / current status.
1458 fn turn_result_lines(app: &App, start: usize, end: usize, detail: ResultDetail) -> Vec<String> {
1459 let mut lines = Vec::new();
1460
1461 let status = match app.runtime_turn_status.as_deref() {
1462 Some("in_progress") => "in progress",
1463 Some(other) => other,
1464 None => "idle",
1465 };
1466 lines.push(format!("Status: {status}"));
1467
1468 let final_text = (start..end)
1469 .rev()
1470 .find_map(|idx| match app.cell_at_virtual_index(idx) {
1471 Some(HistoryCell::Assistant { content, .. }) => {
1472 let text = cleaned_turn_text(content, detail, 200);
1473 (!text.is_empty()).then_some(text)
1474 }
1475 _ => None,
1476 });
1477 if let Some(text) = final_text {
1478 lines.push(format!("Result: {text}"));
1479 } else if status == "in progress" {
1480 lines.push("Result: turn still running".to_string());
1481 } else {
1482 lines.push("Result: —".to_string());
1483 }
1484
1485 let error_text = (start..end)
1486 .rev()
1487 .find_map(|idx| match app.cell_at_virtual_index(idx) {
1488 Some(HistoryCell::Error { message, .. }) => {
1489 let text = cleaned_turn_text(message, detail, 160);
1490 (!text.is_empty()).then_some(text)
1491 }
1492 _ => None,
1493 });
1494 if let Some(err) = error_text {
1495 lines.push(format!("Error: {err}"));
1496 }
1497
1498 lines
1499 }
1500
1501 #[cfg(test)]
1502 mod tests {
1503 use super::*;
1504 use crate::config::Config;
1505 use crate::tui::app::{App, LspRepairState, TuiOptions};
1506 use std::path::PathBuf;
1507
1508 fn test_app() -> App {
1509 let options = TuiOptions {
1510 model: "deepseek-v4-flash".to_string(),
1511 start_in_agent_mode: true,
1512 ..crate::test_support::test_tui_options(PathBuf::from("."))
1513 };
1514 App::new(options, &Config::default())
1515 }
1516
1517 #[test]
1518 fn turn_diagnostics_lines_quiet_when_no_activity() {
1519 let mut app = test_app();
1520 app.lsp_enabled = true;
1521 assert!(turn_diagnostics_lines(&app).is_empty());
1522 app.lsp_enabled = false;
1523 assert!(turn_diagnostics_lines(&app).is_empty());
1524 }
1525
1526 #[test]
1527 fn turn_diagnostics_lines_summarize_repair_loop() {
1528 let mut app = test_app();
1529 app.lsp_enabled = true;
1530 app.lsp_repair = LspRepairState {
1531 diagnostics_found: 2,
1532 files_touched: 1,
1533 injected: true,
1534 repair_attempted: true,
1535 latest: "still_failing",
1536 };
1537 let joined = turn_diagnostics_lines(&app).join("\n");
1538 assert!(joined.contains("Found 2 diagnostics"), "{joined}");
1539 assert!(
1540 joined.contains("Injected into the next model request"),
1541 "{joined}"
1542 );
1543 assert!(joined.contains("Model attempted a repair"), "{joined}");
1544 assert!(joined.contains("still failing"), "{joined}");
1545 }
1546
1547 #[test]
1548 fn turn_route_lines_include_truthful_auto_receipt() {
1549 let mut app = test_app();
1550 app.auto_model = true;
1551 app.last_effective_provider = Some(crate::config::ApiProvider::Zai);
1552 app.last_effective_model = Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string());
1553 app.last_auto_route_receipt = Some(crate::model_routing::AutoRouteReceipt {
1554 tier: crate::model_routing::AutoRouteTier::Fast,
1555 pair: crate::model_routing::AutoRoutePair {
1556 strong: crate::config::ZAI_GLM_5_2_MODEL.to_string(),
1557 fast: Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()),
1558 },
1559 scope: crate::model_routing::AutoRouteScope::RunnableProviders,
1560 data_path: crate::model_routing::AutoRouteDataPath::Classifier {
1561 provider: crate::config::ApiProvider::Deepseek,
1562 model: "deepseek-v4-flash".to_string(),
1563 },
1564 reason: crate::model_routing::AutoRouteReason::ClassifierRecommendation,
1565 });
1566
1567 let joined = turn_route_lines(&app).join("\n");
1568
1569 assert!(joined.contains("Route: Zhipu AI / Z.ai · GLM-5-Turbo"));
1570 assert!(joined.contains("Auto decision: fast · classifier recommendation"));
1571 assert!(joined.contains("GLM-5.2 strong · GLM-5-Turbo fast"));
1572 assert!(joined.contains("Auto scope: runnable providers"));
1573 assert!(joined.contains(
1574 "Auto data: latest request + bounded recent context -> DeepSeek / deepseek-v4-flash"
1575 ));
1576 assert!(!joined.contains("API_KEY"));
1577 }
1578
1579 #[test]
1580 fn reasoning_detail_text_empty_when_no_thinking() {
1581 let app = test_app();
1582 assert!(reasoning_detail_text(&app).is_none());
1583 }
1584
1585 #[test]
1586 fn reasoning_detail_text_includes_active_cell_reasoning() {
1587 let mut app = test_app();
1588 let mut active = crate::tui::active_cell::ActiveCell::new();
1589 active.push_thinking(HistoryCell::Thinking {
1590 content: "active reasoning one".to_string(),
1591 streaming: true,
1592 duration_secs: None,
1593 });
1594 active.push_thinking(HistoryCell::Thinking {
1595 content: "active reasoning two".to_string(),
1596 streaming: false,
1597 duration_secs: Some(1.0),
1598 });
1599 app.active_cell = Some(active);
1600 app.runtime_turn_id = Some("turn-active-123".to_string());
1601 app.runtime_turn_status = Some("in_progress".to_string());
1602
1603 let body = reasoning_detail_text(&app).expect("active reasoning should produce detail");
1604 assert!(body.contains("Thinking chunk 1 of 2"), "{body}");
1605 assert!(body.contains("Thinking chunk 2 of 2"), "{body}");
1606 assert!(body.contains("active reasoning one"), "{body}");
1607 assert!(body.contains("active reasoning two"), "{body}");
1608 assert!(body.contains("running"), "{body}");
1609 }
1610
1611 #[test]
1612 fn reasoning_detail_text_scopes_to_latest_turn_without_selection() {
1613 let mut app = test_app();
1614 app.history = vec![
1615 HistoryCell::User {
1616 content: "first prompt".to_string(),
1617 },
1618 HistoryCell::Thinking {
1619 content: "first turn reasoning".to_string(),
1620 streaming: false,
1621 duration_secs: Some(1.0),
1622 },
1623 HistoryCell::Assistant {
1624 content: "first reply".to_string(),
1625 streaming: false,
1626 },
1627 HistoryCell::User {
1628 content: "second prompt".to_string(),
1629 },
1630 HistoryCell::Thinking {
1631 content: "second turn reasoning".to_string(),
1632 streaming: false,
1633 duration_secs: Some(1.0),
1634 },
1635 HistoryCell::Assistant {
1636 content: "second reply".to_string(),
1637 streaming: false,
1638 },
1639 ];
1640 app.resync_history_revisions();
1641
1642 let body =
1643 reasoning_detail_text(&app).expect("latest turn reasoning should produce detail");
1644 assert!(body.contains("second turn reasoning"), "{body}");
1645 assert!(
1646 !body.contains("first turn reasoning"),
1647 "reasoning detail without selection must scope to the latest turn: {body}"
1648 );
1649 }
1650
1651 #[test]
1652 fn turn_range_for_index_scopes_to_containing_turn() {
1653 let mut app = test_app();
1654 app.history = vec![
1655 HistoryCell::User {
1656 content: "first prompt".to_string(),
1657 },
1658 HistoryCell::Thinking {
1659 content: "first turn reasoning".to_string(),
1660 streaming: false,
1661 duration_secs: Some(1.0),
1662 },
1663 HistoryCell::Assistant {
1664 content: "first reply".to_string(),
1665 streaming: false,
1666 },
1667 HistoryCell::User {
1668 content: "second prompt".to_string(),
1669 },
1670 HistoryCell::Thinking {
1671 content: "second turn reasoning".to_string(),
1672 streaming: false,
1673 duration_secs: Some(1.0),
1674 },
1675 HistoryCell::Assistant {
1676 content: "second reply".to_string(),
1677 streaming: false,
1678 },
1679 ];
1680 app.resync_history_revisions();
1681
1682 let (start, end) = turn_range_for_index(&app, 1);
1683 assert_eq!(start, 0, "first turn should start at user cell 0");
1684 assert_eq!(end, 3, "first turn should end before second user cell");
1685
1686 let (start, end) = turn_range_for_index(&app, 4);
1687 assert_eq!(start, 3, "second turn should start at user cell 3");
1688 assert_eq!(end, 6, "second turn should run to end of transcript");
1689 }
1690
1691 #[test]
1692 fn open_reasoning_detail_pager_pushes_reasoning_detail_pager() {
1693 let mut app = test_app();
1694 app.history = vec![HistoryCell::Thinking {
1695 content: "recorded reasoning".to_string(),
1696 streaming: false,
1697 duration_secs: Some(1.0),
1698 }];
1699 app.resync_history_revisions();
1700 let revisions = app.history_revisions.clone();
1701 app.viewport.transcript_cache.ensure(
1702 &app.history,
1703 &revisions,
1704 100,
1705 app.transcript_render_options(),
1706 );
1707 app.viewport.last_transcript_area = Some(ratatui::layout::Rect {
1708 x: 0,
1709 y: 0,
1710 width: 80,
1711 height: 24,
1712 });
1713
1714 assert!(open_reasoning_detail_pager(&mut app));
1715 let top = app.view_stack.top_kind();
1716 assert_eq!(top, Some(crate::tui::views::ModalKind::Pager));
1717 }
1718 }
1719
1719 lines RUST