返回 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::{PagerPage, PagerView};
15 use crate::tui::ui_text::{
16 history_cell_to_clipboard_text, history_cell_to_text, truncate_line_to_width,
17 };
18 use codewhale_localization::{MessageId, tr};
19
20 fn selected_transcript_cell_index(app: &App) -> Option<usize> {
21 app.viewport
22 .transcript_selection
23 .ordered_endpoints()
24 .and_then(|(start, _)| {
25 app.viewport
26 .transcript_cache
27 .line_meta()
28 .get(start.line_index)
29 .and_then(|meta| meta.cell_line())
30 .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index))
31 })
32 }
33
34 /// Open the full recorded-reasoning detail pager for the selected thinking
35 /// block, or for the current/latest turn when no reasoning block is selected.
36 /// Ctrl+O routes here; only provider-supplied reasoning is shown.
37 pub(super) fn open_reasoning_detail_pager(app: &mut App) -> bool {
38 let width = app
39 .viewport
40 .last_transcript_area
41 .map(|area| area.width)
42 .unwrap_or(80);
43 let Some(text) = reasoning_detail_text(app) else {
44 app.status_message = Some("No reasoning detail available".to_string());
45 return true;
46 };
47 app.view_stack.push(PagerView::from_text(
48 "Reasoning Detail",
49 &text,
50 width.saturating_sub(2),
51 ));
52 true
53 }
54
55 /// Resolve the turn range that contains the given virtual cell index.
56 /// The turn starts at the most recent user cell at or before the index and
57 /// ends at the next user cell after the index, or the end of the transcript.
58 fn turn_range_for_index(app: &App, index: usize) -> (usize, usize) {
59 let end = app.virtual_cell_count();
60 let start = (0..index.saturating_add(1))
61 .rev()
62 .find(|&idx| {
63 matches!(
64 app.cell_at_virtual_index(idx),
65 Some(HistoryCell::User { .. })
66 )
67 })
68 .unwrap_or(0);
69 let turn_end = (index..end)
70 .find(|&idx| {
71 idx > index
72 && matches!(
73 app.cell_at_virtual_index(idx),
74 Some(HistoryCell::User { .. })
75 )
76 })
77 .unwrap_or(end);
78 (start, turn_end)
79 }
80
81 /// Assemble the full recorded reasoning for the selected thinking block's
82 /// turn, or for the current/latest turn when nothing is selected. Empty
83 /// chunks are surfaced as "(no reasoning text recorded)" rather than invented.
84 pub(super) fn reasoning_detail_text(app: &App) -> Option<String> {
85 let selected = selected_transcript_cell_index(app).filter(|&idx| {
86 matches!(
87 app.cell_at_virtual_index(idx),
88 Some(HistoryCell::Thinking { .. })
89 )
90 });
91 let (start, end) = selected
92 .map(|idx| turn_range_for_index(app, idx))
93 .unwrap_or_else(|| current_turn_range(app));
94 reasoning_timeline_text(app, selected, start, end)
95 }
96
97 /// Build the full recorded-reasoning text for a turn-scoped set of thinking
98 /// cells. Only provider-supplied reasoning Codewhale actually recorded is
99 /// shown; nothing is fabricated when a chunk is empty.
100 pub(super) fn reasoning_timeline_text(
101 app: &App,
102 selected_cell_index: Option<usize>,
103 start: usize,
104 end: usize,
105 ) -> Option<String> {
106 let thinking_indices: Vec<usize> = (start..end)
107 .filter(|&idx| {
108 matches!(
109 app.cell_at_virtual_index(idx),
110 Some(HistoryCell::Thinking { .. })
111 )
112 })
113 .collect();
114 if thinking_indices.is_empty() {
115 return None;
116 }
117
118 let selected_position = selected_cell_index.and_then(|selected| {
119 thinking_indices
120 .iter()
121 .position(|&idx| idx == selected)
122 .map(|idx| idx + 1)
123 });
124 let total = thinking_indices.len();
125 let running = thinking_indices.iter().any(|&idx| {
126 matches!(
127 app.cell_at_virtual_index(idx),
128 Some(HistoryCell::Thinking {
129 streaming: true,
130 ..
131 })
132 )
133 });
134
135 let mut sections = Vec::new();
136 if let Some(turn_id) = app.runtime_turn_id.as_ref() {
137 let status = humanized_turn_status(app);
138 sections.push(format!("Turn {} \u{00B7} {status}", short_turn_id(turn_id)));
139 }
140 sections.push("Activity: reasoning timeline".to_string());
141 sections.push(format!(
142 "Status: {} · {total} chunk{}",
143 if running { "running" } else { "done" },
144 if total == 1 { "" } else { "s" }
145 ));
146 if let Some(position) = selected_position {
147 sections.push(format!("Selected chunk: {position} of {total}"));
148 if position > 1 {
149 let previous_index = thinking_indices[position - 2];
150 let preview = thinking_chunk_preview(app, previous_index);
151 sections.push(format!(
152 "Previous chunk: {} of {total} - {preview}",
153 position - 1
154 ));
155 }
156 if position < total {
157 let next_index = thinking_indices[position];
158 let preview = thinking_chunk_preview(app, next_index);
159 sections.push(format!(
160 "Next chunk: {} of {total} - {preview}",
161 position + 1
162 ));
163 }
164 }
165 sections.push(String::new());
166
167 for (position, cell_index) in thinking_indices.iter().copied().enumerate() {
168 let Some(HistoryCell::Thinking {
169 content,
170 streaming,
171 duration_secs,
172 }) = app.cell_at_virtual_index(cell_index)
173 else {
174 continue;
175 };
176 let position = position + 1;
177 let marker = if Some(position) == selected_position {
178 " (selected)"
179 } else {
180 ""
181 };
182 let mut status = if *streaming {
183 "running".to_string()
184 } else {
185 "done".to_string()
186 };
187 if let Some(duration_secs) = duration_secs {
188 status.push_str(" · ");
189 status.push_str(&crate::elapsed::format_elapsed_ms(
190 (duration_secs * 1000.0) as u64,
191 ));
192 }
193 sections.push(format!("Thinking chunk {position} of {total}{marker}"));
194 sections.push(format!("Status: {status}"));
195 let body = content.trim();
196 if body.is_empty() {
197 sections.push("(no reasoning text recorded)".to_string());
198 } else {
199 sections.push(body.to_string());
200 }
201 sections.push(String::new());
202 }
203
204 Some(sections.join("\n"))
205 }
206
207 fn thinking_chunk_preview(app: &App, cell_index: usize) -> String {
208 let Some(HistoryCell::Thinking { content, .. }) = app.cell_at_virtual_index(cell_index) else {
209 return "thinking".to_string();
210 };
211 let preview = one_line_summary(content, 64);
212 if preview.is_empty() {
213 "thinking".to_string()
214 } else {
215 preview
216 }
217 }
218
219 fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> String {
220 match cell {
221 HistoryCell::Thinking { .. } => "thinking".to_string(),
222 HistoryCell::Error { .. } => "error".to_string(),
223 HistoryCell::SubAgent(_) => "sub-agent".to_string(),
224 HistoryCell::Tool(ToolCell::Generic(generic)) => {
225 crate::tui::widgets::tool_card::tool_activity_label_for_name(
226 &generic.name,
227 app.ui_locale,
228 )
229 }
230 HistoryCell::Tool(_) => {
231 detail_target_label(app, cell_index).unwrap_or_else(|| "tool activity".to_string())
232 }
233 _ => "message".to_string(),
234 }
235 }
236
237 fn tool_duration_for_activity(tool: &ToolCell) -> Option<u64> {
238 match tool {
239 ToolCell::Exec(cell) => cell.duration_ms.or_else(|| {
240 (cell.status == ToolStatus::Running).then(|| {
241 u64::try_from(
242 cell.started_at
243 .map(|started| started.elapsed().as_millis())
244 .unwrap_or_default(),
245 )
246 .unwrap_or(u64::MAX)
247 })
248 }),
249 _ => None,
250 }
251 }
252
253 fn activity_status_label(status: ToolStatus) -> &'static str {
254 match status {
255 ToolStatus::Running => "running",
256 ToolStatus::Success => "done",
257 ToolStatus::Hydrated => "tool loaded - retry required",
258 ToolStatus::Warning => "issue",
259 ToolStatus::Failed => "failed",
260 }
261 }
262
263 /// Empty-state hint shown when the selection has no raw leaf detail to open.
264 /// `v` / `Alt+V` only ever surface the raw detail of the ONE selected
265 /// tool/card/leaf, so when there is nothing leaf-level to show we point the
266 /// user at Ctrl+Alt+O for the whole-turn context instead of failing silently
267 /// (#4105).
268 const NO_RAW_DETAIL_HINT: &str =
269 "No raw detail for this item — press Ctrl+Alt+O for the turn overview.";
270
271 /// Intro line prepended to the raw tool-detail pager body so the surface reads
272 /// as the raw detail of the single selected item — not the whole turn.
273 /// Ctrl+Alt+O is now the whole-turn Turn Inspector (#v092-reasoning-fix).
274 const RAW_DETAIL_PAGER_INTRO: &str =
275 "Raw detail for the selected item — press Ctrl+Alt+O for the whole-turn overview.";
276
277 pub(super) fn open_tool_details_pager(app: &mut App) -> bool {
278 let target_cell = detail_target_cell_index(app);
279
280 let Some(cell_index) = target_cell else {
281 app.status_message = Some(NO_RAW_DETAIL_HINT.to_string());
282 return false;
283 };
284 open_details_pager_for_cell(app, cell_index)
285 }
286
287 /// Build the trailing "Spillover" section for the tool-details pager
288 /// (#500). Session artifact records are authoritative for every tool family
289 /// (including specialized Bash and MCP cells); the historical generic-cell
290 /// path is only a UI compatibility fallback. The pager deliberately keeps the
291 /// backing path and operating-system error private: a detail surface may be
292 /// captured or shared, and neither is useful evidence for the user.
293 pub(super) fn spillover_pager_section(app: &App, cell_index: usize) -> Option<String> {
294 use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell};
295
296 let cell = app.cell_at_virtual_index(cell_index)?;
297 let current_session = app.current_session_id.as_deref();
298 let session_artifact = app
299 .tool_detail_record_for_cell(cell_index)
300 .and_then(|detail| {
301 app.session_artifacts.iter().find(|artifact| {
302 artifact.kind == crate::artifacts::ArtifactKind::ToolOutput
303 && artifact.tool_call_id == detail.tool_id
304 && current_session == Some(artifact.session_id.as_str())
305 })
306 });
307 let legacy_path = match cell {
308 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
309 spillover_path: Some(path),
310 ..
311 })) => Some(path.clone()),
312 _ => None,
313 };
314 if session_artifact.is_none() && legacy_path.is_none() {
315 return None;
316 }
317 let body = session_artifact
318 .and_then(read_owned_session_artifact)
319 .or_else(|| {
320 legacy_path.as_deref().and_then(|path| {
321 current_session.and_then(|session_id| read_owned_legacy_spillover(path, session_id))
322 })
323 })
324 .unwrap_or_else(|| "(retained output is unavailable)".to_string());
325 Some(format!("── Full output ──\n\n{body}"))
326 }
327
328 // Pager reads are bounded; larger artifacts remain on disk and the existing
329 // unavailable-output state is shown. This is not a persistence size limit.
330 const MAX_SESSION_ARTIFACT_DISPLAY_BYTES: u64 = 64 * 1024 * 1024;
331
332 fn read_owned_session_artifact(artifact: &crate::artifacts::ArtifactRecord) -> Option<String> {
333 use std::io::Read;
334
335 if !artifact
336 .storage_path
337 .starts_with(crate::artifacts::ARTIFACTS_DIR_NAME)
338 {
339 return None;
340 }
341 let file = crate::artifacts::open_session_relative(
342 &artifact.session_id,
343 &artifact.storage_path,
344 false,
345 )
346 .ok()?
347 .open_file()
348 .ok()?;
349 let mut bytes = Vec::new();
350 file.take(MAX_SESSION_ARTIFACT_DISPLAY_BYTES + 1)
351 .read_to_end(&mut bytes)
352 .ok()?;
353 if bytes.len() as u64 > MAX_SESSION_ARTIFACT_DISPLAY_BYTES {
354 return None;
355 }
356 String::from_utf8(bytes).ok()
357 }
358
359 fn read_owned_legacy_spillover(path: &std::path::Path, session_id: &str) -> Option<String> {
360 let root = crate::tools::truncate::spillover_root()?;
361 let path = canonical_owned_file(path, &root)?;
362 let ownership = crate::tools::truncate::read_legacy_spillover_ownership(&path).ok()?;
363 if ownership.origin_session != session_id {
364 return None;
365 }
366 let bytes = std::fs::read(path).ok()?;
367 if ownership.size_bytes != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
368 || ownership.digest != crate::hashing::sha256_hex(&bytes)
369 {
370 return None;
371 }
372 String::from_utf8(bytes).ok()
373 }
374
375 fn canonical_owned_file(
376 candidate: &std::path::Path,
377 root: &std::path::Path,
378 ) -> Option<std::path::PathBuf> {
379 if std::fs::symlink_metadata(candidate)
380 .ok()?
381 .file_type()
382 .is_symlink()
383 {
384 return None;
385 }
386 let root = root.canonicalize().ok()?;
387 let candidate = candidate.canonicalize().ok()?;
388 (candidate.is_file() && candidate.starts_with(root)).then_some(candidate)
389 }
390
391 pub(crate) fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> bool {
392 if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
393 let input = serde_json::to_string_pretty(&detail.input)
394 .unwrap_or_else(|_| detail.input.to_string());
395 let output = detail.output.as_deref().map_or(
396 "(not available)".to_string(),
397 std::string::ToString::to_string,
398 );
399
400 // #500: when the tool result was spilled to disk, fold the full
401 // file content into the pager body so the user can see what was
402 // elided (the model only ever saw the head). The truncated head
403 // stays above as `Output:` so the user can compare what the
404 // model received against the full payload.
405 let spillover_section = spillover_pager_section(app, cell_index);
406 let mutation_section = match app.cell_at_virtual_index(cell_index) {
407 Some(HistoryCell::Tool(ToolCell::PatchSummary(cell))) => cell
408 .receipt
409 .as_ref()
410 .map(|receipt| format!("── Exact File change ──\n{}", receipt.inspect_text())),
411 _ => None,
412 };
413
414 // Frame the body as leaf-level raw detail for the selected item. The
415 // Tool ID / Input / Output / spillover content below is unchanged — only
416 // the leading intro line is new, so existing raw-output visibility is
417 // preserved (#4105).
418 let trailing_sections = [mutation_section, spillover_section]
419 .into_iter()
420 .flatten()
421 .collect::<Vec<_>>()
422 .join("\n\n");
423 let content = if !trailing_sections.is_empty() {
424 format!(
425 "{RAW_DETAIL_PAGER_INTRO}\n\nTool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}\n\n{}",
426 detail.tool_id, detail.tool_name, input, output, trailing_sections
427 )
428 } else {
429 format!(
430 "{RAW_DETAIL_PAGER_INTRO}\n\nTool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}",
431 detail.tool_id, detail.tool_name, input, output
432 )
433 };
434
435 let width = app
436 .viewport
437 .last_transcript_area
438 .map(|area| area.width)
439 .unwrap_or(80);
440 app.view_stack.push(PagerView::from_text(
441 format!("Raw detail — {}", detail.tool_name),
442 &content,
443 width.saturating_sub(2),
444 ));
445 return true;
446 }
447
448 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
449 app.status_message = Some(NO_RAW_DETAIL_HINT.to_string());
450 return false;
451 };
452 let title = match cell {
453 HistoryCell::User { .. } => "You".to_string(),
454 HistoryCell::Assistant { .. } => "Assistant".to_string(),
455 HistoryCell::System { .. } => "Note".to_string(),
456 HistoryCell::Error { .. } => "Error".to_string(),
457 HistoryCell::Thinking { .. } => "Reasoning".to_string(),
458 HistoryCell::Tool(_) => "Message".to_string(),
459 HistoryCell::SubAgent(_) => "Sub-agent".to_string(),
460 HistoryCell::Automation(_) => tr(app.ui_locale, MessageId::AutomationNoun).into_owned(),
461 HistoryCell::ArchivedContext { .. } => "Archived Context".to_string(),
462 };
463 let width = app
464 .viewport
465 .last_transcript_area
466 .map(|area| area.width)
467 .unwrap_or(80);
468 let content = history_cell_to_text(cell, width);
469 let mut pager = PagerView::from_text(title, &content, width.saturating_sub(2));
470 // A completed assistant cell gets a clean `a` (copy answer) action so
471 // this raw-detail pager can hand over the answer text without the
472 // glyph/label scaffolding that `c`/`y` (rendered body) would include.
473 if let Some(answer) = completed_assistant_answer_text(cell, width) {
474 pager = pager.with_copy_answer(answer);
475 }
476 app.view_stack.push(pager);
477 true
478 }
479
480 /// Open the focused transcript cell as a full-screen readable pager.
481 pub(crate) fn open_focused_cell_pager(app: &mut App) -> bool {
482 let Some(cell_index) = detail_target_cell_index(app) else {
483 return false;
484 };
485 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
486 return false;
487 };
488 let title = match cell {
489 HistoryCell::User { .. } => "You".to_string(),
490 HistoryCell::Assistant { .. } => "Assistant".to_string(),
491 HistoryCell::System { .. } => "Note".to_string(),
492 HistoryCell::Error { .. } => "Error".to_string(),
493 HistoryCell::Thinking { .. } => "Reasoning".to_string(),
494 HistoryCell::Tool(_) => "Tool".to_string(),
495 HistoryCell::SubAgent(_) => "Sub-agent".to_string(),
496 HistoryCell::Automation(_) => tr(app.ui_locale, MessageId::AutomationNoun).into_owned(),
497 HistoryCell::ArchivedContext { .. } => "Archived Context".to_string(),
498 };
499 let width = app
500 .viewport
501 .last_transcript_area
502 .map(|area| area.width)
503 .unwrap_or(80);
504 let content = history_cell_to_text(cell, width);
505 let mut pager = PagerView::from_text(title, &content, width.saturating_sub(2));
506 if let Some(answer) = completed_assistant_answer_text(cell, width) {
507 pager = pager.with_copy_answer(answer);
508 }
509 app.view_stack.push(pager);
510 true
511 }
512
513 /// Copy the "focused" transcript cell to the system clipboard.
514 /// The focused cell is determined by the detail-target heuristic
515 /// (viewport centre or most recent cell). Returns true when text
516 /// was actually copied.
517 pub(super) fn copy_focused_cell(app: &mut App) -> bool {
518 let cell_index = detail_target_cell_index(app);
519 let Some(index) = cell_index else {
520 return false;
521 };
522 copy_cell_to_clipboard(app, index)
523 }
524
525 pub(crate) fn copy_cell_to_clipboard(app: &mut App, cell_index: usize) -> bool {
526 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
527 app.status_message = Some("No message at that line".to_string());
528 return false;
529 };
530 let width = app
531 .viewport
532 .last_transcript_area
533 .map(|area| area.width)
534 .unwrap_or(80);
535 let text = history_cell_to_clipboard_text(cell, width);
536 if text.trim().is_empty() {
537 app.status_message = Some("Message is empty".to_string());
538 return false;
539 }
540 if let Ok(transport) = app.clipboard.write_text_status(&text) {
541 app.status_message = Some(crate::tui::mouse_ui::copy_receipt(
542 app,
543 transport,
544 "Message copied",
545 ));
546 true
547 } else {
548 app.status_message = Some("Copy failed".to_string());
549 false
550 }
551 }
552
553 /// Clean clipboard payload for a completed assistant answer cell.
554 ///
555 /// Selection reuses the typed `HistoryCell::is_completed_assistant_answer`
556 /// projection so reasoning/thinking blocks, tool calls and results, runtime
557 /// status, and still-streaming partials never qualify; serialization reuses
558 /// `history_cell_to_clipboard_text`, the canonical clean-copy path that
559 /// returns the authored assistant Markdown with no glyph/label scaffolding.
560 pub(crate) fn completed_assistant_answer_text(cell: &HistoryCell, width: u16) -> Option<String> {
561 cell.is_completed_assistant_answer()
562 .then(|| history_cell_to_clipboard_text(cell, width))
563 }
564
565 /// Latest completed assistant answer inside the virtual-cell range
566 /// `[start, end)` — the payload behind the Turn Inspector's `a` (copy
567 /// answer) action. Scanning backwards keeps each inspector page scoped to
568 /// its own turn, so the latest page carries the latest completed answer.
569 fn turn_answer_payload(app: &App, start: usize, end: usize, width: u16) -> Option<String> {
570 (start..end).rev().find_map(|idx| {
571 app.cell_at_virtual_index(idx)
572 .and_then(|cell| completed_assistant_answer_text(cell, width))
573 })
574 }
575
576 pub(super) fn detail_target_cell_index(app: &App) -> Option<usize> {
577 if let Some((start, _)) = app.viewport.transcript_selection.ordered_endpoints() {
578 return app
579 .viewport
580 .transcript_cache
581 .line_meta()
582 .get(start.line_index)
583 .and_then(|meta| meta.cell_line())
584 .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index));
585 }
586 app.detail_cell_index_for_viewport(
587 app.viewport.last_transcript_top,
588 app.viewport.last_transcript_visible.max(1),
589 app.viewport.transcript_cache.line_meta(),
590 )
591 .or_else(|| app.virtual_cell_count().checked_sub(1))
592 }
593
594 pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option<String> {
595 if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
596 return Some(detail.tool_name.clone());
597 }
598 let cell = app.cell_at_virtual_index(cell_index)?;
599 match cell {
600 HistoryCell::Tool(ToolCell::Exec(exec)) => {
601 Some(format!("run {}", one_line_summary(&exec.command, 80)))
602 }
603 HistoryCell::Tool(ToolCell::Exploring(explore)) => Some(format!(
604 "workspace {} item{}",
605 explore.entries.len(),
606 if explore.entries.len() == 1 { "" } else { "s" }
607 )),
608 HistoryCell::Tool(ToolCell::PlanUpdate(_)) => Some("legacy plan update".to_string()),
609 HistoryCell::Tool(ToolCell::PatchSummary(patch)) => Some(format!("patch {}", patch.path)),
610 HistoryCell::Tool(ToolCell::Review(review)) => {
611 let target = one_line_summary(&review.target, 80);
612 Some(if target.is_empty() {
613 "review".to_string()
614 } else {
615 format!("review {target}")
616 })
617 }
618 HistoryCell::Tool(ToolCell::Mcp(mcp)) => Some(format!("tool {}", mcp.tool)),
619 HistoryCell::Tool(ToolCell::ViewImage(image)) => {
620 Some(format!("image {}", image.path.display()))
621 }
622 HistoryCell::Tool(ToolCell::WebSearch(search)) => Some(format!("search {}", search.query)),
623 HistoryCell::Tool(ToolCell::Generic(generic)) => Some(
624 crate::tui::widgets::tool_card::tool_activity_label_for_name(
625 &generic.name,
626 app.ui_locale,
627 ),
628 ),
629 HistoryCell::SubAgent(_) => Some("sub-agent".to_string()),
630 HistoryCell::Error { .. } => Some("full error message".to_string()),
631 _ => None,
632 }
633 }
634
635 pub(super) fn extract_reasoning_header(text: &str) -> Option<String> {
636 let start = text.find("**")?;
637 let rest = &text[start + 2..];
638 let end = rest.find("**")?;
639 let header = rest[..end].trim().trim_end_matches(':');
640 if header.is_empty() {
641 None
642 } else {
643 Some(header.to_string())
644 }
645 }
646
647 // ============================================================================
648 // Turn Inspector (issue #4104)
649 //
650 // Ctrl+O opens a *turn-level* overview of the current in-flight turn — or the
651 // latest completed turn when idle — rather than the single-cell Activity
652 // Detail. `v` / `Alt+V` remain the raw leaf-detail command for the selected
653 // item; this surface never dumps a single tool's raw output.
654 //
655 // Each of the nine overview sections renders from whatever turn/cell/app state
656 // is cleanly reachable and DEGRADES the rest gracefully to a short "none"/"—"
657 // line — never a mysterious blank. The thinner sections (diagnostics loop,
658 // tests/verifier) are intentionally heuristic in this first pass; the leaf
659 // issues #4106/#4107/#4108 flesh them out with structured data later.
660 // ============================================================================
661
662 /// Open the whole-turn Turn Inspector pager (Ctrl+O).
663 ///
664 /// Reuses the same `PagerView` text-section machinery as the Activity Detail
665 /// pager — no new modal system. Always succeeds: an empty transcript still
666 /// yields a coherent (degraded) overview rather than a dead keypress.
667 pub(super) fn open_turn_inspector_pager(app: &mut App) -> bool {
668 let width = app
669 .viewport
670 .last_transcript_area
671 .map(|area| area.width)
672 .unwrap_or(80);
673 let ranges = turn_ranges(app);
674 let page_count = ranges.len();
675 let pages = ranges
676 .into_iter()
677 .enumerate()
678 .map(|(page_index, (start, end))| {
679 let latest = page_index + 1 == page_count;
680 let text =
681 turn_inspector_text_for_range(app, start, end, page_index, page_count, latest);
682 let page = PagerPage::from_text("Turn Inspector", &text, width.saturating_sub(2))
683 .with_copy_text(text);
684 // `a` copies only this turn's final assistant answer — the clean
685 // counterpart to `e` (whole-turn handoff markdown).
686 let page = match turn_answer_payload(app, start, end, width) {
687 Some(answer) => page.with_copy_answer(answer),
688 None => page,
689 };
690 if latest {
691 // The existing handoff remains attached only to the
692 // current/latest turn it actually describes.
693 page.with_export_markdown(turn_handoff_markdown(app))
694 } else {
695 page
696 }
697 })
698 .collect();
699 app.view_stack
700 .push(PagerView::from_pages(pages, page_count.saturating_sub(1)));
701 true
702 }
703
704 /// Chronological virtual-cell ranges for every recorded turn. A transcript
705 /// without a user prompt still gets one coherent page, matching the previous
706 /// Turn Inspector empty/degraded behavior.
707 fn turn_ranges(app: &App) -> Vec<(usize, usize)> {
708 let end = app.virtual_cell_count();
709 let starts: Vec<usize> = (0..end)
710 .filter(|&idx| {
711 matches!(
712 app.cell_at_virtual_index(idx),
713 Some(HistoryCell::User { .. })
714 )
715 })
716 .collect();
717 if starts.is_empty() {
718 return vec![(0, end)];
719 }
720 starts
721 .iter()
722 .copied()
723 .enumerate()
724 .map(|(idx, start)| (start, starts.get(idx + 1).copied().unwrap_or(end)))
725 .collect()
726 }
727
728 /// Virtual-cell range `[start, end)` of the turn under inspection.
729 ///
730 /// The turn is the run of cells from the last user prompt through the end of
731 /// the transcript. Because `virtual_cell_count()` includes still-in-flight
732 /// `active_cell` entries, this scopes to the current in-flight turn during a
733 /// turn, and to the latest completed turn once the active cell has flushed to
734 /// history. When no user prompt exists yet the whole transcript is used.
735 fn current_turn_range(app: &App) -> (usize, usize) {
736 let end = app.virtual_cell_count();
737 let start = (0..end)
738 .rev()
739 .find(|&idx| {
740 matches!(
741 app.cell_at_virtual_index(idx),
742 Some(HistoryCell::User { .. })
743 )
744 })
745 .unwrap_or(0);
746 (start, end)
747 }
748
749 /// Human form of the runtime turn status — raw enum-ish values like
750 /// "in_progress" must never reach the inspector (dogfood A6, #4102).
751 fn humanized_turn_status(app: &App) -> &str {
752 match app.runtime_turn_status.as_deref() {
753 Some("in_progress") | None => "in progress",
754 Some(other) => other,
755 }
756 }
757
758 /// Short display form of a runtime turn id. The full UUID reads as internal
759 /// state in the inspector header (dogfood A6); twelve characters is plenty
760 /// to correlate with logs.
761 fn short_turn_id(turn_id: &str) -> &str {
762 turn_id.get(..12).unwrap_or(turn_id)
763 }
764
765 /// Assemble the Turn Inspector overview text from all available turn data.
766 #[cfg(test)]
767 pub(super) fn turn_inspector_text(app: &App) -> String {
768 let (start, end) = current_turn_range(app);
769 turn_inspector_text_for_range(app, start, end, 0, 1, true)
770 }
771
772 fn turn_inspector_text_for_range(
773 app: &App,
774 start: usize,
775 end: usize,
776 page_index: usize,
777 page_count: usize,
778 latest: bool,
779 ) -> String {
780 let mut out: Vec<String> = Vec::new();
781
782 // Turn identity header. Lead with the human turn number and status; the
783 // id is a short correlation suffix, never a raw UUID dump (dogfood A6).
784 let status = if latest {
785 std::borrow::Cow::Borrowed(humanized_turn_status(app))
786 } else {
787 tr(app.ui_locale, MessageId::AutomationRunStatusCompleted)
788 };
789 if !latest {
790 let historical_offset = page_count.saturating_sub(page_index + 1) as u64;
791 let number = app
792 .turn_counter
793 .checked_sub(historical_offset)
794 .filter(|number| *number > 0)
795 .unwrap_or(page_index as u64 + 1);
796 out.push(format!("Turn #{number} \u{00B7} {status}"));
797 } else if app.turn_counter > 0 {
798 let mut line = format!("Turn #{} \u{00B7} {status}", app.turn_counter);
799 if let Some(turn_id) = app.runtime_turn_id.as_ref() {
800 line.push_str(&format!(" \u{00B7} id {}", short_turn_id(turn_id)));
801 }
802 out.push(line);
803 } else if let Some(turn_id) = app.runtime_turn_id.as_ref() {
804 out.push(format!("Turn {} \u{00B7} {status}", short_turn_id(turn_id)));
805 } else {
806 out.push("Turn: \u{2014} (no turn recorded yet)".to_string());
807 }
808 // Restate the Ctrl+O (overview) vs. Alt+V/⌥V (raw leaf detail) contract so
809 // the two surfaces never get confused. Bare `v` is never a details shortcut.
810 let details = crate::tui::shell_key_routing::display_chord(
811 crate::tui::shell_key_routing::binding(
812 crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
813 )
814 .footer_chord,
815 );
816 if latest {
817 out.push(format!(
818 "Overview of the current/latest turn · press {details} for the selected item's raw detail"
819 ));
820 }
821
822 push_section(&mut out, "Intent", vec![turn_intent_line(app, start)]);
823
824 if latest && let Some(line) = selected_item_context_line(app) {
825 push_section(&mut out, "Selected item", vec![line]);
826 }
827
828 push_section(
829 &mut out,
830 "To-do",
831 if latest {
832 turn_todo_lines(app)
833 } else {
834 Vec::new()
835 },
836 );
837 let mut timeline = turn_full_conversation_lines(app, start, end);
838 if !timeline.is_empty() {
839 timeline.push(String::new());
840 }
841 timeline.extend(turn_timeline_lines(app, start, end));
842 push_section(&mut out, "Turn timeline", timeline);
843 push_section(
844 &mut out,
845 "Files changed",
846 turn_files_changed(app, start, end),
847 );
848 push_section(
849 &mut out,
850 "Diagnostics loop",
851 if latest {
852 turn_diagnostics_lines(app)
853 } else {
854 Vec::new()
855 },
856 );
857 push_section(
858 &mut out,
859 "Tests / verifier",
860 turn_verifier_lines(app, start, end),
861 );
862 push_section(
863 &mut out,
864 "Approvals / denials",
865 if latest {
866 turn_approvals_lines(app)
867 } else {
868 Vec::new()
869 },
870 );
871 push_section(
872 &mut out,
873 "Model route + tokens/cost",
874 if latest {
875 turn_route_lines(app)
876 } else {
877 Vec::new()
878 },
879 );
880 push_section(
881 &mut out,
882 "Final result / status",
883 turn_result_lines(app, start, end, ResultDetail::Full),
884 );
885
886 out.join("\n")
887 }
888
889 /// Source-faithful, turn-scoped transcript for the inspector page. The normal
890 /// transcript can stay compact/folded; this explicit detail surface preserves
891 /// complete recorded input, reasoning, tool results, and assistant output.
892 fn turn_full_conversation_lines(app: &App, start: usize, end: usize) -> Vec<String> {
893 let thinking_total = (start..end)
894 .filter(|&idx| {
895 matches!(
896 app.cell_at_virtual_index(idx),
897 Some(HistoryCell::Thinking { .. })
898 )
899 })
900 .count();
901 let mut thinking_position = 0usize;
902 let mut out = Vec::new();
903
904 for idx in start..end {
905 let Some(cell) = app.cell_at_virtual_index(idx) else {
906 continue;
907 };
908 let tag = match cell {
909 HistoryCell::User { .. } => "[›]".to_string(),
910 HistoryCell::Thinking { streaming, .. } => {
911 thinking_position += 1;
912 format!(
913 "[∿ {} {thinking_position}/{thinking_total} · {}]",
914 tr(app.ui_locale, MessageId::PhaseReasoning),
915 tr(
916 app.ui_locale,
917 if *streaming {
918 MessageId::AutomationRunStatusRunning
919 } else {
920 MessageId::PhaseDone
921 }
922 )
923 )
924 }
925 HistoryCell::Tool(_) => format!("[⚙ {}]", tr(app.ui_locale, MessageId::PhaseUsingTool)),
926 HistoryCell::SubAgent(_) => "[↗]".to_string(),
927 HistoryCell::Assistant { streaming, .. } => format!(
928 "[◆ · {}]",
929 tr(
930 app.ui_locale,
931 if *streaming {
932 MessageId::AutomationRunStatusRunning
933 } else {
934 MessageId::PhaseDone
935 }
936 )
937 ),
938 HistoryCell::Error { .. } => "[!]".to_string(),
939 HistoryCell::Automation(_) => "[⏱]".to_string(),
940 HistoryCell::System { .. } | HistoryCell::ArchivedContext { .. } => "[i]".to_string(),
941 };
942 if !out.is_empty() {
943 out.push(String::new());
944 }
945 out.push(tag);
946 let body = history_cell_to_clipboard_text(cell, 120);
947 if body.trim().is_empty() {
948 out.push("—".to_string());
949 } else {
950 out.push(body);
951 }
952 }
953
954 out
955 }
956
957 /// Build a compact, pasteable Markdown handoff of the current/latest turn
958 /// (issue #4108).
959 ///
960 /// Reuses the exact same turn scope (`current_turn_range`) and the same
961 /// per-section data helpers as the Turn Inspector (#4104), so the handoff can
962 /// never drift from what Ctrl+O shows — it only re-renders that data as
963 /// Markdown headings + bullets instead of the inspector's box-drawn rules.
964 /// Unavailable sections degrade to a short `—` (and the optional Plan section
965 /// is dropped entirely when empty) so the artifact stays paste-ready without
966 /// leaving a heading over a blank void — the same graceful-degrade contract the
967 /// inspector already follows.
968 pub(crate) fn turn_handoff_markdown(app: &App) -> String {
969 let (start, end) = current_turn_range(app);
970 let mut out: Vec<String> = Vec::new();
971
972 // Title + identity — turn id when known, else the turn counter, else a
973 // bare heading so an empty transcript still yields a coherent artifact.
974 let heading = if app.turn_counter > 0 {
975 format!("# Turn handoff — Turn #{}", app.turn_counter)
976 } else if let Some(turn_id) = app.runtime_turn_id.as_ref() {
977 format!("# Turn handoff — {}", short_turn_id(turn_id))
978 } else {
979 "# Turn handoff".to_string()
980 };
981 out.push(heading);
982
983 let status = match app.runtime_turn_status.as_deref() {
984 Some("in_progress") => "in progress",
985 Some(other) => other,
986 None => "idle",
987 };
988 out.push(format!(
989 "_Status: {status} · generated {}_",
990 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
991 ));
992
993 push_md_section(&mut out, "Intent", vec![turn_intent_line(app, start)]);
994
995 // To-do is optional context: include it only when the canonical list has
996 // items, keeping the handoff compact without recreating a second plan.
997 let todos = turn_todo_lines(app);
998 if !todos.is_empty() {
999 push_md_section(&mut out, "To-do", md_bullets(todos));
1000 }
1001
1002 push_md_section(
1003 &mut out,
1004 "Files changed",
1005 md_bullets(turn_files_changed(app, start, end)),
1006 );
1007 push_md_section(
1008 &mut out,
1009 "Turn timeline",
1010 md_bullets(turn_timeline_lines(app, start, end)),
1011 );
1012 push_md_section(
1013 &mut out,
1014 "Tests / verifier",
1015 md_bullets(turn_verifier_lines(app, start, end)),
1016 );
1017 push_md_section(
1018 &mut out,
1019 "Model route + tokens/cost",
1020 md_bullets(turn_route_lines(app)),
1021 );
1022 push_md_section(
1023 &mut out,
1024 "Result / status",
1025 md_bullets(turn_result_lines(app, start, end, ResultDetail::Compact)),
1026 );
1027
1028 // Trailing newline keeps the artifact clean when pasted into a PR body.
1029 out.push(String::new());
1030 out.join("\n")
1031 }
1032
1033 /// Append a `## Title` Markdown section. An empty body degrades to a single
1034 /// `—` line so a heading is never followed by a void — the Markdown analogue
1035 /// of [`push_section`]'s `none` degrade.
1036 fn push_md_section(out: &mut Vec<String>, title: &str, body: Vec<String>) {
1037 out.push(String::new());
1038 out.push(format!("## {title}"));
1039 if body.is_empty() {
1040 out.push("—".to_string());
1041 } else {
1042 out.extend(body);
1043 }
1044 }
1045
1046 /// Convert Turn Inspector section lines into Markdown bullet rows. Inspector
1047 /// list helpers prefix rows with `• `; swap that for `- `, and bullet the
1048 /// key/value rows (route, tokens, status) too so the whole section is valid
1049 /// Markdown.
1050 fn md_bullets(lines: Vec<String>) -> Vec<String> {
1051 lines
1052 .into_iter()
1053 .map(|line| {
1054 let body = line.strip_prefix("• ").unwrap_or(line.as_str());
1055 format!("- {body}")
1056 })
1057 .collect()
1058 }
1059
1060 /// Append a `── Title ──` section. An empty body degrades to a single
1061 /// `none` line so the section header is never followed by a blank void.
1062 fn push_section(out: &mut Vec<String>, title: &str, body: Vec<String>) {
1063 out.push(String::new());
1064 out.push(format!("── {title} ──"));
1065 if body.is_empty() {
1066 out.push("none".to_string());
1067 } else {
1068 out.extend(body);
1069 }
1070 }
1071
1072 /// Section 1 — intent / user-prompt summary for the turn.
1073 fn turn_intent_line(app: &App, start: usize) -> String {
1074 if let Some(HistoryCell::User { content }) = app.cell_at_virtual_index(start) {
1075 let summary = one_line_summary(content, 240);
1076 if !summary.is_empty() {
1077 return summary;
1078 }
1079 }
1080 if let Some(prompt) = app.last_submitted_prompt.as_deref() {
1081 let summary = one_line_summary(prompt, 240);
1082 if !summary.is_empty() {
1083 return summary;
1084 }
1085 }
1086 "—".to_string()
1087 }
1088
1089 /// Optional selected-item context. The first view is the turn overview, but
1090 /// when the user has an activity cell selected we surface it plus the Alt+V
1091 /// affordance so the Ctrl+O / Alt+V split stays discoverable.
1092 fn selected_item_context_line(app: &App) -> Option<String> {
1093 let idx = selected_transcript_cell_index(app)?;
1094 let cell = app.cell_at_virtual_index(idx)?;
1095 let label = truncate_line_to_width(&activity_cell_label(app, idx, cell), 48);
1096 let hint = if app.cell_has_detail_target(idx) {
1097 let details = crate::tui::shell_key_routing::display_chord(
1098 crate::tui::shell_key_routing::binding(
1099 crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
1100 )
1101 .footer_chord,
1102 );
1103 if matches!(cell, HistoryCell::Error { .. }) {
1104 format!(" · {details} opens the full error")
1105 } else {
1106 format!(" · {details} opens its raw detail")
1107 }
1108 } else {
1109 String::new()
1110 };
1111 Some(format!("{label}{hint}"))
1112 }
1113
1114 /// Section 2 — canonical To-do state.
1115 fn turn_todo_lines(app: &App) -> Vec<String> {
1116 let mut lines = Vec::new();
1117
1118 if let Ok(todos) = app.todos.try_lock() {
1119 let snapshot = todos.snapshot();
1120 if !snapshot.items.is_empty() {
1121 lines.push(format!("To-do: {}% settled", snapshot.completion_pct));
1122 for item in &snapshot.items {
1123 lines.push(format!(
1124 "{} {}",
1125 todo_status_glyph(&item.status),
1126 truncate_line_to_width(&item.content, 72)
1127 ));
1128 }
1129 }
1130 }
1131
1132 lines
1133 }
1134
1135 fn todo_status_glyph(status: &crate::tools::todo::TodoStatus) -> &'static str {
1136 match status {
1137 crate::tools::todo::TodoStatus::Completed => "[x]",
1138 crate::tools::todo::TodoStatus::InProgress => "[~]",
1139 crate::tools::todo::TodoStatus::Pending => "[ ]",
1140 crate::tools::todo::TodoStatus::Cancelled => "[-]",
1141 }
1142 }
1143
1144 /// Section 3 — chronological turn timeline with compact action affordances.
1145 fn turn_timeline_lines(app: &App, start: usize, end: usize) -> Vec<String> {
1146 let mut rows = Vec::new();
1147 for idx in start..end {
1148 let Some(cell) = app.cell_at_virtual_index(idx) else {
1149 continue;
1150 };
1151 match cell {
1152 HistoryCell::User { content } => {
1153 let summary = one_line_summary(content, 96);
1154 rows.push(timeline_row("user prompt", &summary, None, None, &[]));
1155 }
1156 HistoryCell::Thinking {
1157 content,
1158 streaming,
1159 duration_secs,
1160 } => {
1161 let summary = one_line_summary(content, 88);
1162 let status = streaming.then_some("running").unwrap_or("done");
1163 let duration = duration_secs
1164 .map(|secs| crate::elapsed::format_elapsed_ms((secs * 1000.0) as u64));
1165 let actions = timeline_cell_actions(app, idx, cell);
1166 rows.push(timeline_row(
1167 "reasoning",
1168 &summary,
1169 Some(status),
1170 duration.as_deref(),
1171 &actions,
1172 ));
1173 }
1174 HistoryCell::Tool(tool) => {
1175 let (kind, summary) = timeline_tool_summary(app, idx, tool);
1176 let duration =
1177 tool_duration_for_activity(tool).map(crate::elapsed::format_elapsed_ms);
1178 let status = tool.status().map(activity_status_label);
1179 let actions = timeline_cell_actions(app, idx, cell);
1180 rows.push(timeline_row(
1181 kind,
1182 &summary,
1183 status,
1184 duration.as_deref(),
1185 &actions,
1186 ));
1187 }
1188 HistoryCell::SubAgent(_) => {
1189 let summary = detail_target_label(app, idx).unwrap_or_else(|| "sub-agent".into());
1190 let actions = timeline_cell_actions(app, idx, cell);
1191 rows.push(timeline_row("sub-agent", &summary, None, None, &actions));
1192 }
1193 HistoryCell::Assistant { content, streaming } => {
1194 let summary = one_line_summary(content, 96);
1195 let status = streaming.then_some("streaming").unwrap_or("done");
1196 rows.push(timeline_row(
1197 "assistant result",
1198 &summary,
1199 Some(status),
1200 None,
1201 &[],
1202 ));
1203 }
1204 HistoryCell::Error { message, severity } => {
1205 let summary = one_line_summary(message, 96);
1206 let status = severity.to_string();
1207 rows.push(timeline_row("error", &summary, Some(&status), None, &[]));
1208 }
1209 HistoryCell::Automation(cell) => {
1210 let summary = one_line_summary(&cell.plain_summary(), 96);
1211 rows.push(timeline_row("automation", &summary, None, None, &[]));
1212 }
1213 HistoryCell::System { content }
1214 | HistoryCell::ArchivedContext {
1215 summary: content, ..
1216 } => {
1217 let summary = one_line_summary(content, 96);
1218 rows.push(timeline_row("system note", &summary, None, None, &[]));
1219 }
1220 }
1221 }
1222 rows.push(turn_checkpoint_timeline_row(app));
1223 rows.into_iter()
1224 .enumerate()
1225 .map(|(idx, row)| format!("{}. {row}", idx + 1))
1226 .collect()
1227 }
1228
1229 fn timeline_tool_summary(app: &App, idx: usize, tool: &ToolCell) -> (&'static str, String) {
1230 match tool {
1231 ToolCell::Exec(exec) if command_looks_like_verifier(&exec.command) => {
1232 ("test/verifier", truncate_line_to_width(&exec.command, 88))
1233 }
1234 ToolCell::Exec(exec) => ("shell command", truncate_line_to_width(&exec.command, 88)),
1235 ToolCell::Exploring(explore) => (
1236 "read/search",
1237 format!(
1238 "{} item{}",
1239 explore.entries.len(),
1240 if explore.entries.len() == 1 { "" } else { "s" }
1241 ),
1242 ),
1243 ToolCell::PlanUpdate(_) => ("legacy plan", "Legacy plan metadata replayed".to_string()),
1244 ToolCell::PatchSummary(patch) => {
1245 let summary = one_line_summary(&patch.summary, 72);
1246 if summary.is_empty() {
1247 ("edit", truncate_line_to_width(&patch.path, 88))
1248 } else {
1249 (
1250 "edit",
1251 truncate_line_to_width(&format!("{} — {summary}", patch.path), 88),
1252 )
1253 }
1254 }
1255 ToolCell::Review(review) => {
1256 let target = one_line_summary(&review.target, 88);
1257 (
1258 "review",
1259 if target.is_empty() {
1260 "code review".to_string()
1261 } else {
1262 target
1263 },
1264 )
1265 }
1266 ToolCell::Mcp(mcp) => ("MCP tool", truncate_line_to_width(&mcp.tool, 88)),
1267 ToolCell::ViewImage(image) => (
1268 "image",
1269 truncate_line_to_width(&image.path.display().to_string(), 88),
1270 ),
1271 ToolCell::WebSearch(search) => ("web search", truncate_line_to_width(&search.query, 88)),
1272 ToolCell::Generic(generic) => {
1273 let mut label =
1274 detail_target_label(app, idx).unwrap_or_else(|| generic.name.replace('_', " "));
1275 if let Some(input) = generic.input_summary.as_deref().map(str::trim)
1276 && !input.is_empty()
1277 {
1278 label.push_str(" · ");
1279 label.push_str(input);
1280 }
1281 (
1282 generic_tool_timeline_kind(generic),
1283 truncate_line_to_width(&label, 88),
1284 )
1285 }
1286 }
1287 }
1288
1289 fn generic_tool_timeline_kind(generic: &crate::tui::history::GenericToolCell) -> &'static str {
1290 let name = generic.name.as_str();
1291 if generic.is_diff || name.contains("diff") {
1292 "diff"
1293 } else if matches!(name, "read_file" | "list_files" | "glob" | "grep_files")
1294 || name.contains("read")
1295 || name.contains("search")
1296 || name.contains("grep")
1297 {
1298 "read/search"
1299 } else if matches!(name, "apply_patch" | "edit_file" | "write_file")
1300 || name.contains("patch")
1301 || name.contains("edit")
1302 || name.contains("write")
1303 {
1304 "edit"
1305 } else if name.contains("approval") {
1306 "approval"
1307 } else if name.contains("diagnostic") || name.contains("lsp") {
1308 "diagnostics"
1309 } else {
1310 "tool"
1311 }
1312 }
1313
1314 fn timeline_cell_actions(app: &App, idx: usize, cell: &HistoryCell) -> Vec<String> {
1315 let mut actions = Vec::new();
1316 if app.cell_has_detail_target(idx) {
1317 let details = crate::tui::shell_key_routing::display_chord(
1318 crate::tui::shell_key_routing::binding(
1319 crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
1320 )
1321 .footer_chord,
1322 );
1323 // Diff-bearing cells open their diff through the same details chord;
1324 // bare `v` / `d` always type text (TUI-DOG-002), so no bare-key claim.
1325 let is_diff = matches!(cell, HistoryCell::Tool(ToolCell::PatchSummary(_)))
1326 || matches!(
1327 cell,
1328 HistoryCell::Tool(ToolCell::Generic(generic)) if generic.is_diff
1329 );
1330 if is_diff {
1331 actions.push(format!("{details} diff"));
1332 } else if matches!(cell, HistoryCell::Error { .. }) {
1333 actions.push(format!("{details} full error"));
1334 } else {
1335 actions.push(format!("{details} raw detail"));
1336 }
1337 }
1338 actions
1339 }
1340
1341 fn timeline_row(
1342 kind: &str,
1343 summary: &str,
1344 status: Option<&str>,
1345 duration: Option<&str>,
1346 actions: &[String],
1347 ) -> String {
1348 let mut line = if summary.trim().is_empty() {
1349 kind.to_string()
1350 } else {
1351 format!("{kind}: {}", summary.trim())
1352 };
1353 if let Some(status) = status.filter(|s| !s.trim().is_empty()) {
1354 line.push_str(" — ");
1355 line.push_str(status);
1356 }
1357 if let Some(duration) = duration.filter(|s| !s.trim().is_empty()) {
1358 line.push_str(" · ");
1359 line.push_str(duration);
1360 }
1361 if !actions.is_empty() {
1362 line.push_str(" · actions: ");
1363 line.push_str(&actions.join(", "));
1364 }
1365 line
1366 }
1367
1368 fn turn_checkpoint_timeline_row(app: &App) -> String {
1369 if app.turn_counter == 0 {
1370 return "checkpoint: unavailable — no numbered turn snapshot yet · action: e export handoff"
1371 .to_string();
1372 }
1373
1374 let repo = match SnapshotRepo::open_existing(&app.workspace) {
1375 Ok(Some(repo)) => repo,
1376 Ok(None) => {
1377 return "checkpoint: unavailable — no snapshot repo found · action: e export handoff"
1378 .to_string();
1379 }
1380 Err(err) => {
1381 return format!(
1382 "checkpoint: unknown — snapshot repo could not be opened ({}) · action: e export handoff",
1383 truncate_line_to_width(&err.to_string(), 72)
1384 );
1385 }
1386 };
1387 let snapshots = match repo.list(20) {
1388 Ok(snapshots) => snapshots,
1389 Err(err) => {
1390 return format!(
1391 "checkpoint: unknown — snapshot list failed ({}) · action: e export handoff",
1392 truncate_line_to_width(&err.to_string(), 72)
1393 );
1394 }
1395 };
1396 let prefix = format!("pre-turn:{}", app.turn_counter);
1397 let matching = snapshots
1398 .iter()
1399 .find(|snapshot| {
1400 snapshot.label == prefix || snapshot.label.starts_with(&format!("{prefix}:"))
1401 })
1402 .or_else(|| {
1403 snapshots
1404 .iter()
1405 .find(|snapshot| snapshot.label.starts_with("pre-turn:"))
1406 });
1407 if let Some(snapshot) = matching {
1408 let short = &snapshot.id.as_str()[..snapshot.id.as_str().len().min(8)];
1409 format!(
1410 "checkpoint: {} ({short}) available · actions: r restore via /restore (guarded), e export handoff",
1411 truncate_line_to_width(&snapshot.label, 72)
1412 )
1413 } else {
1414 "checkpoint: unavailable — no pre-turn snapshot found · action: e export handoff"
1415 .to_string()
1416 }
1417 }
1418
1419 /// Section 4 — files touched by patch/diff tool cells in the turn.
1420 fn turn_files_changed(app: &App, start: usize, end: usize) -> Vec<String> {
1421 let mut lines = Vec::new();
1422 let mut seen = std::collections::HashSet::new();
1423 for idx in start..end {
1424 let Some(HistoryCell::Tool(tool)) = app.cell_at_virtual_index(idx) else {
1425 continue;
1426 };
1427 match tool {
1428 ToolCell::PatchSummary(patch) if seen.insert(patch.path.clone()) => {
1429 lines.push(format!(
1430 "• {} — {}",
1431 truncate_line_to_width(&patch.path, 60),
1432 activity_status_label(patch.status)
1433 ));
1434 }
1435 _ => {}
1436 }
1437 }
1438 lines
1439 }
1440
1441 /// Section 5 — diagnostics / LSP repair loop (#4107).
1442 ///
1443 /// Shows the observable repair loop when LSP produced diagnostics this turn.
1444 /// Stays quiet when LSP is disabled or no diagnostics were found.
1445 fn turn_diagnostics_lines(app: &App) -> Vec<String> {
1446 if !app.lsp_enabled {
1447 return Vec::new();
1448 }
1449 let repair = &app.lsp_repair;
1450 if repair.diagnostics_found == 0 && !repair.injected && !repair.repair_attempted {
1451 return Vec::new();
1452 }
1453 let mut lines = Vec::new();
1454 if repair.diagnostics_found > 0 {
1455 lines.push(format!(
1456 "Found {} diagnostic{} across {} file{}",
1457 repair.diagnostics_found,
1458 if repair.diagnostics_found == 1 {
1459 ""
1460 } else {
1461 "s"
1462 },
1463 repair.files_touched.max(1),
1464 if repair.files_touched == 1 { "" } else { "s" },
1465 ));
1466 }
1467 lines.push(if repair.injected {
1468 "Injected into the next model request".to_string()
1469 } else {
1470 "Queued — not yet injected".to_string()
1471 });
1472 if repair.repair_attempted {
1473 lines.push("Model attempted a repair after injection".to_string());
1474 }
1475 let latest = match repair.latest {
1476 "resolved" => "Latest: resolved",
1477 "still_failing" => "Latest: still failing",
1478 "unavailable" => "Latest: unavailable",
1479 _ => "Latest: unknown",
1480 };
1481 lines.push(latest.to_string());
1482 lines
1483 }
1484
1485 /// Section 6 — tests / verifier results.
1486 ///
1487 /// Heuristic first pass (issue #4107): scans the turn's exec/review tool cells
1488 /// for verifier-shaped commands and reports their status. Degrades to `none`
1489 /// when nothing test-shaped ran.
1490 fn turn_verifier_lines(app: &App, start: usize, end: usize) -> Vec<String> {
1491 let mut lines = Vec::new();
1492 for idx in start..end {
1493 let Some(HistoryCell::Tool(tool)) = app.cell_at_virtual_index(idx) else {
1494 continue;
1495 };
1496 match tool {
1497 ToolCell::Exec(exec) if command_looks_like_verifier(&exec.command) => {
1498 lines.push(format!(
1499 "• {} — {}",
1500 truncate_line_to_width(&exec.command, 56),
1501 activity_status_label(exec.status)
1502 ));
1503 }
1504 ToolCell::Review(review) => {
1505 let target = truncate_line_to_width(review.target.trim(), 48);
1506 let target = if target.is_empty() {
1507 "review".to_string()
1508 } else {
1509 format!("review {target}")
1510 };
1511 lines.push(format!(
1512 "• {target} — {}",
1513 activity_status_label(review.status)
1514 ));
1515 }
1516 _ => {}
1517 }
1518 }
1519 lines
1520 }
1521
1522 fn command_looks_like_verifier(command: &str) -> bool {
1523 let lower = command.to_lowercase();
1524 [
1525 "test",
1526 "pytest",
1527 "jest",
1528 "cargo check",
1529 "cargo clippy",
1530 "verif",
1531 "lint",
1532 ]
1533 .iter()
1534 .any(|needle| lower.contains(needle))
1535 }
1536
1537 /// Section 7 — approvals / denials.
1538 ///
1539 /// "Approve for session" grants last the conversation; a Deny lasts only
1540 /// the user turn it answered (cleared on `TurnStarted`), so each count names
1541 /// its own scope.
1542 fn turn_approvals_lines(app: &App) -> Vec<String> {
1543 let mut lines = Vec::new();
1544 let approved = app.approval_session_approved.len();
1545 let denied = app.approval_session_denied.len();
1546 if approved > 0 {
1547 lines.push(format!("Approved (session): {approved}"));
1548 }
1549 if denied > 0 {
1550 lines.push(format!("Denied (this turn): {denied}"));
1551 }
1552 lines
1553 }
1554
1555 /// Section 8 — model route plus token/cost accounting.
1556 fn turn_route_lines(app: &App) -> Vec<String> {
1557 let mut lines = Vec::new();
1558
1559 let (provider, model) = if let Some(route) = app
1560 .active_turn
1561 .as_ref()
1562 .and_then(|turn| turn.route.as_ref())
1563 {
1564 let provider = if route.provider == crate::config::ProviderKind::Custom {
1565 route.provider_identity.clone()
1566 } else {
1567 route.provider.provider().display_name().to_string()
1568 };
1569 (provider, route.model.clone())
1570 } else {
1571 // Pending and last Auto routes use the same billing-authoritative
1572 // display contract as the header; do not fall back to `auto` after the
1573 // concrete turn route has resolved.
1574 app.effective_route_identity_display()
1575 };
1576 lines.push(format!("Route: {provider} · {model}"));
1577
1578 let auto_receipt = app
1579 .active_turn
1580 .as_ref()
1581 .filter(|turn| turn.route.as_ref().is_some_and(|route| route.auto_model))
1582 .and_then(|turn| turn.auto_route_receipt.as_ref())
1583 .or_else(|| {
1584 app.pending_turn_route
1585 .as_ref()
1586 .filter(|(_, _, auto_model)| *auto_model)
1587 .and(app.pending_auto_route_receipt.as_ref())
1588 })
1589 .or_else(|| {
1590 app.auto_model
1591 .then_some(app.last_auto_route_receipt.as_ref())
1592 .flatten()
1593 });
1594 if let Some(receipt) = auto_receipt {
1595 lines.push(format!(
1596 "Auto decision: {} · {}",
1597 receipt.tier.label(),
1598 receipt.reason.label()
1599 ));
1600 let pair = receipt.pair.fast.as_deref().map_or_else(
1601 || format!("{} (no runnable fast sibling)", receipt.pair.strong),
1602 |fast| format!("{} strong · {fast} fast", receipt.pair.strong),
1603 );
1604 lines.push(format!("Auto pair: {pair}"));
1605 lines.push(format!("Auto scope: {}", receipt.scope.label()));
1606 lines.push(format!("Auto data: {}", receipt.data_path.label()));
1607 lines.extend(auto_router_receipt_lines(receipt));
1608 }
1609
1610 let session = &app.session;
1611 match (session.last_prompt_tokens, session.last_completion_tokens) {
1612 (Some(prompt), Some(completion)) => {
1613 lines.push(format!(
1614 "Tokens (last turn): {prompt} in · {completion} out"
1615 ));
1616 }
1617 (Some(prompt), None) => lines.push(format!("Tokens (last turn): {prompt} in")),
1618 (None, Some(completion)) => lines.push(format!("Tokens (last turn): {completion} out")),
1619 (None, None) => {
1620 if session.total_tokens > 0 {
1621 lines.push(format!("Tokens (session): {}", session.total_tokens));
1622 }
1623 }
1624 }
1625
1626 let chip = app.cumulative_usage_chip();
1627 match &chip {
1628 crate::route_billing::UsageChip::Money(amount) => {
1629 lines.push(format!("Cost (session): {amount}"));
1630 }
1631 crate::route_billing::UsageChip::PricedSubtotal { .. } => {
1632 lines.push(format!(
1633 "Cost (session): {}",
1634 crate::route_billing::format_usage_chip(&chip, app.ui_locale).unwrap_or_default()
1635 ));
1636 }
1637 crate::route_billing::UsageChip::Allowance { label, used_pct } => {
1638 lines.push(match used_pct {
1639 Some(pct) => format!("Usage plan: {label} ({pct:.0}% used)"),
1640 None => format!("Usage plan: {label}"),
1641 });
1642 }
1643 crate::route_billing::UsageChip::Local => {
1644 lines.push("Cost: local".to_string());
1645 }
1646 crate::route_billing::UsageChip::Unknown(_) => {
1647 lines.push(
1648 crate::route_billing::format_usage_chip(&chip, app.ui_locale).unwrap_or_default(),
1649 );
1650 }
1651 crate::route_billing::UsageChip::Hidden => {}
1652 }
1653
1654 lines
1655 }
1656
1657 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1658 enum ResultDetail {
1659 /// Pager content is the review surface and must retain the complete final
1660 /// response. Width wrapping belongs to `PagerView`, not data assembly.
1661 Full,
1662 /// The exported handoff is intentionally a compact overview.
1663 Compact,
1664 }
1665
1666 /// `/status` lines for the router behind an Auto receipt (#6525): the
1667 /// decision-model answer, its cost and latency, and a failing router.
1668 fn auto_router_receipt_lines(receipt: &crate::model_routing::AutoRouteReceipt) -> Vec<String> {
1669 let percent = |bp: u16| format!("{}%", (u32::from(bp) + 50) / 100);
1670 let mut lines = Vec::new();
1671 if let Some(decision) = receipt.decision.as_ref() {
1672 let cost = decision.provider_reported_cost_usd.as_deref().map_or_else(
1673 || "cost not reported".to_string(),
1674 |cost| format!("${cost} (provider-reported)"),
1675 );
1676 let model = decision
1677 .response_model
1678 .as_deref()
1679 .map(|model| format!(" · {model}"))
1680 .unwrap_or_default();
1681 lines.push(format!(
1682 "Auto router: decision model{model} · {} ms · {cost}",
1683 decision.latency_ms
1684 ));
1685 let mut probabilities: Vec<(&String, &u16)> = decision.probabilities_bp.iter().collect();
1686 probabilities.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
1687 let probabilities = probabilities
1688 .into_iter()
1689 .map(|(option, bp)| format!("{option} {}", percent(*bp)))
1690 .collect::<Vec<_>>()
1691 .join(" · ");
1692 let mut choice = format!(
1693 "Auto choice: {} ({probabilities}) · confidence {} (min {})",
1694 decision.choice,
1695 percent(decision.confidence_bp),
1696 percent(decision.min_confidence_bp)
1697 );
1698 if decision.cost_saving_kept_fast {
1699 choice.push_str(" · cost-saving kept fast");
1700 }
1701 if let Some(thinking) = decision.thinking.as_deref() {
1702 choice.push_str(&format!(" · thinking {thinking}"));
1703 }
1704 lines.push(choice);
1705 }
1706 if let Some(failure) = receipt.router_failure {
1707 lines.push(format!(
1708 "Auto router: failing — {} (used the local fallback)",
1709 failure.label()
1710 ));
1711 }
1712 lines
1713 }
1714
1715 fn cleaned_turn_text(text: &str, detail: ResultDetail, max_width: usize) -> String {
1716 if detail == ResultDetail::Compact {
1717 return one_line_summary(text, max_width);
1718 }
1719
1720 let mut cleaned = String::with_capacity(text.len());
1721 crate::tui::osc8::strip_ansi_into(text, &mut cleaned);
1722 cleaned.trim().to_string()
1723 }
1724
1725 /// Section 9 — final result / current status.
1726 fn turn_result_lines(app: &App, start: usize, end: usize, detail: ResultDetail) -> Vec<String> {
1727 let mut lines = Vec::new();
1728
1729 let status = match app.runtime_turn_status.as_deref() {
1730 Some("in_progress") => "in progress",
1731 Some(other) => other,
1732 None => "idle",
1733 };
1734 lines.push(format!("Status: {status}"));
1735
1736 let final_text = (start..end)
1737 .rev()
1738 .find_map(|idx| match app.cell_at_virtual_index(idx) {
1739 Some(HistoryCell::Assistant { content, .. }) => {
1740 let text = cleaned_turn_text(content, detail, 200);
1741 (!text.is_empty()).then_some(text)
1742 }
1743 _ => None,
1744 });
1745 if let Some(text) = final_text {
1746 lines.push(format!("Result: {text}"));
1747 } else if status == "in progress" {
1748 lines.push("Result: turn still running".to_string());
1749 } else {
1750 lines.push("Result: —".to_string());
1751 }
1752
1753 let error_text = (start..end)
1754 .rev()
1755 .find_map(|idx| match app.cell_at_virtual_index(idx) {
1756 Some(HistoryCell::Error { message, .. }) => {
1757 let text = cleaned_turn_text(message, detail, 160);
1758 (!text.is_empty()).then_some(text)
1759 }
1760 _ => None,
1761 });
1762 if let Some(err) = error_text {
1763 lines.push(format!("Error: {err}"));
1764 }
1765
1766 lines
1767 }
1768
1769 #[cfg(test)]
1770 mod tests {
1771 use super::*;
1772 use crate::config::Config;
1773 use crate::tui::app::{App, LspRepairState, TuiOptions};
1774 use std::path::PathBuf;
1775
1776 fn test_app() -> App {
1777 let options = TuiOptions {
1778 model: "deepseek-v4-flash".to_string(),
1779 start_in_agent_mode: true,
1780 ..crate::test_support::test_tui_options(PathBuf::from("."))
1781 };
1782 App::new(options, &Config::default())
1783 }
1784
1785 #[test]
1786 fn turn_diagnostics_lines_quiet_when_no_activity() {
1787 let mut app = test_app();
1788 app.lsp_enabled = true;
1789 assert!(turn_diagnostics_lines(&app).is_empty());
1790 app.lsp_enabled = false;
1791 assert!(turn_diagnostics_lines(&app).is_empty());
1792 }
1793
1794 #[test]
1795 fn turn_diagnostics_lines_summarize_repair_loop() {
1796 let mut app = test_app();
1797 app.lsp_enabled = true;
1798 app.lsp_repair = LspRepairState {
1799 diagnostics_found: 2,
1800 files_touched: 1,
1801 injected: true,
1802 repair_attempted: true,
1803 latest: "still_failing",
1804 };
1805 let joined = turn_diagnostics_lines(&app).join("\n");
1806 assert!(joined.contains("Found 2 diagnostics"), "{joined}");
1807 assert!(
1808 joined.contains("Injected into the next model request"),
1809 "{joined}"
1810 );
1811 assert!(joined.contains("Model attempted a repair"), "{joined}");
1812 assert!(joined.contains("still failing"), "{joined}");
1813 }
1814
1815 #[test]
1816 fn turn_route_lines_show_decision_evidence_and_a_failing_router() {
1817 let mut app = test_app();
1818 app.auto_model = true;
1819 app.last_effective_provider = Some(crate::config::ProviderKind::Deepseek);
1820 app.last_effective_model = Some("deepseek-v4-pro".to_string());
1821 let decision = crate::model_routing::AutoRouteDecisionEvidence {
1822 choice: "strong".to_string(),
1823 probabilities_bp: [("fast".to_string(), 1800), ("strong".to_string(), 8200)]
1824 .into_iter()
1825 .collect(),
1826 confidence_bp: 6400,
1827 min_confidence_bp: 5000,
1828 cost_saving_kept_fast: false,
1829 thinking: Some("max".to_string()),
1830 provider_reported_cost_usd: Some("0.000019992".to_string()),
1831 latency_ms: 180,
1832 response_model: Some("typesafe/jev-1.13-20260917".to_string()),
1833 };
1834 let receipt = crate::model_routing::AutoRouteReceipt {
1835 tier: crate::model_routing::AutoRouteTier::Strong,
1836 pair: crate::model_routing::AutoRoutePair {
1837 strong: "deepseek-v4-pro".to_string(),
1838 fast: Some("deepseek-v4-flash".to_string()),
1839 },
1840 scope: crate::model_routing::AutoRouteScope::ActiveProvider,
1841 data_path: crate::model_routing::AutoRouteDataPath::Decision {
1842 route: crate::client::system_one::DecisionRouterRoute::Openrouter,
1843 model: "typesafe/jev-1.13".to_string(),
1844 },
1845 reason: crate::model_routing::AutoRouteReason::ClassifierRecommendation,
1846 decision: Some(decision),
1847 router_failure: None,
1848 };
1849 app.last_auto_route_receipt = Some(receipt.clone());
1850 let joined = turn_route_lines(&app).join("\n");
1851 assert!(joined.contains("-> OpenRouter / typesafe/jev-1.13 (decision model)"));
1852 assert!(joined.contains(
1853 "Auto router: decision model · typesafe/jev-1.13-20260917 · 180 ms · $0.000019992 (provider-reported)"
1854 ));
1855 assert!(joined.contains(
1856 "Auto choice: strong (strong 82% · fast 18%) · confidence 64% (min 50%) · thinking max"
1857 ));
1858 assert!(!joined.contains("failing"));
1859
1860 app.last_auto_route_receipt = Some(crate::model_routing::AutoRouteReceipt {
1861 reason: crate::model_routing::AutoRouteReason::ClassifierFallback(
1862 crate::model_routing::AutoRouteHeuristicReason::DeclaredDefault,
1863 ),
1864 decision: None,
1865 router_failure: Some(crate::model_routing::AutoRouterFailure::Http { status: 402 }),
1866 ..receipt
1867 });
1868 let joined = turn_route_lines(&app).join("\n");
1869 assert!(
1870 joined.contains("Auto router: failing — HTTP 402 (used the local fallback)"),
1871 "{joined}"
1872 );
1873 }
1874
1875 #[test]
1876 fn turn_route_lines_include_truthful_auto_receipt() {
1877 let mut app = test_app();
1878 app.auto_model = true;
1879 app.last_effective_provider = Some(crate::config::ProviderKind::Zai);
1880 app.last_effective_model = Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string());
1881 app.last_auto_route_receipt = Some(crate::model_routing::AutoRouteReceipt {
1882 tier: crate::model_routing::AutoRouteTier::Fast,
1883 pair: crate::model_routing::AutoRoutePair {
1884 strong: crate::config::ZAI_GLM_5_2_MODEL.to_string(),
1885 fast: Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()),
1886 },
1887 scope: crate::model_routing::AutoRouteScope::RunnableProviders,
1888 data_path: crate::model_routing::AutoRouteDataPath::Classifier {
1889 provider: "deepseek".into(),
1890 provider_kind: crate::config::ProviderKind::Deepseek,
1891 model: "deepseek-v4-flash".to_string(),
1892 },
1893 reason: crate::model_routing::AutoRouteReason::ClassifierRecommendation,
1894 decision: None,
1895 router_failure: None,
1896 });
1897
1898 let joined = turn_route_lines(&app).join("\n");
1899
1900 assert!(joined.contains("Route: Zhipu AI / Z.ai · GLM-5-Turbo"));
1901 assert!(joined.contains("Auto decision: fast · classifier recommendation"));
1902 assert!(joined.contains("GLM-5.2 strong · GLM-5-Turbo fast"));
1903 assert!(joined.contains("Auto scope: runnable providers"));
1904 assert!(joined.contains(
1905 "Auto data: latest request + bounded recent context -> DeepSeek / deepseek-v4-flash"
1906 ));
1907 assert!(!joined.contains("API_KEY"));
1908 }
1909
1910 #[test]
1911 fn reasoning_detail_text_empty_when_no_thinking() {
1912 let app = test_app();
1913 assert!(reasoning_detail_text(&app).is_none());
1914 }
1915
1916 #[test]
1917 fn reasoning_detail_text_includes_active_cell_reasoning() {
1918 let mut app = test_app();
1919 let mut active = crate::tui::active_cell::ActiveCell::new();
1920 active.push_thinking(HistoryCell::Thinking {
1921 content: "active reasoning one".to_string(),
1922 streaming: true,
1923 duration_secs: None,
1924 });
1925 active.push_thinking(HistoryCell::Thinking {
1926 content: "active reasoning two".to_string(),
1927 streaming: false,
1928 duration_secs: Some(1.0),
1929 });
1930 app.active_cell = Some(active);
1931 app.runtime_turn_id = Some("turn-active-123".to_string());
1932 app.runtime_turn_status = Some("in_progress".to_string());
1933
1934 let body = reasoning_detail_text(&app).expect("active reasoning should produce detail");
1935 assert!(body.contains("Thinking chunk 1 of 2"), "{body}");
1936 assert!(body.contains("Thinking chunk 2 of 2"), "{body}");
1937 assert!(body.contains("active reasoning one"), "{body}");
1938 assert!(body.contains("active reasoning two"), "{body}");
1939 assert!(body.contains("running"), "{body}");
1940 }
1941
1942 #[test]
1943 fn reasoning_detail_text_scopes_to_latest_turn_without_selection() {
1944 let mut app = test_app();
1945 app.history = vec![
1946 HistoryCell::User {
1947 content: "first prompt".to_string(),
1948 },
1949 HistoryCell::Thinking {
1950 content: "first turn reasoning".to_string(),
1951 streaming: false,
1952 duration_secs: Some(1.0),
1953 },
1954 HistoryCell::Assistant {
1955 content: "first reply".to_string(),
1956 streaming: false,
1957 },
1958 HistoryCell::User {
1959 content: "second prompt".to_string(),
1960 },
1961 HistoryCell::Thinking {
1962 content: "second turn reasoning".to_string(),
1963 streaming: false,
1964 duration_secs: Some(1.0),
1965 },
1966 HistoryCell::Assistant {
1967 content: "second reply".to_string(),
1968 streaming: false,
1969 },
1970 ];
1971 app.resync_history_revisions();
1972
1973 let body =
1974 reasoning_detail_text(&app).expect("latest turn reasoning should produce detail");
1975 assert!(body.contains("second turn reasoning"), "{body}");
1976 assert!(
1977 !body.contains("first turn reasoning"),
1978 "reasoning detail without selection must scope to the latest turn: {body}"
1979 );
1980 }
1981
1982 #[test]
1983 fn turn_range_for_index_scopes_to_containing_turn() {
1984 let mut app = test_app();
1985 app.history = vec![
1986 HistoryCell::User {
1987 content: "first prompt".to_string(),
1988 },
1989 HistoryCell::Thinking {
1990 content: "first turn reasoning".to_string(),
1991 streaming: false,
1992 duration_secs: Some(1.0),
1993 },
1994 HistoryCell::Assistant {
1995 content: "first reply".to_string(),
1996 streaming: false,
1997 },
1998 HistoryCell::User {
1999 content: "second prompt".to_string(),
2000 },
2001 HistoryCell::Thinking {
2002 content: "second turn reasoning".to_string(),
2003 streaming: false,
2004 duration_secs: Some(1.0),
2005 },
2006 HistoryCell::Assistant {
2007 content: "second reply".to_string(),
2008 streaming: false,
2009 },
2010 ];
2011 app.resync_history_revisions();
2012
2013 let (start, end) = turn_range_for_index(&app, 1);
2014 assert_eq!(start, 0, "first turn should start at user cell 0");
2015 assert_eq!(end, 3, "first turn should end before second user cell");
2016
2017 let (start, end) = turn_range_for_index(&app, 4);
2018 assert_eq!(start, 3, "second turn should start at user cell 3");
2019 assert_eq!(end, 6, "second turn should run to end of transcript");
2020 }
2021
2022 #[test]
2023 fn open_reasoning_detail_pager_pushes_reasoning_detail_pager() {
2024 let mut app = test_app();
2025 app.history = vec![HistoryCell::Thinking {
2026 content: "recorded reasoning".to_string(),
2027 streaming: false,
2028 duration_secs: Some(1.0),
2029 }];
2030 app.resync_history_revisions();
2031 let revisions = app.history_revisions.clone();
2032 app.viewport.transcript_cache.ensure(
2033 &app.history,
2034 &revisions,
2035 100,
2036 app.transcript_render_options(),
2037 );
2038 app.viewport.last_transcript_area = Some(ratatui::layout::Rect {
2039 x: 0,
2040 y: 0,
2041 width: 80,
2042 height: 24,
2043 });
2044
2045 assert!(open_reasoning_detail_pager(&mut app));
2046 let top = app.view_stack.top_kind();
2047 assert_eq!(top, Some(crate::tui::views::ModalKind::Pager));
2048 }
2049
2050 #[test]
2051 fn copy_cell_to_clipboard_uses_canonical_assistant_source() {
2052 let mut app = test_app();
2053 let content = "A long response with literal ● and ▏ glyphs that wraps visually.";
2054 app.history = vec![HistoryCell::Assistant {
2055 content: content.to_string(),
2056 streaming: false,
2057 }];
2058 app.resync_history_revisions();
2059 app.viewport.last_transcript_area = Some(ratatui::layout::Rect {
2060 x: 0,
2061 y: 0,
2062 width: 12,
2063 height: 24,
2064 });
2065
2066 assert!(copy_cell_to_clipboard(&mut app, 0));
2067 assert_eq!(app.clipboard.last_written_text(), Some(content));
2068 }
2069
2070 #[test]
2071 fn focused_pager_and_copy_use_the_same_cell_target() {
2072 let mut app = test_app();
2073 app.history = vec![HistoryCell::Assistant {
2074 content: "focused markdown **answer**".to_string(),
2075 streaming: false,
2076 }];
2077 app.resync_history_revisions();
2078 app.viewport.last_transcript_area = Some(ratatui::layout::Rect {
2079 x: 0,
2080 y: 0,
2081 width: 80,
2082 height: 24,
2083 });
2084
2085 assert!(open_focused_cell_pager(&mut app));
2086 assert_eq!(
2087 app.view_stack.top_kind(),
2088 Some(crate::tui::views::ModalKind::Pager)
2089 );
2090 app.view_stack.pop();
2091 assert!(copy_focused_cell(&mut app));
2092 assert_eq!(
2093 app.clipboard.last_written_text(),
2094 Some("focused markdown **answer**")
2095 );
2096 }
2097
2098 #[test]
2099 fn turn_inspector_copy_answer_copies_only_the_latest_completed_answer() {
2100 use crate::tui::history::GenericToolCell;
2101 use crate::tui::views::{ModalView, ViewAction, ViewEvent};
2102 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2103
2104 let mut app = test_app();
2105 app.history = vec![
2106 HistoryCell::User {
2107 content: "please summarize".to_string(),
2108 },
2109 HistoryCell::Thinking {
2110 content: "private reasoning trace".to_string(),
2111 streaming: false,
2112 duration_secs: Some(1.0),
2113 },
2114 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2115 name: "read_file".to_string(),
2116 status: ToolStatus::Success,
2117 input_summary: Some("src/lib.rs".to_string()),
2118 output: Some("raw tool result body".to_string()),
2119 prompts: None,
2120 spillover_path: None,
2121 output_summary: None,
2122 is_diff: false,
2123 })),
2124 HistoryCell::System {
2125 content: "runtime status note".to_string(),
2126 },
2127 HistoryCell::Assistant {
2128 content: "still streaming partial".to_string(),
2129 streaming: true,
2130 },
2131 HistoryCell::Assistant {
2132 content: "FINAL ANSWER\nauthored markdown".to_string(),
2133 streaming: false,
2134 },
2135 ];
2136
2137 assert!(open_turn_inspector_pager(&mut app));
2138 let mut view = app.view_stack.pop().expect("turn inspector pager");
2139 let pager = view
2140 .as_any_mut()
2141 .downcast_mut::<PagerView>()
2142 .expect("turn inspector should reuse PagerView");
2143 let copied = match pager.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)) {
2144 ViewAction::Emit(ViewEvent::CopyToClipboard { text, label }) => {
2145 assert_eq!(label, "Answer");
2146 text
2147 }
2148 other => panic!("expected answer copy event, got {other:?}"),
2149 };
2150
2151 assert_eq!(copied, "FINAL ANSWER\nauthored markdown");
2152 for excluded in [
2153 "please summarize",
2154 "private reasoning trace",
2155 "raw tool result body",
2156 "runtime status note",
2157 "still streaming partial",
2158 ] {
2159 assert!(
2160 !copied.contains(excluded),
2161 "answer copy leaked {excluded:?}"
2162 );
2163 }
2164 }
2165 struct PrivateArtifactRoot(Option<PathBuf>);
2166 impl PrivateArtifactRoot {
2167 fn set(path: PathBuf) -> Self {
2168 Self(crate::artifacts::set_test_artifact_sessions_root(Some(
2169 path,
2170 )))
2171 }
2172 }
2173 impl Drop for PrivateArtifactRoot {
2174 fn drop(&mut self) {
2175 crate::artifacts::set_test_artifact_sessions_root(self.0.take());
2176 }
2177 }
2178 fn owned_artifact_fixture() -> crate::artifacts::ArtifactRecord {
2179 crate::artifacts::record_tool_output_artifact(
2180 "session-a",
2181 "call-fixture",
2182 "bash",
2183 PathBuf::from("artifacts/mutable.txt"),
2184 "fixture",
2185 )
2186 }
2187
2188 #[test]
2189 fn owned_artifact_pager_reads_regular_exact_session_output() {
2190 let _guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
2191 .lock()
2192 .unwrap_or_else(|err| err.into_inner());
2193 let temp = tempfile::tempdir().unwrap();
2194 let _root = PrivateArtifactRoot::set(temp.path().join("sessions"));
2195 crate::artifacts::write_session_artifact("session-a", "mutable", "exact pager output")
2196 .unwrap();
2197 assert_eq!(
2198 read_owned_session_artifact(&owned_artifact_fixture()).as_deref(),
2199 Some("exact pager output")
2200 );
2201 let mut malformed = owned_artifact_fixture();
2202 malformed.storage_path = PathBuf::from("other/mutable.txt");
2203 assert!(read_owned_session_artifact(&malformed).is_none());
2204 }
2205
2206 #[cfg(unix)]
2207 #[test]
2208 fn owned_artifact_pager_refuses_linked_session_parent() {
2209 let _guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
2210 .lock()
2211 .unwrap_or_else(|err| err.into_inner());
2212 let temp = tempfile::tempdir().unwrap();
2213 let sessions = temp.path().join("sessions");
2214 let _root = PrivateArtifactRoot::set(sessions.clone());
2215 std::fs::create_dir(&sessions).unwrap();
2216 let outside = temp.path().join("outside");
2217 std::fs::create_dir_all(outside.join("artifacts")).unwrap();
2218 std::fs::write(
2219 outside.join("artifacts/mutable.txt"),
2220 b"outside-pager-synthetic",
2221 )
2222 .unwrap();
2223 std::os::unix::fs::symlink(&outside, sessions.join("session-a")).unwrap();
2224 assert!(
2225 read_owned_session_artifact(&owned_artifact_fixture()).is_none(),
2226 "pager accepted output from a linked outside session"
2227 );
2228 }
2229
2230 #[cfg(unix)]
2231 #[test]
2232 fn owned_artifact_pager_refuses_hardlinked_outside_bytes() {
2233 let _guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
2234 .lock()
2235 .unwrap_or_else(|err| err.into_inner());
2236 let temp = tempfile::tempdir().unwrap();
2237 let sessions = temp.path().join("sessions");
2238 let _root = PrivateArtifactRoot::set(sessions.clone());
2239 std::fs::create_dir_all(sessions.join("session-a/artifacts")).unwrap();
2240 let outside = temp.path().join("outside.txt");
2241 std::fs::write(&outside, b"outside-hardlink-synthetic").unwrap();
2242 std::fs::hard_link(&outside, sessions.join("session-a/artifacts/mutable.txt")).unwrap();
2243 assert!(
2244 read_owned_session_artifact(&owned_artifact_fixture()).is_none(),
2245 "pager exposed outside hardlinked bytes"
2246 );
2247 }
2248
2249 #[test]
2250 fn owned_artifact_pager_refuses_oversized_output_without_deleting_it() {
2251 let _guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
2252 .lock()
2253 .unwrap_or_else(|err| err.into_inner());
2254 let temp = tempfile::tempdir().unwrap();
2255 let sessions = temp.path().join("sessions");
2256 let _root = PrivateArtifactRoot::set(sessions.clone());
2257 let directory = sessions.join("session-a/artifacts");
2258 std::fs::create_dir_all(&directory).unwrap();
2259 let path = directory.join("mutable.txt");
2260 let file = std::fs::File::create(&path).unwrap();
2261 file.set_len(MAX_SESSION_ARTIFACT_DISPLAY_BYTES + 1)
2262 .unwrap();
2263 assert!(
2264 read_owned_session_artifact(&owned_artifact_fixture()).is_none(),
2265 "pager allocated oversized session output"
2266 );
2267 assert_eq!(
2268 std::fs::metadata(path).unwrap().len(),
2269 MAX_SESSION_ARTIFACT_DISPLAY_BYTES + 1
2270 );
2271 }
2272 }
2273
2273 lines RUST