返回 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 fn read_owned_session_artifact(artifact: &crate::artifacts::ArtifactRecord) -> Option<String> {
329 if artifact.storage_path.is_absolute() {
330 return None;
331 }
332 let root = crate::artifacts::session_artifact_absolute_path(
333 &artifact.session_id,
334 std::path::Path::new(crate::artifacts::ARTIFACTS_DIR_NAME),
335 )?;
336 let candidate = crate::artifacts::session_artifact_absolute_path(
337 &artifact.session_id,
338 &artifact.storage_path,
339 )?;
340 let path = canonical_owned_file(&candidate, &root)?;
341 std::fs::read_to_string(path).ok()
342 }
343
344 fn read_owned_legacy_spillover(path: &std::path::Path, session_id: &str) -> Option<String> {
345 let root = crate::tools::truncate::spillover_root()?;
346 let path = canonical_owned_file(path, &root)?;
347 let ownership = crate::tools::truncate::read_legacy_spillover_ownership(&path).ok()?;
348 if ownership.origin_session != session_id {
349 return None;
350 }
351 let bytes = std::fs::read(path).ok()?;
352 if ownership.size_bytes != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
353 || ownership.digest != crate::hashing::sha256_hex(&bytes)
354 {
355 return None;
356 }
357 String::from_utf8(bytes).ok()
358 }
359
360 fn canonical_owned_file(
361 candidate: &std::path::Path,
362 root: &std::path::Path,
363 ) -> Option<std::path::PathBuf> {
364 if std::fs::symlink_metadata(candidate)
365 .ok()?
366 .file_type()
367 .is_symlink()
368 {
369 return None;
370 }
371 let root = root.canonicalize().ok()?;
372 let candidate = candidate.canonicalize().ok()?;
373 (candidate.is_file() && candidate.starts_with(root)).then_some(candidate)
374 }
375
376 pub(crate) fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> bool {
377 if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
378 let input = serde_json::to_string_pretty(&detail.input)
379 .unwrap_or_else(|_| detail.input.to_string());
380 let output = detail.output.as_deref().map_or(
381 "(not available)".to_string(),
382 std::string::ToString::to_string,
383 );
384
385 // #500: when the tool result was spilled to disk, fold the full
386 // file content into the pager body so the user can see what was
387 // elided (the model only ever saw the head). The truncated head
388 // stays above as `Output:` so the user can compare what the
389 // model received against the full payload.
390 let spillover_section = spillover_pager_section(app, cell_index);
391 let mutation_section = match app.cell_at_virtual_index(cell_index) {
392 Some(HistoryCell::Tool(ToolCell::PatchSummary(cell))) => cell
393 .receipt
394 .as_ref()
395 .map(|receipt| format!("── Exact File change ──\n{}", receipt.inspect_text())),
396 _ => None,
397 };
398
399 // Frame the body as leaf-level raw detail for the selected item. The
400 // Tool ID / Input / Output / spillover content below is unchanged — only
401 // the leading intro line is new, so existing raw-output visibility is
402 // preserved (#4105).
403 let trailing_sections = [mutation_section, spillover_section]
404 .into_iter()
405 .flatten()
406 .collect::<Vec<_>>()
407 .join("\n\n");
408 let content = if !trailing_sections.is_empty() {
409 format!(
410 "{RAW_DETAIL_PAGER_INTRO}\n\nTool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}\n\n{}",
411 detail.tool_id, detail.tool_name, input, output, trailing_sections
412 )
413 } else {
414 format!(
415 "{RAW_DETAIL_PAGER_INTRO}\n\nTool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}",
416 detail.tool_id, detail.tool_name, input, output
417 )
418 };
419
420 let width = app
421 .viewport
422 .last_transcript_area
423 .map(|area| area.width)
424 .unwrap_or(80);
425 app.view_stack.push(PagerView::from_text(
426 format!("Raw detail — {}", detail.tool_name),
427 &content,
428 width.saturating_sub(2),
429 ));
430 return true;
431 }
432
433 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
434 app.status_message = Some(NO_RAW_DETAIL_HINT.to_string());
435 return false;
436 };
437 let title = match cell {
438 HistoryCell::User { .. } => "You".to_string(),
439 HistoryCell::Assistant { .. } => "Assistant".to_string(),
440 HistoryCell::System { .. } => "Note".to_string(),
441 HistoryCell::Error { .. } => "Error".to_string(),
442 HistoryCell::Thinking { .. } => "Reasoning".to_string(),
443 HistoryCell::Tool(_) => "Message".to_string(),
444 HistoryCell::SubAgent(_) => "Sub-agent".to_string(),
445 HistoryCell::Automation(_) => tr(app.ui_locale, MessageId::AutomationNoun).into_owned(),
446 HistoryCell::ArchivedContext { .. } => "Archived Context".to_string(),
447 };
448 let width = app
449 .viewport
450 .last_transcript_area
451 .map(|area| area.width)
452 .unwrap_or(80);
453 let content = history_cell_to_text(cell, width);
454 let mut pager = PagerView::from_text(title, &content, width.saturating_sub(2));
455 // A completed assistant cell gets a clean `a` (copy answer) action so
456 // this raw-detail pager can hand over the answer text without the
457 // glyph/label scaffolding that `c`/`y` (rendered body) would include.
458 if let Some(answer) = completed_assistant_answer_text(cell, width) {
459 pager = pager.with_copy_answer(answer);
460 }
461 app.view_stack.push(pager);
462 true
463 }
464
465 /// Open the focused transcript cell as a full-screen readable pager.
466 pub(crate) fn open_focused_cell_pager(app: &mut App) -> bool {
467 let Some(cell_index) = detail_target_cell_index(app) else {
468 return false;
469 };
470 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
471 return false;
472 };
473 let title = match cell {
474 HistoryCell::User { .. } => "You".to_string(),
475 HistoryCell::Assistant { .. } => "Assistant".to_string(),
476 HistoryCell::System { .. } => "Note".to_string(),
477 HistoryCell::Error { .. } => "Error".to_string(),
478 HistoryCell::Thinking { .. } => "Reasoning".to_string(),
479 HistoryCell::Tool(_) => "Tool".to_string(),
480 HistoryCell::SubAgent(_) => "Sub-agent".to_string(),
481 HistoryCell::Automation(_) => tr(app.ui_locale, MessageId::AutomationNoun).into_owned(),
482 HistoryCell::ArchivedContext { .. } => "Archived Context".to_string(),
483 };
484 let width = app
485 .viewport
486 .last_transcript_area
487 .map(|area| area.width)
488 .unwrap_or(80);
489 let content = history_cell_to_text(cell, width);
490 let mut pager = PagerView::from_text(title, &content, width.saturating_sub(2));
491 if let Some(answer) = completed_assistant_answer_text(cell, width) {
492 pager = pager.with_copy_answer(answer);
493 }
494 app.view_stack.push(pager);
495 true
496 }
497
498 /// Copy the "focused" transcript cell to the system clipboard.
499 /// The focused cell is determined by the detail-target heuristic
500 /// (viewport centre or most recent cell). Returns true when text
501 /// was actually copied.
502 pub(super) fn copy_focused_cell(app: &mut App) -> bool {
503 let cell_index = detail_target_cell_index(app);
504 let Some(index) = cell_index else {
505 return false;
506 };
507 copy_cell_to_clipboard(app, index)
508 }
509
510 pub(crate) fn copy_cell_to_clipboard(app: &mut App, cell_index: usize) -> bool {
511 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
512 app.status_message = Some("No message at that line".to_string());
513 return false;
514 };
515 let width = app
516 .viewport
517 .last_transcript_area
518 .map(|area| area.width)
519 .unwrap_or(80);
520 let text = history_cell_to_clipboard_text(cell, width);
521 if text.trim().is_empty() {
522 app.status_message = Some("Message is empty".to_string());
523 return false;
524 }
525 if app.clipboard.write_text(&text).is_ok() {
526 app.status_message = Some("Message copied".to_string());
527 true
528 } else {
529 app.status_message = Some("Copy failed".to_string());
530 false
531 }
532 }
533
534 /// Clean clipboard payload for a completed assistant answer cell.
535 ///
536 /// Selection reuses the typed `HistoryCell::is_completed_assistant_answer`
537 /// projection so reasoning/thinking blocks, tool calls and results, runtime
538 /// status, and still-streaming partials never qualify; serialization reuses
539 /// `history_cell_to_clipboard_text`, the canonical clean-copy path that
540 /// returns the authored assistant Markdown with no glyph/label scaffolding.
541 pub(crate) fn completed_assistant_answer_text(cell: &HistoryCell, width: u16) -> Option<String> {
542 cell.is_completed_assistant_answer()
543 .then(|| history_cell_to_clipboard_text(cell, width))
544 }
545
546 /// Latest completed assistant answer inside the virtual-cell range
547 /// `[start, end)` — the payload behind the Turn Inspector's `a` (copy
548 /// answer) action. Scanning backwards keeps each inspector page scoped to
549 /// its own turn, so the latest page carries the latest completed answer.
550 fn turn_answer_payload(app: &App, start: usize, end: usize, width: u16) -> Option<String> {
551 (start..end).rev().find_map(|idx| {
552 app.cell_at_virtual_index(idx)
553 .and_then(|cell| completed_assistant_answer_text(cell, width))
554 })
555 }
556
557 pub(super) fn detail_target_cell_index(app: &App) -> Option<usize> {
558 if let Some((start, _)) = app.viewport.transcript_selection.ordered_endpoints() {
559 return app
560 .viewport
561 .transcript_cache
562 .line_meta()
563 .get(start.line_index)
564 .and_then(|meta| meta.cell_line())
565 .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index));
566 }
567 app.detail_cell_index_for_viewport(
568 app.viewport.last_transcript_top,
569 app.viewport.last_transcript_visible.max(1),
570 app.viewport.transcript_cache.line_meta(),
571 )
572 .or_else(|| app.virtual_cell_count().checked_sub(1))
573 }
574
575 pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option<String> {
576 if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
577 return Some(detail.tool_name.clone());
578 }
579 let cell = app.cell_at_virtual_index(cell_index)?;
580 match cell {
581 HistoryCell::Tool(ToolCell::Exec(exec)) => {
582 Some(format!("run {}", one_line_summary(&exec.command, 80)))
583 }
584 HistoryCell::Tool(ToolCell::Exploring(explore)) => Some(format!(
585 "workspace {} item{}",
586 explore.entries.len(),
587 if explore.entries.len() == 1 { "" } else { "s" }
588 )),
589 HistoryCell::Tool(ToolCell::PlanUpdate(_)) => Some("legacy plan update".to_string()),
590 HistoryCell::Tool(ToolCell::PatchSummary(patch)) => Some(format!("patch {}", patch.path)),
591 HistoryCell::Tool(ToolCell::Review(review)) => {
592 let target = one_line_summary(&review.target, 80);
593 Some(if target.is_empty() {
594 "review".to_string()
595 } else {
596 format!("review {target}")
597 })
598 }
599 HistoryCell::Tool(ToolCell::Mcp(mcp)) => Some(format!("tool {}", mcp.tool)),
600 HistoryCell::Tool(ToolCell::ViewImage(image)) => {
601 Some(format!("image {}", image.path.display()))
602 }
603 HistoryCell::Tool(ToolCell::WebSearch(search)) => Some(format!("search {}", search.query)),
604 HistoryCell::Tool(ToolCell::Generic(generic)) => Some(
605 crate::tui::widgets::tool_card::tool_activity_label_for_name(
606 &generic.name,
607 app.ui_locale,
608 ),
609 ),
610 HistoryCell::SubAgent(_) => Some("sub-agent".to_string()),
611 HistoryCell::Error { .. } => Some("full error message".to_string()),
612 _ => None,
613 }
614 }
615
616 pub(super) fn extract_reasoning_header(text: &str) -> Option<String> {
617 let start = text.find("**")?;
618 let rest = &text[start + 2..];
619 let end = rest.find("**")?;
620 let header = rest[..end].trim().trim_end_matches(':');
621 if header.is_empty() {
622 None
623 } else {
624 Some(header.to_string())
625 }
626 }
627
628 // ============================================================================
629 // Turn Inspector (issue #4104)
630 //
631 // Ctrl+O opens a *turn-level* overview of the current in-flight turn — or the
632 // latest completed turn when idle — rather than the single-cell Activity
633 // Detail. `v` / `Alt+V` remain the raw leaf-detail command for the selected
634 // item; this surface never dumps a single tool's raw output.
635 //
636 // Each of the nine overview sections renders from whatever turn/cell/app state
637 // is cleanly reachable and DEGRADES the rest gracefully to a short "none"/"—"
638 // line — never a mysterious blank. The thinner sections (diagnostics loop,
639 // tests/verifier) are intentionally heuristic in this first pass; the leaf
640 // issues #4106/#4107/#4108 flesh them out with structured data later.
641 // ============================================================================
642
643 /// Open the whole-turn Turn Inspector pager (Ctrl+O).
644 ///
645 /// Reuses the same `PagerView` text-section machinery as the Activity Detail
646 /// pager — no new modal system. Always succeeds: an empty transcript still
647 /// yields a coherent (degraded) overview rather than a dead keypress.
648 pub(super) fn open_turn_inspector_pager(app: &mut App) -> bool {
649 let width = app
650 .viewport
651 .last_transcript_area
652 .map(|area| area.width)
653 .unwrap_or(80);
654 let ranges = turn_ranges(app);
655 let page_count = ranges.len();
656 let pages = ranges
657 .into_iter()
658 .enumerate()
659 .map(|(page_index, (start, end))| {
660 let latest = page_index + 1 == page_count;
661 let text =
662 turn_inspector_text_for_range(app, start, end, page_index, page_count, latest);
663 let page = PagerPage::from_text("Turn Inspector", &text, width.saturating_sub(2))
664 .with_copy_text(text);
665 // `a` copies only this turn's final assistant answer — the clean
666 // counterpart to `e` (whole-turn handoff markdown).
667 let page = match turn_answer_payload(app, start, end, width) {
668 Some(answer) => page.with_copy_answer(answer),
669 None => page,
670 };
671 if latest {
672 // The existing handoff remains attached only to the
673 // current/latest turn it actually describes.
674 page.with_export_markdown(turn_handoff_markdown(app))
675 } else {
676 page
677 }
678 })
679 .collect();
680 app.view_stack
681 .push(PagerView::from_pages(pages, page_count.saturating_sub(1)));
682 true
683 }
684
685 /// Chronological virtual-cell ranges for every recorded turn. A transcript
686 /// without a user prompt still gets one coherent page, matching the previous
687 /// Turn Inspector empty/degraded behavior.
688 fn turn_ranges(app: &App) -> Vec<(usize, usize)> {
689 let end = app.virtual_cell_count();
690 let starts: Vec<usize> = (0..end)
691 .filter(|&idx| {
692 matches!(
693 app.cell_at_virtual_index(idx),
694 Some(HistoryCell::User { .. })
695 )
696 })
697 .collect();
698 if starts.is_empty() {
699 return vec![(0, end)];
700 }
701 starts
702 .iter()
703 .copied()
704 .enumerate()
705 .map(|(idx, start)| (start, starts.get(idx + 1).copied().unwrap_or(end)))
706 .collect()
707 }
708
709 /// Virtual-cell range `[start, end)` of the turn under inspection.
710 ///
711 /// The turn is the run of cells from the last user prompt through the end of
712 /// the transcript. Because `virtual_cell_count()` includes still-in-flight
713 /// `active_cell` entries, this scopes to the current in-flight turn during a
714 /// turn, and to the latest completed turn once the active cell has flushed to
715 /// history. When no user prompt exists yet the whole transcript is used.
716 fn current_turn_range(app: &App) -> (usize, usize) {
717 let end = app.virtual_cell_count();
718 let start = (0..end)
719 .rev()
720 .find(|&idx| {
721 matches!(
722 app.cell_at_virtual_index(idx),
723 Some(HistoryCell::User { .. })
724 )
725 })
726 .unwrap_or(0);
727 (start, end)
728 }
729
730 /// Human form of the runtime turn status — raw enum-ish values like
731 /// "in_progress" must never reach the inspector (dogfood A6, #4102).
732 fn humanized_turn_status(app: &App) -> &str {
733 match app.runtime_turn_status.as_deref() {
734 Some("in_progress") | None => "in progress",
735 Some(other) => other,
736 }
737 }
738
739 /// Short display form of a runtime turn id. The full UUID reads as internal
740 /// state in the inspector header (dogfood A6); twelve characters is plenty
741 /// to correlate with logs.
742 fn short_turn_id(turn_id: &str) -> &str {
743 turn_id.get(..12).unwrap_or(turn_id)
744 }
745
746 /// Assemble the Turn Inspector overview text from all available turn data.
747 #[cfg(test)]
748 pub(super) fn turn_inspector_text(app: &App) -> String {
749 let (start, end) = current_turn_range(app);
750 turn_inspector_text_for_range(app, start, end, 0, 1, true)
751 }
752
753 fn turn_inspector_text_for_range(
754 app: &App,
755 start: usize,
756 end: usize,
757 page_index: usize,
758 page_count: usize,
759 latest: bool,
760 ) -> String {
761 let mut out: Vec<String> = Vec::new();
762
763 // Turn identity header. Lead with the human turn number and status; the
764 // id is a short correlation suffix, never a raw UUID dump (dogfood A6).
765 let status = if latest {
766 std::borrow::Cow::Borrowed(humanized_turn_status(app))
767 } else {
768 tr(app.ui_locale, MessageId::AutomationRunStatusCompleted)
769 };
770 if !latest {
771 let historical_offset = page_count.saturating_sub(page_index + 1) as u64;
772 let number = app
773 .turn_counter
774 .checked_sub(historical_offset)
775 .filter(|number| *number > 0)
776 .unwrap_or(page_index as u64 + 1);
777 out.push(format!("Turn #{number} \u{00B7} {status}"));
778 } else if app.turn_counter > 0 {
779 let mut line = format!("Turn #{} \u{00B7} {status}", app.turn_counter);
780 if let Some(turn_id) = app.runtime_turn_id.as_ref() {
781 line.push_str(&format!(" \u{00B7} id {}", short_turn_id(turn_id)));
782 }
783 out.push(line);
784 } else if let Some(turn_id) = app.runtime_turn_id.as_ref() {
785 out.push(format!("Turn {} \u{00B7} {status}", short_turn_id(turn_id)));
786 } else {
787 out.push("Turn: \u{2014} (no turn recorded yet)".to_string());
788 }
789 // Restate the Ctrl+O (overview) vs. Alt+V/⌥V (raw leaf detail) contract so
790 // the two surfaces never get confused. Bare `v` is never a details shortcut.
791 let details = crate::tui::shell_key_routing::display_chord(
792 crate::tui::shell_key_routing::binding(
793 crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
794 )
795 .footer_chord,
796 );
797 if latest {
798 out.push(format!(
799 "Overview of the current/latest turn · press {details} for the selected item's raw detail"
800 ));
801 }
802
803 push_section(&mut out, "Intent", vec![turn_intent_line(app, start)]);
804
805 if latest && let Some(line) = selected_item_context_line(app) {
806 push_section(&mut out, "Selected item", vec![line]);
807 }
808
809 push_section(
810 &mut out,
811 "To-do",
812 if latest {
813 turn_todo_lines(app)
814 } else {
815 Vec::new()
816 },
817 );
818 let mut timeline = turn_full_conversation_lines(app, start, end);
819 if !timeline.is_empty() {
820 timeline.push(String::new());
821 }
822 timeline.extend(turn_timeline_lines(app, start, end));
823 push_section(&mut out, "Turn timeline", timeline);
824 push_section(
825 &mut out,
826 "Files changed",
827 turn_files_changed(app, start, end),
828 );
829 push_section(
830 &mut out,
831 "Diagnostics loop",
832 if latest {
833 turn_diagnostics_lines(app)
834 } else {
835 Vec::new()
836 },
837 );
838 push_section(
839 &mut out,
840 "Tests / verifier",
841 turn_verifier_lines(app, start, end),
842 );
843 push_section(
844 &mut out,
845 "Approvals / denials",
846 if latest {
847 turn_approvals_lines(app)
848 } else {
849 Vec::new()
850 },
851 );
852 push_section(
853 &mut out,
854 "Model route + tokens/cost",
855 if latest {
856 turn_route_lines(app)
857 } else {
858 Vec::new()
859 },
860 );
861 push_section(
862 &mut out,
863 "Final result / status",
864 turn_result_lines(app, start, end, ResultDetail::Full),
865 );
866
867 out.join("\n")
868 }
869
870 /// Source-faithful, turn-scoped transcript for the inspector page. The normal
871 /// transcript can stay compact/folded; this explicit detail surface preserves
872 /// complete recorded input, reasoning, tool results, and assistant output.
873 fn turn_full_conversation_lines(app: &App, start: usize, end: usize) -> Vec<String> {
874 let thinking_total = (start..end)
875 .filter(|&idx| {
876 matches!(
877 app.cell_at_virtual_index(idx),
878 Some(HistoryCell::Thinking { .. })
879 )
880 })
881 .count();
882 let mut thinking_position = 0usize;
883 let mut out = Vec::new();
884
885 for idx in start..end {
886 let Some(cell) = app.cell_at_virtual_index(idx) else {
887 continue;
888 };
889 let tag = match cell {
890 HistoryCell::User { .. } => "[›]".to_string(),
891 HistoryCell::Thinking { streaming, .. } => {
892 thinking_position += 1;
893 format!(
894 "[∿ {} {thinking_position}/{thinking_total} · {}]",
895 tr(app.ui_locale, MessageId::PhaseReasoning),
896 tr(
897 app.ui_locale,
898 if *streaming {
899 MessageId::AutomationRunStatusRunning
900 } else {
901 MessageId::PhaseDone
902 }
903 )
904 )
905 }
906 HistoryCell::Tool(_) => format!("[⚙ {}]", tr(app.ui_locale, MessageId::PhaseUsingTool)),
907 HistoryCell::SubAgent(_) => "[↗]".to_string(),
908 HistoryCell::Assistant { streaming, .. } => format!(
909 "[◆ · {}]",
910 tr(
911 app.ui_locale,
912 if *streaming {
913 MessageId::AutomationRunStatusRunning
914 } else {
915 MessageId::PhaseDone
916 }
917 )
918 ),
919 HistoryCell::Error { .. } => "[!]".to_string(),
920 HistoryCell::Automation(_) => "[⏱]".to_string(),
921 HistoryCell::System { .. } | HistoryCell::ArchivedContext { .. } => "[i]".to_string(),
922 };
923 if !out.is_empty() {
924 out.push(String::new());
925 }
926 out.push(tag);
927 let body = history_cell_to_clipboard_text(cell, 120);
928 if body.trim().is_empty() {
929 out.push("—".to_string());
930 } else {
931 out.push(body);
932 }
933 }
934
935 out
936 }
937
938 /// Build a compact, pasteable Markdown handoff of the current/latest turn
939 /// (issue #4108).
940 ///
941 /// Reuses the exact same turn scope (`current_turn_range`) and the same
942 /// per-section data helpers as the Turn Inspector (#4104), so the handoff can
943 /// never drift from what Ctrl+O shows — it only re-renders that data as
944 /// Markdown headings + bullets instead of the inspector's box-drawn rules.
945 /// Unavailable sections degrade to a short `—` (and the optional Plan section
946 /// is dropped entirely when empty) so the artifact stays paste-ready without
947 /// leaving a heading over a blank void — the same graceful-degrade contract the
948 /// inspector already follows.
949 pub(crate) fn turn_handoff_markdown(app: &App) -> String {
950 let (start, end) = current_turn_range(app);
951 let mut out: Vec<String> = Vec::new();
952
953 // Title + identity — turn id when known, else the turn counter, else a
954 // bare heading so an empty transcript still yields a coherent artifact.
955 let heading = if app.turn_counter > 0 {
956 format!("# Turn handoff — Turn #{}", app.turn_counter)
957 } else if let Some(turn_id) = app.runtime_turn_id.as_ref() {
958 format!("# Turn handoff — {}", short_turn_id(turn_id))
959 } else {
960 "# Turn handoff".to_string()
961 };
962 out.push(heading);
963
964 let status = match app.runtime_turn_status.as_deref() {
965 Some("in_progress") => "in progress",
966 Some(other) => other,
967 None => "idle",
968 };
969 out.push(format!(
970 "_Status: {status} · generated {}_",
971 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
972 ));
973
974 push_md_section(&mut out, "Intent", vec![turn_intent_line(app, start)]);
975
976 // To-do is optional context: include it only when the canonical list has
977 // items, keeping the handoff compact without recreating a second plan.
978 let todos = turn_todo_lines(app);
979 if !todos.is_empty() {
980 push_md_section(&mut out, "To-do", md_bullets(todos));
981 }
982
983 push_md_section(
984 &mut out,
985 "Files changed",
986 md_bullets(turn_files_changed(app, start, end)),
987 );
988 push_md_section(
989 &mut out,
990 "Turn timeline",
991 md_bullets(turn_timeline_lines(app, start, end)),
992 );
993 push_md_section(
994 &mut out,
995 "Tests / verifier",
996 md_bullets(turn_verifier_lines(app, start, end)),
997 );
998 push_md_section(
999 &mut out,
1000 "Model route + tokens/cost",
1001 md_bullets(turn_route_lines(app)),
1002 );
1003 push_md_section(
1004 &mut out,
1005 "Result / status",
1006 md_bullets(turn_result_lines(app, start, end, ResultDetail::Compact)),
1007 );
1008
1009 // Trailing newline keeps the artifact clean when pasted into a PR body.
1010 out.push(String::new());
1011 out.join("\n")
1012 }
1013
1014 /// Append a `## Title` Markdown section. An empty body degrades to a single
1015 /// `—` line so a heading is never followed by a void — the Markdown analogue
1016 /// of [`push_section`]'s `none` degrade.
1017 fn push_md_section(out: &mut Vec<String>, title: &str, body: Vec<String>) {
1018 out.push(String::new());
1019 out.push(format!("## {title}"));
1020 if body.is_empty() {
1021 out.push("—".to_string());
1022 } else {
1023 out.extend(body);
1024 }
1025 }
1026
1027 /// Convert Turn Inspector section lines into Markdown bullet rows. Inspector
1028 /// list helpers prefix rows with `• `; swap that for `- `, and bullet the
1029 /// key/value rows (route, tokens, status) too so the whole section is valid
1030 /// Markdown.
1031 fn md_bullets(lines: Vec<String>) -> Vec<String> {
1032 lines
1033 .into_iter()
1034 .map(|line| {
1035 let body = line.strip_prefix("• ").unwrap_or(line.as_str());
1036 format!("- {body}")
1037 })
1038 .collect()
1039 }
1040
1041 /// Append a `── Title ──` section. An empty body degrades to a single
1042 /// `none` line so the section header is never followed by a blank void.
1043 fn push_section(out: &mut Vec<String>, title: &str, body: Vec<String>) {
1044 out.push(String::new());
1045 out.push(format!("── {title} ──"));
1046 if body.is_empty() {
1047 out.push("none".to_string());
1048 } else {
1049 out.extend(body);
1050 }
1051 }
1052
1053 /// Section 1 — intent / user-prompt summary for the turn.
1054 fn turn_intent_line(app: &App, start: usize) -> String {
1055 if let Some(HistoryCell::User { content }) = app.cell_at_virtual_index(start) {
1056 let summary = one_line_summary(content, 240);
1057 if !summary.is_empty() {
1058 return summary;
1059 }
1060 }
1061 if let Some(prompt) = app.last_submitted_prompt.as_deref() {
1062 let summary = one_line_summary(prompt, 240);
1063 if !summary.is_empty() {
1064 return summary;
1065 }
1066 }
1067 "—".to_string()
1068 }
1069
1070 /// Optional selected-item context. The first view is the turn overview, but
1071 /// when the user has an activity cell selected we surface it plus the Alt+V
1072 /// affordance so the Ctrl+O / Alt+V split stays discoverable.
1073 fn selected_item_context_line(app: &App) -> Option<String> {
1074 let idx = selected_transcript_cell_index(app)?;
1075 let cell = app.cell_at_virtual_index(idx)?;
1076 let label = truncate_line_to_width(&activity_cell_label(app, idx, cell), 48);
1077 let hint = if app.cell_has_detail_target(idx) {
1078 let details = crate::tui::shell_key_routing::display_chord(
1079 crate::tui::shell_key_routing::binding(
1080 crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
1081 )
1082 .footer_chord,
1083 );
1084 if matches!(cell, HistoryCell::Error { .. }) {
1085 format!(" · {details} opens the full error")
1086 } else {
1087 format!(" · {details} opens its raw detail")
1088 }
1089 } else {
1090 String::new()
1091 };
1092 Some(format!("{label}{hint}"))
1093 }
1094
1095 /// Section 2 — canonical To-do state.
1096 fn turn_todo_lines(app: &App) -> Vec<String> {
1097 let mut lines = Vec::new();
1098
1099 if let Ok(todos) = app.todos.try_lock() {
1100 let snapshot = todos.snapshot();
1101 if !snapshot.items.is_empty() {
1102 lines.push(format!("To-do: {}% settled", snapshot.completion_pct));
1103 for item in &snapshot.items {
1104 lines.push(format!(
1105 "{} {}",
1106 todo_status_glyph(&item.status),
1107 truncate_line_to_width(&item.content, 72)
1108 ));
1109 }
1110 }
1111 }
1112
1113 lines
1114 }
1115
1116 fn todo_status_glyph(status: &crate::tools::todo::TodoStatus) -> &'static str {
1117 match status {
1118 crate::tools::todo::TodoStatus::Completed => "[x]",
1119 crate::tools::todo::TodoStatus::InProgress => "[~]",
1120 crate::tools::todo::TodoStatus::Pending => "[ ]",
1121 crate::tools::todo::TodoStatus::Cancelled => "[-]",
1122 }
1123 }
1124
1125 /// Section 3 — chronological turn timeline with compact action affordances.
1126 fn turn_timeline_lines(app: &App, start: usize, end: usize) -> Vec<String> {
1127 let mut rows = Vec::new();
1128 for idx in start..end {
1129 let Some(cell) = app.cell_at_virtual_index(idx) else {
1130 continue;
1131 };
1132 match cell {
1133 HistoryCell::User { content } => {
1134 let summary = one_line_summary(content, 96);
1135 rows.push(timeline_row("user prompt", &summary, None, None, &[]));
1136 }
1137 HistoryCell::Thinking {
1138 content,
1139 streaming,
1140 duration_secs,
1141 } => {
1142 let summary = one_line_summary(content, 88);
1143 let status = streaming.then_some("running").unwrap_or("done");
1144 let duration = duration_secs
1145 .map(|secs| crate::elapsed::format_elapsed_ms((secs * 1000.0) as u64));
1146 let actions = timeline_cell_actions(app, idx, cell);
1147 rows.push(timeline_row(
1148 "reasoning",
1149 &summary,
1150 Some(status),
1151 duration.as_deref(),
1152 &actions,
1153 ));
1154 }
1155 HistoryCell::Tool(tool) => {
1156 let (kind, summary) = timeline_tool_summary(app, idx, tool);
1157 let duration =
1158 tool_duration_for_activity(tool).map(crate::elapsed::format_elapsed_ms);
1159 let status = tool.status().map(activity_status_label);
1160 let actions = timeline_cell_actions(app, idx, cell);
1161 rows.push(timeline_row(
1162 kind,
1163 &summary,
1164 status,
1165 duration.as_deref(),
1166 &actions,
1167 ));
1168 }
1169 HistoryCell::SubAgent(_) => {
1170 let summary = detail_target_label(app, idx).unwrap_or_else(|| "sub-agent".into());
1171 let actions = timeline_cell_actions(app, idx, cell);
1172 rows.push(timeline_row("sub-agent", &summary, None, None, &actions));
1173 }
1174 HistoryCell::Assistant { content, streaming } => {
1175 let summary = one_line_summary(content, 96);
1176 let status = streaming.then_some("streaming").unwrap_or("done");
1177 rows.push(timeline_row(
1178 "assistant result",
1179 &summary,
1180 Some(status),
1181 None,
1182 &[],
1183 ));
1184 }
1185 HistoryCell::Error { message, severity } => {
1186 let summary = one_line_summary(message, 96);
1187 let status = severity.to_string();
1188 rows.push(timeline_row("error", &summary, Some(&status), None, &[]));
1189 }
1190 HistoryCell::Automation(cell) => {
1191 let summary = one_line_summary(&cell.plain_summary(), 96);
1192 rows.push(timeline_row("automation", &summary, None, None, &[]));
1193 }
1194 HistoryCell::System { content }
1195 | HistoryCell::ArchivedContext {
1196 summary: content, ..
1197 } => {
1198 let summary = one_line_summary(content, 96);
1199 rows.push(timeline_row("system note", &summary, None, None, &[]));
1200 }
1201 }
1202 }
1203 rows.push(turn_checkpoint_timeline_row(app));
1204 rows.into_iter()
1205 .enumerate()
1206 .map(|(idx, row)| format!("{}. {row}", idx + 1))
1207 .collect()
1208 }
1209
1210 fn timeline_tool_summary(app: &App, idx: usize, tool: &ToolCell) -> (&'static str, String) {
1211 match tool {
1212 ToolCell::Exec(exec) if command_looks_like_verifier(&exec.command) => {
1213 ("test/verifier", truncate_line_to_width(&exec.command, 88))
1214 }
1215 ToolCell::Exec(exec) => ("shell command", truncate_line_to_width(&exec.command, 88)),
1216 ToolCell::Exploring(explore) => (
1217 "read/search",
1218 format!(
1219 "{} item{}",
1220 explore.entries.len(),
1221 if explore.entries.len() == 1 { "" } else { "s" }
1222 ),
1223 ),
1224 ToolCell::PlanUpdate(_) => ("legacy plan", "Legacy plan metadata replayed".to_string()),
1225 ToolCell::PatchSummary(patch) => {
1226 let summary = one_line_summary(&patch.summary, 72);
1227 if summary.is_empty() {
1228 ("edit", truncate_line_to_width(&patch.path, 88))
1229 } else {
1230 (
1231 "edit",
1232 truncate_line_to_width(&format!("{} — {summary}", patch.path), 88),
1233 )
1234 }
1235 }
1236 ToolCell::Review(review) => {
1237 let target = one_line_summary(&review.target, 88);
1238 (
1239 "review",
1240 if target.is_empty() {
1241 "code review".to_string()
1242 } else {
1243 target
1244 },
1245 )
1246 }
1247 ToolCell::Mcp(mcp) => ("MCP tool", truncate_line_to_width(&mcp.tool, 88)),
1248 ToolCell::ViewImage(image) => (
1249 "image",
1250 truncate_line_to_width(&image.path.display().to_string(), 88),
1251 ),
1252 ToolCell::WebSearch(search) => ("web search", truncate_line_to_width(&search.query, 88)),
1253 ToolCell::Generic(generic) => {
1254 let mut label =
1255 detail_target_label(app, idx).unwrap_or_else(|| generic.name.replace('_', " "));
1256 if let Some(input) = generic.input_summary.as_deref().map(str::trim)
1257 && !input.is_empty()
1258 {
1259 label.push_str(" · ");
1260 label.push_str(input);
1261 }
1262 (
1263 generic_tool_timeline_kind(generic),
1264 truncate_line_to_width(&label, 88),
1265 )
1266 }
1267 }
1268 }
1269
1270 fn generic_tool_timeline_kind(generic: &crate::tui::history::GenericToolCell) -> &'static str {
1271 let name = generic.name.as_str();
1272 if generic.is_diff || name.contains("diff") {
1273 "diff"
1274 } else if matches!(name, "read_file" | "list_files" | "glob" | "grep_files")
1275 || name.contains("read")
1276 || name.contains("search")
1277 || name.contains("grep")
1278 {
1279 "read/search"
1280 } else if matches!(name, "apply_patch" | "edit_file" | "write_file")
1281 || name.contains("patch")
1282 || name.contains("edit")
1283 || name.contains("write")
1284 {
1285 "edit"
1286 } else if name.contains("approval") {
1287 "approval"
1288 } else if name.contains("diagnostic") || name.contains("lsp") {
1289 "diagnostics"
1290 } else {
1291 "tool"
1292 }
1293 }
1294
1295 fn timeline_cell_actions(app: &App, idx: usize, cell: &HistoryCell) -> Vec<String> {
1296 let mut actions = Vec::new();
1297 if app.cell_has_detail_target(idx) {
1298 let details = crate::tui::shell_key_routing::display_chord(
1299 crate::tui::shell_key_routing::binding(
1300 crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
1301 )
1302 .footer_chord,
1303 );
1304 // Diff-bearing cells open their diff through the same details chord;
1305 // bare `v` / `d` always type text (TUI-DOG-002), so no bare-key claim.
1306 let is_diff = matches!(cell, HistoryCell::Tool(ToolCell::PatchSummary(_)))
1307 || matches!(
1308 cell,
1309 HistoryCell::Tool(ToolCell::Generic(generic)) if generic.is_diff
1310 );
1311 if is_diff {
1312 actions.push(format!("{details} diff"));
1313 } else if matches!(cell, HistoryCell::Error { .. }) {
1314 actions.push(format!("{details} full error"));
1315 } else {
1316 actions.push(format!("{details} raw detail"));
1317 }
1318 }
1319 actions
1320 }
1321
1322 fn timeline_row(
1323 kind: &str,
1324 summary: &str,
1325 status: Option<&str>,
1326 duration: Option<&str>,
1327 actions: &[String],
1328 ) -> String {
1329 let mut line = if summary.trim().is_empty() {
1330 kind.to_string()
1331 } else {
1332 format!("{kind}: {}", summary.trim())
1333 };
1334 if let Some(status) = status.filter(|s| !s.trim().is_empty()) {
1335 line.push_str(" — ");
1336 line.push_str(status);
1337 }
1338 if let Some(duration) = duration.filter(|s| !s.trim().is_empty()) {
1339 line.push_str(" · ");
1340 line.push_str(duration);
1341 }
1342 if !actions.is_empty() {
1343 line.push_str(" · actions: ");
1344 line.push_str(&actions.join(", "));
1345 }
1346 line
1347 }
1348
1349 fn turn_checkpoint_timeline_row(app: &App) -> String {
1350 if app.turn_counter == 0 {
1351 return "checkpoint: unavailable — no numbered turn snapshot yet · action: e export handoff"
1352 .to_string();
1353 }
1354
1355 let repo = match SnapshotRepo::open_existing(&app.workspace) {
1356 Ok(Some(repo)) => repo,
1357 Ok(None) => {
1358 return "checkpoint: unavailable — no snapshot repo found · action: e export handoff"
1359 .to_string();
1360 }
1361 Err(err) => {
1362 return format!(
1363 "checkpoint: unknown — snapshot repo could not be opened ({}) · action: e export handoff",
1364 truncate_line_to_width(&err.to_string(), 72)
1365 );
1366 }
1367 };
1368 let snapshots = match repo.list(20) {
1369 Ok(snapshots) => snapshots,
1370 Err(err) => {
1371 return format!(
1372 "checkpoint: unknown — snapshot list failed ({}) · action: e export handoff",
1373 truncate_line_to_width(&err.to_string(), 72)
1374 );
1375 }
1376 };
1377 let prefix = format!("pre-turn:{}", app.turn_counter);
1378 let matching = snapshots
1379 .iter()
1380 .find(|snapshot| {
1381 snapshot.label == prefix || snapshot.label.starts_with(&format!("{prefix}:"))
1382 })
1383 .or_else(|| {
1384 snapshots
1385 .iter()
1386 .find(|snapshot| snapshot.label.starts_with("pre-turn:"))
1387 });
1388 if let Some(snapshot) = matching {
1389 let short = &snapshot.id.as_str()[..snapshot.id.as_str().len().min(8)];
1390 format!(
1391 "checkpoint: {} ({short}) available · actions: r restore via /restore (guarded), e export handoff",
1392 truncate_line_to_width(&snapshot.label, 72)
1393 )
1394 } else {
1395 "checkpoint: unavailable — no pre-turn snapshot found · action: e export handoff"
1396 .to_string()
1397 }
1398 }
1399
1400 /// Section 4 — files touched by patch/diff tool cells in the turn.
1401 fn turn_files_changed(app: &App, start: usize, end: usize) -> Vec<String> {
1402 let mut lines = Vec::new();
1403 let mut seen = std::collections::HashSet::new();
1404 for idx in start..end {
1405 let Some(HistoryCell::Tool(tool)) = app.cell_at_virtual_index(idx) else {
1406 continue;
1407 };
1408 match tool {
1409 ToolCell::PatchSummary(patch) if seen.insert(patch.path.clone()) => {
1410 lines.push(format!(
1411 "• {} — {}",
1412 truncate_line_to_width(&patch.path, 60),
1413 activity_status_label(patch.status)
1414 ));
1415 }
1416 _ => {}
1417 }
1418 }
1419 lines
1420 }
1421
1422 /// Section 5 — diagnostics / LSP repair loop (#4107).
1423 ///
1424 /// Shows the observable repair loop when LSP produced diagnostics this turn.
1425 /// Stays quiet when LSP is disabled or no diagnostics were found.
1426 fn turn_diagnostics_lines(app: &App) -> Vec<String> {
1427 if !app.lsp_enabled {
1428 return Vec::new();
1429 }
1430 let repair = &app.lsp_repair;
1431 if repair.diagnostics_found == 0 && !repair.injected && !repair.repair_attempted {
1432 return Vec::new();
1433 }
1434 let mut lines = Vec::new();
1435 if repair.diagnostics_found > 0 {
1436 lines.push(format!(
1437 "Found {} diagnostic{} across {} file{}",
1438 repair.diagnostics_found,
1439 if repair.diagnostics_found == 1 {
1440 ""
1441 } else {
1442 "s"
1443 },
1444 repair.files_touched.max(1),
1445 if repair.files_touched == 1 { "" } else { "s" },
1446 ));
1447 }
1448 lines.push(if repair.injected {
1449 "Injected into the next model request".to_string()
1450 } else {
1451 "Queued — not yet injected".to_string()
1452 });
1453 if repair.repair_attempted {
1454 lines.push("Model attempted a repair after injection".to_string());
1455 }
1456 let latest = match repair.latest {
1457 "resolved" => "Latest: resolved",
1458 "still_failing" => "Latest: still failing",
1459 "unavailable" => "Latest: unavailable",
1460 _ => "Latest: unknown",
1461 };
1462 lines.push(latest.to_string());
1463 lines
1464 }
1465
1466 /// Section 6 — tests / verifier results.
1467 ///
1468 /// Heuristic first pass (issue #4107): scans the turn's exec/review tool cells
1469 /// for verifier-shaped commands and reports their status. Degrades to `none`
1470 /// when nothing test-shaped ran.
1471 fn turn_verifier_lines(app: &App, start: usize, end: usize) -> Vec<String> {
1472 let mut lines = Vec::new();
1473 for idx in start..end {
1474 let Some(HistoryCell::Tool(tool)) = app.cell_at_virtual_index(idx) else {
1475 continue;
1476 };
1477 match tool {
1478 ToolCell::Exec(exec) if command_looks_like_verifier(&exec.command) => {
1479 lines.push(format!(
1480 "• {} — {}",
1481 truncate_line_to_width(&exec.command, 56),
1482 activity_status_label(exec.status)
1483 ));
1484 }
1485 ToolCell::Review(review) => {
1486 let target = truncate_line_to_width(review.target.trim(), 48);
1487 let target = if target.is_empty() {
1488 "review".to_string()
1489 } else {
1490 format!("review {target}")
1491 };
1492 lines.push(format!(
1493 "• {target} — {}",
1494 activity_status_label(review.status)
1495 ));
1496 }
1497 _ => {}
1498 }
1499 }
1500 lines
1501 }
1502
1503 fn command_looks_like_verifier(command: &str) -> bool {
1504 let lower = command.to_lowercase();
1505 [
1506 "test",
1507 "pytest",
1508 "jest",
1509 "cargo check",
1510 "cargo clippy",
1511 "verif",
1512 "lint",
1513 ]
1514 .iter()
1515 .any(|needle| lower.contains(needle))
1516 }
1517
1518 /// Section 7 — approvals / denials.
1519 ///
1520 /// The approval allow/deny sets are session-scoped (not per-turn), so the
1521 /// counts are labelled `(session)` to avoid implying turn precision.
1522 fn turn_approvals_lines(app: &App) -> Vec<String> {
1523 let mut lines = Vec::new();
1524 let approved = app.approval_session_approved.len();
1525 let denied = app.approval_session_denied.len();
1526 if approved > 0 {
1527 lines.push(format!("Approved (session): {approved}"));
1528 }
1529 if denied > 0 {
1530 lines.push(format!("Denied (session): {denied}"));
1531 }
1532 lines
1533 }
1534
1535 /// Section 8 — model route plus token/cost accounting.
1536 fn turn_route_lines(app: &App) -> Vec<String> {
1537 let mut lines = Vec::new();
1538
1539 let (provider, model) = if let Some(route) = app
1540 .active_turn
1541 .as_ref()
1542 .and_then(|turn| turn.route.as_ref())
1543 {
1544 let provider = if route.provider == crate::config::ApiProvider::Custom {
1545 route.provider_identity.clone()
1546 } else {
1547 route.provider.display_name().to_string()
1548 };
1549 (provider, route.model.clone())
1550 } else {
1551 // Pending and last Auto routes use the same billing-authoritative
1552 // display contract as the header; do not fall back to `auto` after the
1553 // concrete turn route has resolved.
1554 app.effective_route_identity_display()
1555 };
1556 lines.push(format!("Route: {provider} · {model}"));
1557
1558 let auto_receipt = app
1559 .active_turn
1560 .as_ref()
1561 .filter(|turn| turn.route.as_ref().is_some_and(|route| route.auto_model))
1562 .and_then(|turn| turn.auto_route_receipt.as_ref())
1563 .or_else(|| {
1564 app.pending_turn_route
1565 .as_ref()
1566 .filter(|(_, _, auto_model)| *auto_model)
1567 .and(app.pending_auto_route_receipt.as_ref())
1568 })
1569 .or_else(|| {
1570 app.auto_model
1571 .then_some(app.last_auto_route_receipt.as_ref())
1572 .flatten()
1573 });
1574 if let Some(receipt) = auto_receipt {
1575 lines.push(format!(
1576 "Auto decision: {} · {}",
1577 receipt.tier.label(),
1578 receipt.reason.label()
1579 ));
1580 let pair = receipt.pair.fast.as_deref().map_or_else(
1581 || format!("{} (no runnable fast sibling)", receipt.pair.strong),
1582 |fast| format!("{} strong · {fast} fast", receipt.pair.strong),
1583 );
1584 lines.push(format!("Auto pair: {pair}"));
1585 lines.push(format!("Auto scope: {}", receipt.scope.label()));
1586 lines.push(format!("Auto data: {}", receipt.data_path.label()));
1587 }
1588
1589 let session = &app.session;
1590 match (session.last_prompt_tokens, session.last_completion_tokens) {
1591 (Some(prompt), Some(completion)) => {
1592 lines.push(format!(
1593 "Tokens (last turn): {prompt} in · {completion} out"
1594 ));
1595 }
1596 (Some(prompt), None) => lines.push(format!("Tokens (last turn): {prompt} in")),
1597 (None, Some(completion)) => lines.push(format!("Tokens (last turn): {completion} out")),
1598 (None, None) => {
1599 if session.total_tokens > 0 {
1600 lines.push(format!("Tokens (session): {}", session.total_tokens));
1601 }
1602 }
1603 }
1604
1605 let chip = app.cumulative_usage_chip();
1606 match &chip {
1607 crate::route_billing::UsageChip::Money(amount) => {
1608 lines.push(format!("Cost (session): {amount}"));
1609 }
1610 crate::route_billing::UsageChip::PricedSubtotal { .. } => {
1611 lines.push(format!(
1612 "Cost (session): {}",
1613 crate::route_billing::format_usage_chip(&chip, app.ui_locale).unwrap_or_default()
1614 ));
1615 }
1616 crate::route_billing::UsageChip::Allowance { label, used_pct } => {
1617 lines.push(match used_pct {
1618 Some(pct) => format!("Usage plan: {label} ({pct:.0}% used)"),
1619 None => format!("Usage plan: {label}"),
1620 });
1621 }
1622 crate::route_billing::UsageChip::Local => {
1623 lines.push("Cost: local".to_string());
1624 }
1625 crate::route_billing::UsageChip::Unknown(_) => {
1626 lines.push(
1627 crate::route_billing::format_usage_chip(&chip, app.ui_locale).unwrap_or_default(),
1628 );
1629 }
1630 crate::route_billing::UsageChip::Hidden => {}
1631 }
1632
1633 lines
1634 }
1635
1636 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1637 enum ResultDetail {
1638 /// Pager content is the review surface and must retain the complete final
1639 /// response. Width wrapping belongs to `PagerView`, not data assembly.
1640 Full,
1641 /// The exported handoff is intentionally a compact overview.
1642 Compact,
1643 }
1644
1645 fn cleaned_turn_text(text: &str, detail: ResultDetail, max_width: usize) -> String {
1646 if detail == ResultDetail::Compact {
1647 return one_line_summary(text, max_width);
1648 }
1649
1650 let mut cleaned = String::with_capacity(text.len());
1651 crate::tui::osc8::strip_ansi_into(text, &mut cleaned);
1652 cleaned.trim().to_string()
1653 }
1654
1655 /// Section 9 — final result / current status.
1656 fn turn_result_lines(app: &App, start: usize, end: usize, detail: ResultDetail) -> Vec<String> {
1657 let mut lines = Vec::new();
1658
1659 let status = match app.runtime_turn_status.as_deref() {
1660 Some("in_progress") => "in progress",
1661 Some(other) => other,
1662 None => "idle",
1663 };
1664 lines.push(format!("Status: {status}"));
1665
1666 let final_text = (start..end)
1667 .rev()
1668 .find_map(|idx| match app.cell_at_virtual_index(idx) {
1669 Some(HistoryCell::Assistant { content, .. }) => {
1670 let text = cleaned_turn_text(content, detail, 200);
1671 (!text.is_empty()).then_some(text)
1672 }
1673 _ => None,
1674 });
1675 if let Some(text) = final_text {
1676 lines.push(format!("Result: {text}"));
1677 } else if status == "in progress" {
1678 lines.push("Result: turn still running".to_string());
1679 } else {
1680 lines.push("Result: —".to_string());
1681 }
1682
1683 let error_text = (start..end)
1684 .rev()
1685 .find_map(|idx| match app.cell_at_virtual_index(idx) {
1686 Some(HistoryCell::Error { message, .. }) => {
1687 let text = cleaned_turn_text(message, detail, 160);
1688 (!text.is_empty()).then_some(text)
1689 }
1690 _ => None,
1691 });
1692 if let Some(err) = error_text {
1693 lines.push(format!("Error: {err}"));
1694 }
1695
1696 lines
1697 }
1698
1699 #[cfg(test)]
1700 mod tests {
1701 use super::*;
1702 use crate::config::Config;
1703 use crate::tui::app::{App, LspRepairState, TuiOptions};
1704 use std::path::PathBuf;
1705
1706 fn test_app() -> App {
1707 let options = TuiOptions {
1708 model: "deepseek-v4-flash".to_string(),
1709 start_in_agent_mode: true,
1710 ..crate::test_support::test_tui_options(PathBuf::from("."))
1711 };
1712 App::new(options, &Config::default())
1713 }
1714
1715 #[test]
1716 fn turn_diagnostics_lines_quiet_when_no_activity() {
1717 let mut app = test_app();
1718 app.lsp_enabled = true;
1719 assert!(turn_diagnostics_lines(&app).is_empty());
1720 app.lsp_enabled = false;
1721 assert!(turn_diagnostics_lines(&app).is_empty());
1722 }
1723
1724 #[test]
1725 fn turn_diagnostics_lines_summarize_repair_loop() {
1726 let mut app = test_app();
1727 app.lsp_enabled = true;
1728 app.lsp_repair = LspRepairState {
1729 diagnostics_found: 2,
1730 files_touched: 1,
1731 injected: true,
1732 repair_attempted: true,
1733 latest: "still_failing",
1734 };
1735 let joined = turn_diagnostics_lines(&app).join("\n");
1736 assert!(joined.contains("Found 2 diagnostics"), "{joined}");
1737 assert!(
1738 joined.contains("Injected into the next model request"),
1739 "{joined}"
1740 );
1741 assert!(joined.contains("Model attempted a repair"), "{joined}");
1742 assert!(joined.contains("still failing"), "{joined}");
1743 }
1744
1745 #[test]
1746 fn turn_route_lines_include_truthful_auto_receipt() {
1747 let mut app = test_app();
1748 app.auto_model = true;
1749 app.last_effective_provider = Some(crate::config::ApiProvider::Zai);
1750 app.last_effective_model = Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string());
1751 app.last_auto_route_receipt = Some(crate::model_routing::AutoRouteReceipt {
1752 tier: crate::model_routing::AutoRouteTier::Fast,
1753 pair: crate::model_routing::AutoRoutePair {
1754 strong: crate::config::ZAI_GLM_5_2_MODEL.to_string(),
1755 fast: Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()),
1756 },
1757 scope: crate::model_routing::AutoRouteScope::RunnableProviders,
1758 data_path: crate::model_routing::AutoRouteDataPath::Classifier {
1759 provider: crate::config::ApiProvider::Deepseek,
1760 model: "deepseek-v4-flash".to_string(),
1761 },
1762 reason: crate::model_routing::AutoRouteReason::ClassifierRecommendation,
1763 });
1764
1765 let joined = turn_route_lines(&app).join("\n");
1766
1767 assert!(joined.contains("Route: Zhipu AI / Z.ai · GLM-5-Turbo"));
1768 assert!(joined.contains("Auto decision: fast · classifier recommendation"));
1769 assert!(joined.contains("GLM-5.2 strong · GLM-5-Turbo fast"));
1770 assert!(joined.contains("Auto scope: runnable providers"));
1771 assert!(joined.contains(
1772 "Auto data: latest request + bounded recent context -> DeepSeek / deepseek-v4-flash"
1773 ));
1774 assert!(!joined.contains("API_KEY"));
1775 }
1776
1777 #[test]
1778 fn reasoning_detail_text_empty_when_no_thinking() {
1779 let app = test_app();
1780 assert!(reasoning_detail_text(&app).is_none());
1781 }
1782
1783 #[test]
1784 fn reasoning_detail_text_includes_active_cell_reasoning() {
1785 let mut app = test_app();
1786 let mut active = crate::tui::active_cell::ActiveCell::new();
1787 active.push_thinking(HistoryCell::Thinking {
1788 content: "active reasoning one".to_string(),
1789 streaming: true,
1790 duration_secs: None,
1791 });
1792 active.push_thinking(HistoryCell::Thinking {
1793 content: "active reasoning two".to_string(),
1794 streaming: false,
1795 duration_secs: Some(1.0),
1796 });
1797 app.active_cell = Some(active);
1798 app.runtime_turn_id = Some("turn-active-123".to_string());
1799 app.runtime_turn_status = Some("in_progress".to_string());
1800
1801 let body = reasoning_detail_text(&app).expect("active reasoning should produce detail");
1802 assert!(body.contains("Thinking chunk 1 of 2"), "{body}");
1803 assert!(body.contains("Thinking chunk 2 of 2"), "{body}");
1804 assert!(body.contains("active reasoning one"), "{body}");
1805 assert!(body.contains("active reasoning two"), "{body}");
1806 assert!(body.contains("running"), "{body}");
1807 }
1808
1809 #[test]
1810 fn reasoning_detail_text_scopes_to_latest_turn_without_selection() {
1811 let mut app = test_app();
1812 app.history = vec![
1813 HistoryCell::User {
1814 content: "first prompt".to_string(),
1815 },
1816 HistoryCell::Thinking {
1817 content: "first turn reasoning".to_string(),
1818 streaming: false,
1819 duration_secs: Some(1.0),
1820 },
1821 HistoryCell::Assistant {
1822 content: "first reply".to_string(),
1823 streaming: false,
1824 },
1825 HistoryCell::User {
1826 content: "second prompt".to_string(),
1827 },
1828 HistoryCell::Thinking {
1829 content: "second turn reasoning".to_string(),
1830 streaming: false,
1831 duration_secs: Some(1.0),
1832 },
1833 HistoryCell::Assistant {
1834 content: "second reply".to_string(),
1835 streaming: false,
1836 },
1837 ];
1838 app.resync_history_revisions();
1839
1840 let body =
1841 reasoning_detail_text(&app).expect("latest turn reasoning should produce detail");
1842 assert!(body.contains("second turn reasoning"), "{body}");
1843 assert!(
1844 !body.contains("first turn reasoning"),
1845 "reasoning detail without selection must scope to the latest turn: {body}"
1846 );
1847 }
1848
1849 #[test]
1850 fn turn_range_for_index_scopes_to_containing_turn() {
1851 let mut app = test_app();
1852 app.history = vec![
1853 HistoryCell::User {
1854 content: "first prompt".to_string(),
1855 },
1856 HistoryCell::Thinking {
1857 content: "first turn reasoning".to_string(),
1858 streaming: false,
1859 duration_secs: Some(1.0),
1860 },
1861 HistoryCell::Assistant {
1862 content: "first reply".to_string(),
1863 streaming: false,
1864 },
1865 HistoryCell::User {
1866 content: "second prompt".to_string(),
1867 },
1868 HistoryCell::Thinking {
1869 content: "second turn reasoning".to_string(),
1870 streaming: false,
1871 duration_secs: Some(1.0),
1872 },
1873 HistoryCell::Assistant {
1874 content: "second reply".to_string(),
1875 streaming: false,
1876 },
1877 ];
1878 app.resync_history_revisions();
1879
1880 let (start, end) = turn_range_for_index(&app, 1);
1881 assert_eq!(start, 0, "first turn should start at user cell 0");
1882 assert_eq!(end, 3, "first turn should end before second user cell");
1883
1884 let (start, end) = turn_range_for_index(&app, 4);
1885 assert_eq!(start, 3, "second turn should start at user cell 3");
1886 assert_eq!(end, 6, "second turn should run to end of transcript");
1887 }
1888
1889 #[test]
1890 fn open_reasoning_detail_pager_pushes_reasoning_detail_pager() {
1891 let mut app = test_app();
1892 app.history = vec![HistoryCell::Thinking {
1893 content: "recorded reasoning".to_string(),
1894 streaming: false,
1895 duration_secs: Some(1.0),
1896 }];
1897 app.resync_history_revisions();
1898 let revisions = app.history_revisions.clone();
1899 app.viewport.transcript_cache.ensure(
1900 &app.history,
1901 &revisions,
1902 100,
1903 app.transcript_render_options(),
1904 );
1905 app.viewport.last_transcript_area = Some(ratatui::layout::Rect {
1906 x: 0,
1907 y: 0,
1908 width: 80,
1909 height: 24,
1910 });
1911
1912 assert!(open_reasoning_detail_pager(&mut app));
1913 let top = app.view_stack.top_kind();
1914 assert_eq!(top, Some(crate::tui::views::ModalKind::Pager));
1915 }
1916
1917 #[test]
1918 fn copy_cell_to_clipboard_uses_canonical_assistant_source() {
1919 let mut app = test_app();
1920 let content = "A long response with literal ● and ▏ glyphs that wraps visually.";
1921 app.history = vec![HistoryCell::Assistant {
1922 content: content.to_string(),
1923 streaming: false,
1924 }];
1925 app.resync_history_revisions();
1926 app.viewport.last_transcript_area = Some(ratatui::layout::Rect {
1927 x: 0,
1928 y: 0,
1929 width: 12,
1930 height: 24,
1931 });
1932
1933 assert!(copy_cell_to_clipboard(&mut app, 0));
1934 assert_eq!(app.clipboard.last_written_text(), Some(content));
1935 }
1936
1937 #[test]
1938 fn focused_pager_and_copy_use_the_same_cell_target() {
1939 let mut app = test_app();
1940 app.history = vec![HistoryCell::Assistant {
1941 content: "focused markdown **answer**".to_string(),
1942 streaming: false,
1943 }];
1944 app.resync_history_revisions();
1945 app.viewport.last_transcript_area = Some(ratatui::layout::Rect {
1946 x: 0,
1947 y: 0,
1948 width: 80,
1949 height: 24,
1950 });
1951
1952 assert!(open_focused_cell_pager(&mut app));
1953 assert_eq!(
1954 app.view_stack.top_kind(),
1955 Some(crate::tui::views::ModalKind::Pager)
1956 );
1957 app.view_stack.pop();
1958 assert!(copy_focused_cell(&mut app));
1959 assert_eq!(
1960 app.clipboard.last_written_text(),
1961 Some("focused markdown **answer**")
1962 );
1963 }
1964
1965 #[test]
1966 fn turn_inspector_copy_answer_copies_only_the_latest_completed_answer() {
1967 use crate::tui::history::GenericToolCell;
1968 use crate::tui::views::{ModalView, ViewAction, ViewEvent};
1969 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1970
1971 let mut app = test_app();
1972 app.history = vec![
1973 HistoryCell::User {
1974 content: "please summarize".to_string(),
1975 },
1976 HistoryCell::Thinking {
1977 content: "private reasoning trace".to_string(),
1978 streaming: false,
1979 duration_secs: Some(1.0),
1980 },
1981 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1982 name: "read_file".to_string(),
1983 status: ToolStatus::Success,
1984 input_summary: Some("src/lib.rs".to_string()),
1985 output: Some("raw tool result body".to_string()),
1986 prompts: None,
1987 spillover_path: None,
1988 output_summary: None,
1989 is_diff: false,
1990 })),
1991 HistoryCell::System {
1992 content: "runtime status note".to_string(),
1993 },
1994 HistoryCell::Assistant {
1995 content: "still streaming partial".to_string(),
1996 streaming: true,
1997 },
1998 HistoryCell::Assistant {
1999 content: "FINAL ANSWER\nauthored markdown".to_string(),
2000 streaming: false,
2001 },
2002 ];
2003
2004 assert!(open_turn_inspector_pager(&mut app));
2005 let mut view = app.view_stack.pop().expect("turn inspector pager");
2006 let pager = view
2007 .as_any_mut()
2008 .downcast_mut::<PagerView>()
2009 .expect("turn inspector should reuse PagerView");
2010 let copied = match pager.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)) {
2011 ViewAction::Emit(ViewEvent::CopyToClipboard { text, label }) => {
2012 assert_eq!(label, "Answer");
2013 text
2014 }
2015 other => panic!("expected answer copy event, got {other:?}"),
2016 };
2017
2018 assert_eq!(copied, "FINAL ANSWER\nauthored markdown");
2019 for excluded in [
2020 "please summarize",
2021 "private reasoning trace",
2022 "raw tool result body",
2023 "runtime status note",
2024 "still streaming partial",
2025 ] {
2026 assert!(
2027 !copied.contains(excluded),
2028 "answer copy leaked {excluded:?}"
2029 );
2030 }
2031 }
2032 }
2033
2033 lines RUST