diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index fb89de619..32dd1b633 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -7591,8 +7591,18 @@ fn activity_detail_text(app: &App, cell_index: usize, width: u16) -> Option Option 1 { + let previous_index = thinking_indices[position - 2]; + let preview = thinking_chunk_preview(app, previous_index); + sections.push(format!( + "Previous chunk: {} of {total} - {preview}", + position - 1 + )); + } + if position < total { + let next_index = thinking_indices[position]; + let preview = thinking_chunk_preview(app, next_index); + sections.push(format!( + "Next chunk: {} of {total} - {preview}", + position + 1 + )); + } } sections.push(String::new()); @@ -7685,6 +7711,18 @@ fn reasoning_timeline_text(app: &App, selected_cell_index: usize) -> Option String { + let Some(HistoryCell::Thinking { content, .. }) = app.cell_at_virtual_index(cell_index) else { + return "thinking".to_string(); + }; + let preview = one_line_summary(content, 64); + if preview.is_empty() { + "thinking".to_string() + } else { + preview + } +} + fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> String { match cell { HistoryCell::Thinking { .. } => "thinking".to_string(), @@ -7793,28 +7831,70 @@ fn format_activity_duration_ms(ms: u64) -> String { } } -fn thinking_chunk_position(app: &App, cell_index: usize) -> Option<(usize, usize)> { - if !matches!( - app.cell_at_virtual_index(cell_index), - Some(HistoryCell::Thinking { .. }) - ) { - return None; +fn activity_indices(app: &App) -> Vec { + (0..app.virtual_cell_count()) + .filter(|&idx| { + app.cell_at_virtual_index(idx) + .is_some_and(is_meaningful_activity_cell) + }) + .collect() +} + +fn activity_navigation_lines( + app: &App, + position: usize, + activity_indices: &[usize], +) -> Vec { + let total = activity_indices.len(); + let mut lines = Vec::new(); + if position > 0 { + let previous_idx = activity_indices[position - 1]; + if let Some(cell) = app.cell_at_virtual_index(previous_idx) { + let label = activity_cell_label(app, previous_idx, cell); + lines.push(format!( + "Previous activity: {} of {total} - {}", + position, + truncate_line_to_width(&label, 56) + )); + } } + if position + 1 < total { + let next_idx = activity_indices[position + 1]; + if let Some(cell) = app.cell_at_virtual_index(next_idx) { + let label = activity_cell_label(app, next_idx, cell); + lines.push(format!( + "Next activity: {} of {total} - {}", + position + 2, + truncate_line_to_width(&label, 56) + )); + } + } + lines +} - let mut total = 0usize; - let mut position = None; - for idx in 0..app.virtual_cell_count() { - if matches!( - app.cell_at_virtual_index(idx), - Some(HistoryCell::Thinking { .. }) - ) { - total += 1; - if idx == cell_index { - position = Some(total); - } +fn activity_detail_handle_line(app: &App, cell_index: usize, cell: &HistoryCell) -> Option { + if let Some(detail) = app.tool_detail_record_for_cell(cell_index) { + if let Some(artifact) = app + .session_artifacts + .iter() + .find(|artifact| artifact.tool_call_id == detail.tool_id) + { + return Some(format!( + "Detail handle: {} (retrieve_tool_result ref={}; Alt+V raw details)", + artifact.id, artifact.id + )); } + return Some(format!( + "Detail handle: tool:{} (Alt+V raw details)", + detail.tool_id + )); + } + + match cell { + HistoryCell::Tool(_) => Some("Detail handle: Alt+V details".to_string()), + HistoryCell::SubAgent(_) => Some("Detail handle: Alt+V details".to_string()), + _ => None, } - position.map(|pos| (pos, total)) } fn activity_cell_to_text(cell: &HistoryCell, width: u16) -> String { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4f0baa5bf..9773cb031 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5048,6 +5048,10 @@ fn activity_detail_opens_reasoning_timeline_for_selected_thinking() { body.contains("Selected chunk: 1 of 2"), "chunk position missing: {body}" ); + assert!( + body.contains("Next chunk: 2 of 2 - second chunk reasoning"), + "neighboring chunk missing: {body}" + ); assert!(body.contains("Thinking chunk 1 of 2 (selected)"), "{body}"); assert!(body.contains("Thinking chunk 2 of 2"), "{body}"); assert!(body.contains("first chunk reasoning"), "body: {body}"); @@ -5057,6 +5061,95 @@ fn activity_detail_opens_reasoning_timeline_for_selected_thinking() { ); } +#[test] +fn activity_detail_includes_tool_handle_and_neighbor_context() { + let mut app = create_test_app(); + app.history = vec![ + HistoryCell::Thinking { + content: "checked approach".to_string(), + streaming: false, + duration_secs: Some(0.6), + }, + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "read_file".to_string(), + status: ToolStatus::Success, + input_summary: Some("src/main.rs".to_string()), + output: Some("bounded preview".to_string()), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })), + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "grep_files".to_string(), + status: ToolStatus::Success, + input_summary: Some("TODO".to_string()), + output: Some("grep summary".to_string()), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })), + ]; + app.tool_details_by_cell.insert( + 1, + ToolDetailRecord { + tool_id: "call-read".to_string(), + tool_name: "read_file".to_string(), + input: serde_json::json!({"path": "src/main.rs"}), + output: Some("full output behind raw details".to_string()), + }, + ); + app.session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_call-read".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "session-activity".to_string(), + tool_call_id: "call-read".to_string(), + tool_name: "read_file".to_string(), + created_at: chrono::Utc::now(), + byte_size: 42, + preview: "bounded preview".to_string(), + storage_path: PathBuf::from("artifacts").join("art_call-read.txt"), + }); + app.resync_history_revisions(); + let revisions = app.history_revisions.clone(); + app.viewport.transcript_cache.ensure( + &app.history, + &revisions, + 100, + app.transcript_render_options(), + ); + let line = first_line_for_cell(&app, 1); + let point = TranscriptSelectionPoint { + line_index: line, + column: 0, + }; + app.viewport.transcript_selection.anchor = Some(point); + app.viewport.transcript_selection.head = Some(point); + + assert!(open_activity_detail_pager(&mut app)); + let body = pop_pager_body(&mut app); + + assert!(body.contains("Activity: read_file"), "{body}"); + assert!(body.contains("Activity chunk: 2 of 3"), "{body}"); + assert!( + body.contains("Previous activity: 1 of 3 - thinking"), + "{body}" + ); + assert!( + body.contains("Next activity: 3 of 3 - tool grep_files"), + "{body}" + ); + assert!(body.contains("Detail handle: art_call-read"), "{body}"); + assert!( + body.contains("retrieve_tool_result ref=art_call-read"), + "{body}" + ); + assert!(body.contains("Alt+V"), "{body}"); + assert!(body.contains("raw details"), "{body}"); +} + #[test] fn activity_detail_fallback_prefers_live_activity_context() { let mut app = create_test_app(); @@ -5115,6 +5208,11 @@ fn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_du body.contains("Alt+V for details"), "activity detail should stay bounded and point to Alt+V for raw detail: {body}" ); + assert!(body.contains("Detail handle: Alt+V details"), "{body}"); + assert!( + !body.contains("Detail handle: Alt+V raw details"), + "fallback tool details should not be labeled raw: {body}" + ); assert!( !body.contains("line 10"), "middle of large raw output should not be dumped into Activity Detail: {body}"