From d7b033d59e328fffec3db5701a37af75760696e7 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 28 Apr 2026 17:36:00 -0500 Subject: [PATCH] Polish tool cards and context previews (#151) --- crates/tui/src/tui/diff_render.rs | 237 ++++++++- crates/tui/src/tui/file_mention.rs | 186 +++++++ crates/tui/src/tui/history.rs | 465 ++++++++++++++---- crates/tui/src/tui/ui.rs | 15 +- crates/tui/src/tui/ui/tests.rs | 29 ++ .../src/tui/widgets/pending_input_preview.rs | 78 ++- crates/tui/src/tui/widgets/tool_card.rs | 68 ++- 7 files changed, 976 insertions(+), 102 deletions(-) diff --git a/crates/tui/src/tui/diff_render.rs b/crates/tui/src/tui/diff_render.rs index d70f73a4..e05b4029 100644 --- a/crates/tui/src/tui/diff_render.rs +++ b/crates/tui/src/tui/diff_render.rs @@ -8,10 +8,23 @@ use crate::palette; const LINE_NUMBER_WIDTH: usize = 4; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffFileSummary { + pub path: String, + pub added: usize, + pub deleted: usize, + pub hunks: usize, +} + pub fn render_diff(diff: &str, width: u16) -> Vec> { let mut lines = Vec::new(); let mut old_line: Option = None; let mut new_line: Option = None; + let summaries = summarize_diff(diff); + + if !summaries.is_empty() { + lines.extend(render_diff_summary(&summaries, width)); + } for raw in diff.lines() { if raw.starts_with("diff --git") || raw.starts_with("index ") { @@ -40,6 +53,7 @@ pub fn render_diff(diff: &str, width: u16) -> Vec> { width, old_line, new_line, + '+', Style::default().fg(palette::STATUS_SUCCESS), )); if let Some(line) = new_line.as_mut() { @@ -55,6 +69,7 @@ pub fn render_diff(diff: &str, width: u16) -> Vec> { width, old_line, new_line, + '-', Style::default().fg(palette::STATUS_ERROR), )); if let Some(line) = old_line.as_mut() { @@ -70,6 +85,7 @@ pub fn render_diff(diff: &str, width: u16) -> Vec> { width, old_line, new_line, + ' ', Style::default().fg(palette::TEXT_PRIMARY), )); if let Some(line) = old_line.as_mut() { @@ -87,6 +103,153 @@ pub fn render_diff(diff: &str, width: u16) -> Vec> { lines } +#[must_use] +pub fn summarize_diff(diff: &str) -> Vec { + let mut summaries = Vec::new(); + let mut current: Option = None; + + for raw in diff.lines() { + if raw.starts_with("diff --git ") { + if let Some(summary) = current.take() + && summary.has_changes() + { + summaries.push(summary); + } + current = Some(DiffFileSummary { + path: parse_diff_git_path(raw).unwrap_or_else(|| "".to_string()), + added: 0, + deleted: 0, + hunks: 0, + }); + continue; + } + + if raw.starts_with("+++ ") { + let path = raw + .trim_start_matches("+++ ") + .trim_start_matches("b/") + .to_string(); + if path != "/dev/null" { + current + .get_or_insert_with(|| DiffFileSummary { + path: path.clone(), + added: 0, + deleted: 0, + hunks: 0, + }) + .path = path.clone(); + } + continue; + } + + if raw.starts_with("@@") { + current + .get_or_insert_with(|| DiffFileSummary { + path: "".to_string(), + added: 0, + deleted: 0, + hunks: 0, + }) + .hunks += 1; + continue; + } + + if raw.starts_with('+') && !raw.starts_with("+++") { + current + .get_or_insert_with(|| DiffFileSummary { + path: "".to_string(), + added: 0, + deleted: 0, + hunks: 0, + }) + .added += 1; + } else if raw.starts_with('-') && !raw.starts_with("---") { + current + .get_or_insert_with(|| DiffFileSummary { + path: "".to_string(), + added: 0, + deleted: 0, + hunks: 0, + }) + .deleted += 1; + } + } + + if let Some(summary) = current + && summary.has_changes() + { + summaries.push(summary); + } + + summaries +} + +#[must_use] +pub fn diff_summary_label(diff: &str) -> Option { + let summaries = summarize_diff(diff); + if summaries.is_empty() { + return None; + } + let files = summaries.len(); + let added: usize = summaries.iter().map(|summary| summary.added).sum(); + let deleted: usize = summaries.iter().map(|summary| summary.deleted).sum(); + Some(format!( + "{files} file{} +{added} -{deleted}", + if files == 1 { "" } else { "s" } + )) +} + +impl DiffFileSummary { + fn has_changes(&self) -> bool { + self.added > 0 || self.deleted > 0 || self.hunks > 0 + } +} + +fn parse_diff_git_path(line: &str) -> Option { + let mut parts = line.split_whitespace(); + let _diff = parts.next()?; + let _git = parts.next()?; + let _old = parts.next()?; + let new = parts.next()?; + Some(new.trim_start_matches("b/").to_string()) +} + +fn render_diff_summary(summaries: &[DiffFileSummary], width: u16) -> Vec> { + let files = summaries.len(); + let added: usize = summaries.iter().map(|summary| summary.added).sum(); + let deleted: usize = summaries.iter().map(|summary| summary.deleted).sum(); + let hunks: usize = summaries.iter().map(|summary| summary.hunks).sum(); + + let mut lines = Vec::new(); + lines.extend(wrap_with_style( + &format!( + "summary: {files} file{}, +{added} -{deleted}, {hunks} hunk{}", + if files == 1 { "" } else { "s" }, + if hunks == 1 { "" } else { "s" }, + ), + Style::default() + .fg(palette::TEXT_PRIMARY) + .add_modifier(Modifier::BOLD), + width, + )); + for summary in summaries { + let row = format!( + " {} +{} -{} {} hunk{}", + summary.path, + summary.added, + summary.deleted, + summary.hunks, + if summary.hunks == 1 { "" } else { "s" }, + ); + lines.extend(wrap_with_style( + &row, + Style::default().fg(palette::TEXT_MUTED), + width, + )); + } + lines +} + fn parse_hunk_header(line: &str) -> Option<(usize, usize)> { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() < 3 { @@ -116,9 +279,10 @@ fn render_diff_line( width: u16, old_line: Option, new_line: Option, + marker: char, style: Style, ) -> Vec> { - let prefix = format_line_numbers(old_line, new_line); + let prefix = format_line_numbers(old_line, new_line, marker); let prefix_width = prefix.width(); let available = width.saturating_sub(prefix_width as u16).max(1) as usize; let wrapped = wrap_text(content, available); @@ -148,7 +312,7 @@ fn render_diff_line( out } -fn format_line_numbers(old_line: Option, new_line: Option) -> String { +fn format_line_numbers(old_line: Option, new_line: Option, marker: char) -> String { let old = old_line .map(|value| { format!( @@ -165,7 +329,7 @@ fn format_line_numbers(old_line: Option, new_line: Option) -> Stri ) }) .unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH)); - format!("{old} {new} | ") + format!("{old} {new} {marker} ") } fn wrap_with_style(text: &str, style: Style, width: u16) -> Vec> { @@ -216,3 +380,70 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } + +#[cfg(test)] +mod tests { + use super::*; + + fn line_text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn summarizes_multi_file_diff() { + let diff = "\ +diff --git a/src/a.rs b/src/a.rs +--- a/src/a.rs ++++ b/src/a.rs +@@ -1,2 +1,3 @@ + line ++new +-old +diff --git a/src/b.rs b/src/b.rs +--- a/src/b.rs ++++ b/src/b.rs +@@ -10,0 +11,2 @@ ++one ++two +"; + + let summaries = summarize_diff(diff); + assert_eq!(summaries.len(), 2); + assert_eq!(summaries[0].path, "src/a.rs"); + assert_eq!(summaries[0].added, 1); + assert_eq!(summaries[0].deleted, 1); + assert_eq!(summaries[1].path, "src/b.rs"); + assert_eq!(summaries[1].added, 2); + assert_eq!(summaries[1].deleted, 0); + assert_eq!(diff_summary_label(diff).as_deref(), Some("2 files +3 -1")); + } + + #[test] + fn render_diff_prepends_summary_and_gutter_markers() { + let diff = "\ +diff --git a/src/a.rs b/src/a.rs +--- a/src/a.rs ++++ b/src/a.rs +@@ -1,2 +1,3 @@ + line ++new +-old +"; + + let rendered = render_diff(diff, 80); + let text = rendered.iter().map(line_text).collect::>(); + assert!(text[0].contains("summary: 1 file, +1 -1, 1 hunk")); + assert!(text.iter().any(|line| line.contains("src/a.rs +1 -1"))); + assert!( + text.iter().any(|line| line.contains(" + new")), + "added line should carry + gutter: {text:?}" + ); + assert!( + text.iter().any(|line| line.contains(" - old")), + "deleted line should carry - gutter: {text:?}" + ); + } +} diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index b57f74f0..e977c2b1 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -41,6 +41,16 @@ pub const MAX_DIRECTORY_MENTION_ENTRIES: usize = 80; /// the cost of walking large workspaces; subsequent keystrokes narrow further. const FILE_MENTION_COMPLETION_LIMIT: usize = 64; +/// Compact composer preview row for local context that will be included or +/// skipped when the user submits the current input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileMentionPreview { + pub kind: String, + pub label: String, + pub detail: Option, + pub included: bool, +} + // --------------------------------------------------------------------------- // Tab-completion // --------------------------------------------------------------------------- @@ -273,6 +283,139 @@ pub fn user_request_with_file_mentions( format!("{input}\n\n---\n\nLocal context from @mentions:\n{context}") } +#[must_use] +pub fn pending_context_previews( + input: &str, + workspace: &Path, + cwd: Option, +) -> Vec { + let mut previews = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd); + + for mention in extract_file_mentions(input) + .into_iter() + .take(MAX_FILE_MENTIONS_PER_MESSAGE) + { + let (path, display_path, exists) = match ws.resolve(&mention) { + Ok(path) => { + let display = path.display().to_string(); + (path, display, true) + } + Err(path) => { + let display = path.display().to_string(); + (path, display, false) + } + }; + if !seen.insert(format!("mention:{display_path}")) { + continue; + } + previews.push(preview_for_mention(&mention, &path, &display_path, exists)); + } + + for reference in extract_media_attachment_references(input) { + if !seen.insert(format!("media:{}", reference.path)) { + continue; + } + previews.push(FileMentionPreview { + kind: reference.kind, + label: reference.path, + detail: Some("attached media".to_string()), + included: true, + }); + } + + previews +} + +fn preview_for_mention( + raw: &str, + path: &Path, + display_path: &str, + exists: bool, +) -> FileMentionPreview { + if !exists { + return FileMentionPreview { + kind: "missing".to_string(), + label: raw.to_string(), + detail: Some("not found".to_string()), + included: false, + }; + } + if path.is_dir() { + return FileMentionPreview { + kind: "dir".to_string(), + label: raw.to_string(), + detail: Some("directory listing".to_string()), + included: true, + }; + } + if !path.is_file() { + return FileMentionPreview { + kind: "skipped".to_string(), + label: raw.to_string(), + detail: Some("unsupported path".to_string()), + included: false, + }; + } + if is_media_path(path) { + return FileMentionPreview { + kind: "media".to_string(), + label: raw.to_string(), + detail: Some("use /attach for media bytes".to_string()), + included: false, + }; + } + + let detail = match std::fs::metadata(path) { + Ok(metadata) if metadata.len() > MAX_MENTION_FILE_BYTES => { + Some("included truncated".to_string()) + } + Ok(_) => Some("included".to_string()), + Err(err) => Some(format!("metadata: {err}")), + }; + + FileMentionPreview { + kind: "file".to_string(), + label: raw.to_string(), + detail: detail.or_else(|| Some(display_path.to_string())), + included: true, + } +} + +#[derive(Debug, Clone)] +struct MediaAttachmentReference { + kind: String, + path: String, +} + +fn extract_media_attachment_references(input: &str) -> Vec { + let mut out = Vec::new(); + for line in input.lines() { + let trimmed = line.trim(); + let Some(body) = trimmed + .strip_prefix("[Attached ") + .and_then(|value| value.strip_suffix(']')) + else { + continue; + }; + let Some((kind, rest)) = body.split_once(": ") else { + continue; + }; + let path = rest + .rsplit_once(" at ") + .map_or(rest, |(_, path)| path) + .trim(); + if !path.is_empty() { + out.push(MediaAttachmentReference { + kind: kind.trim().to_string(), + path: path.to_string(), + }); + } + } + out +} + fn local_context_from_file_mentions( input: &str, workspace: &Path, @@ -638,4 +781,47 @@ mod tests { "got: {content}", ); } + + #[test] + fn pending_context_preview_marks_included_and_missing_mentions() { + let tmp = TempDir::new().expect("tempdir"); + std::fs::write(tmp.path().join("guide.md"), "hello").expect("write"); + + let previews = pending_context_previews( + "read @guide.md and @missing.md", + tmp.path(), + Some(tmp.path().to_path_buf()), + ); + + assert_eq!(previews.len(), 2); + assert_eq!(previews[0].kind, "file"); + assert_eq!(previews[0].label, "guide.md"); + assert!(previews[0].included); + assert_eq!(previews[1].kind, "missing"); + assert_eq!(previews[1].label, "missing.md"); + assert!(!previews[1].included); + } + + #[test] + fn pending_context_preview_distinguishes_attach_media_from_at_media() { + let tmp = TempDir::new().expect("tempdir"); + std::fs::write(tmp.path().join("photo.png"), b"png").expect("write"); + let attached = tmp.path().join("photo.png").display().to_string(); + let input = format!("inspect @photo.png\n[Attached image: {attached}]"); + + let previews = pending_context_previews(&input, tmp.path(), Some(tmp.path().to_path_buf())); + + assert!( + previews + .iter() + .any(|item| item.kind == "media" && !item.included), + "at-mention media should be hint-only: {previews:?}" + ); + assert!( + previews + .iter() + .any(|item| item.kind == "image" && item.included), + "/attach media should be included: {previews:?}" + ); + } } diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 4228e66e..961a40ef 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -21,6 +21,9 @@ use crate::tui::markdown_render; const TOOL_COMMAND_LINE_LIMIT: usize = 3; const TOOL_OUTPUT_LINE_LIMIT: usize = 6; const TOOL_TEXT_LIMIT: usize = 180; +const TOOL_HEADER_SUMMARY_LIMIT: usize = 56; +const TOOL_OUTPUT_HEAD_LINES: usize = 2; +const TOOL_OUTPUT_TAIL_LINES: usize = 2; const TOOL_RUNNING_SYMBOLS: [&str; 4] = ["·", "◦", "•", "◦"]; // Spinner cadence per glyph. The status-animation tick (UI_STATUS_ANIMATION_MS // = 360 ms) fires every two glyphs, so a full 4-glyph "heartbeat" lands in @@ -466,8 +469,14 @@ impl ExecCell { mode: RenderMode, ) -> Vec> { let mut lines = Vec::new(); - lines.push(render_tool_header( + let command_summary = command_header_summary(&self.command); + let header_summary = self + .interaction + .as_deref() + .or(Some(command_summary.as_str())); + lines.push(render_tool_header_with_summary( "Shell", + header_summary, tool_status_label(self.status), self.status, self.started_at, @@ -549,8 +558,10 @@ impl ExploringCell { } else { ToolStatus::Running }; - lines.push(render_tool_header( + let header_summary = exploring_header_summary(&self.entries); + lines.push(render_tool_header_with_summary( "Workspace", + header_summary.as_deref(), if all_done { "done" } else { "running" }, status, None, @@ -660,8 +671,9 @@ impl PatchSummaryCell { mode: RenderMode, ) -> Vec> { let mut lines = Vec::new(); - lines.push(render_tool_header( + lines.push(render_tool_header_with_summary( "Patch", + Some(&self.path), tool_status_label(self.status), self.status, None, @@ -837,8 +849,10 @@ pub struct DiffPreviewCell { impl DiffPreviewCell { pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { let mut lines = Vec::new(); - lines.push(render_tool_header( + let diff_summary = diff_render::diff_summary_label(&self.diff); + lines.push(render_tool_header_with_summary( "Diff", + diff_summary.as_deref(), "done", ToolStatus::Success, None, @@ -872,8 +886,9 @@ impl McpToolCell { mode: RenderMode, ) -> Vec> { let mut lines = Vec::new(); - lines.push(render_tool_header( + lines.push(render_tool_header_with_summary( "Tool", + Some(&self.tool), tool_status_label(self.status), self.status, None, @@ -916,19 +931,16 @@ pub struct ViewImageCell { impl ViewImageCell { /// Render the image view cell into lines. pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { - let mut lines = vec![render_tool_header( + let path = self.path.display().to_string(); + let mut lines = vec![render_tool_header_with_summary( "Image", + Some(&path), "done", ToolStatus::Success, None, low_motion, )]; - lines.extend(render_compact_kv( - "path", - &self.path.display().to_string(), - tool_value_style(), - width, - )); + lines.extend(render_compact_kv("path", &path, tool_value_style(), width)); lines } } @@ -945,8 +957,9 @@ impl WebSearchCell { /// Render the web search cell into lines. pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { let mut lines = Vec::new(); - lines.push(render_tool_header( + lines.push(render_tool_header_with_summary( "Search", + Some(&self.query), tool_status_label(self.status), self.status, None, @@ -1001,8 +1014,13 @@ impl GenericToolCell { // gives a `GenericToolCell` the right verb glyph (◐ delegate, ⋮⋮ // fanout, etc.) instead of falling back to the neutral bullet. let family = crate::tui::widgets::tool_card::tool_family_for_name(&self.name); - lines.push(render_tool_header_with_family( + let header_summary = crate::tui::widgets::tool_card::tool_header_summary_for_name( + &self.name, + self.input_summary.as_deref(), + ); + lines.push(render_tool_header_with_family_and_summary( family, + header_summary.as_deref(), tool_status_label(self.status), self.status, None, @@ -1520,6 +1538,24 @@ fn render_command_mode(command: &str, width: u16, mode: RenderMode) -> Vec String { + command + .lines() + .next() + .unwrap_or(command) + .trim_start_matches("$ ") + .trim() + .to_string() +} + +fn exploring_header_summary(entries: &[ExploringEntry]) -> Option { + match entries { + [] => None, + [entry] => Some(entry.label.clone()), + entries => Some(format!("{} items", entries.len())), + } +} + fn render_compact_kv(label: &str, value: &str, style: Style, width: u16) -> Vec> { render_card_detail_line(Some(label.trim_end_matches(':')), value, style, width) } @@ -1530,42 +1566,7 @@ fn render_tool_output_mode( line_limit: usize, mode: RenderMode, ) -> Vec> { - let mut lines = Vec::new(); - if output.trim().is_empty() { - lines.push(Line::from(Span::styled( - " (no output)", - Style::default().fg(palette::TEXT_MUTED).italic(), - ))); - return lines; - } - let mut all_lines = Vec::new(); - for line in output.lines() { - all_lines.extend(wrap_text(line, width.saturating_sub(4).max(1) as usize)); - } - let total = all_lines.len(); - let effective_limit = match mode { - RenderMode::Live => line_limit, - RenderMode::Transcript => usize::MAX, - }; - for (idx, line) in all_lines.into_iter().enumerate() { - if idx >= effective_limit { - let omitted = total.saturating_sub(effective_limit); - if omitted > 0 { - lines.push(details_affordance_line( - &format!("+{omitted} more lines; Alt+V for details"), - Style::default().fg(palette::TEXT_MUTED), - )); - } - break; - } - lines.extend(render_card_detail_line( - if idx == 0 { Some("result") } else { None }, - &line, - tool_value_style(), - width, - )); - } - lines + render_preserved_output_mode(output, width, line_limit, mode, "result") } fn review_severity_color(severity: &str) -> Color { @@ -1591,6 +1592,22 @@ fn render_exec_output_mode( width: u16, line_limit: usize, mode: RenderMode, +) -> Vec> { + render_preserved_output_mode(output, width, line_limit, mode, "output") +} + +#[derive(Debug, Clone)] +struct OutputRow { + text: String, + intact: bool, +} + +fn render_preserved_output_mode( + output: &str, + width: u16, + line_limit: usize, + mode: RenderMode, + first_label: &str, ) -> Vec> { let mut lines = Vec::new(); if output.trim().is_empty() { @@ -1601,64 +1618,174 @@ fn render_exec_output_mode( return lines; } - let mut all_lines = Vec::new(); - for line in output.lines() { - all_lines.extend(wrap_text(line, width.saturating_sub(4).max(1) as usize)); - } - - let total = all_lines.len(); + let all_lines = output_rows(output, width); if matches!(mode, RenderMode::Transcript) { // Full-content path: emit every wrapped line with no head/tail split, // no "+N more" affordance. - for (idx, line) in all_lines.iter().enumerate() { - lines.extend(render_card_detail_line( - if idx == 0 { Some("output") } else { None }, - line, - tool_value_style(), + for (idx, row) in all_lines.iter().enumerate() { + render_output_row( + &mut lines, + if idx == 0 { Some(first_label) } else { None }, + row, width, - )); + ); } return lines; } - let head_end = total.min(line_limit); - for (idx, line) in all_lines[..head_end].iter().enumerate() { + let selected = selected_output_indices(&all_lines, line_limit); + let mut previous: Option = None; + for (rendered_idx, idx) in selected.iter().copied().enumerate() { + if let Some(prev) = previous { + let omitted = idx.saturating_sub(prev + 1); + if omitted > 0 { + lines.push(details_affordance_line( + &format!("{omitted} lines omitted; Alt+V for details"), + Style::default().fg(palette::TEXT_MUTED), + )); + } + } + + let row = &all_lines[idx]; + render_output_row( + &mut lines, + if rendered_idx == 0 { + Some(first_label) + } else { + None + }, + row, + width, + ); + previous = Some(idx); + } + + lines +} + +fn output_rows(output: &str, width: u16) -> Vec { + let wrap_width = width.saturating_sub(4).max(1) as usize; + let mut rows = Vec::new(); + for line in output.lines() { + let intact = is_path_or_url_like(line); + if intact { + rows.push(OutputRow { + text: line.to_string(), + intact: true, + }); + } else { + for wrapped in wrap_text(line, wrap_width) { + rows.push(OutputRow { + text: wrapped, + intact: false, + }); + } + } + } + if rows.is_empty() { + rows.push(OutputRow { + text: String::new(), + intact: false, + }); + } + rows +} + +fn selected_output_indices(rows: &[OutputRow], line_limit: usize) -> Vec { + let total = rows.len(); + if total <= line_limit || line_limit == 0 { + return (0..total).collect(); + } + + let head = TOOL_OUTPUT_HEAD_LINES.min(line_limit).min(total); + let tail = TOOL_OUTPUT_TAIL_LINES + .min(line_limit.saturating_sub(head)) + .min(total.saturating_sub(head)); + let mut selected = std::collections::BTreeSet::new(); + selected.extend(0..head); + selected.extend(total.saturating_sub(tail)..total); + + let budget = line_limit.saturating_sub(selected.len()); + if budget > 0 { + let mut important: Vec<(usize, usize)> = rows + .iter() + .enumerate() + .skip(head) + .take(total.saturating_sub(head + tail)) + .filter_map(|(idx, row)| output_importance_rank(&row.text).map(|rank| (idx, rank))) + .collect(); + important.sort_by_key(|(idx, rank)| (*rank, *idx)); + for (idx, _) in important.into_iter().take(budget) { + selected.insert(idx); + } + } + + selected.into_iter().collect() +} + +fn output_importance_rank(line: &str) -> Option { + let lower = line.to_ascii_lowercase(); + if [ + "error", + "failed", + "failure", + "fatal", + "panic", + "exception", + "traceback", + "denied", + "not found", + "no such file", + "cannot", + "can't", + ] + .iter() + .any(|needle| lower.contains(needle)) + { + return Some(0); + } + if lower.contains("warning") || lower.contains("warn") { + return Some(1); + } + if is_path_or_url_like(line) { + return Some(2); + } + None +} + +fn is_path_or_url_like(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.contains("://") || trimmed.starts_with("file:") { + return true; + } + let has_separator = trimmed.contains('/') || trimmed.contains('\\'); + let has_extension = trimmed + .split_whitespace() + .any(|part| part.rsplit_once('.').is_some_and(|(_, ext)| ext.len() <= 8)); + has_separator && has_extension +} + +fn render_output_row( + lines: &mut Vec>, + label: Option<&str>, + row: &OutputRow, + width: u16, +) { + if row.intact { + lines.push(render_card_detail_line_single( + label, + &row.text, + tool_value_style(), + )); + } else { lines.extend(render_card_detail_line( - if idx == 0 { Some("output") } else { None }, - line, + label, + &row.text, tool_value_style(), width, )); } - - if total > 2 * line_limit { - let omitted = total.saturating_sub(2 * line_limit); - lines.push(details_affordance_line( - &format!("+{omitted} more lines; Alt+V for details"), - Style::default().fg(palette::TEXT_MUTED), - )); - let tail_start = total.saturating_sub(line_limit); - for line in &all_lines[tail_start..] { - lines.extend(render_card_detail_line( - None, - line, - tool_value_style(), - width, - )); - } - } else if total > head_end { - for line in &all_lines[head_end..] { - lines.extend(render_card_detail_line( - None, - line, - tool_value_style(), - width, - )); - } - } - - lines } fn wrap_plain_line(line: &str, style: Style, width: u16) -> Vec> { @@ -1834,6 +1961,20 @@ fn render_tool_header( render_tool_header_with_family(family, state, status, started_at, low_motion) } +fn render_tool_header_with_summary( + title: &str, + summary: Option<&str>, + state: &str, + status: ToolStatus, + started_at: Option, + low_motion: bool, +) -> Line<'static> { + let family = crate::tui::widgets::tool_card::tool_family_for_title(title); + render_tool_header_with_family_and_summary( + family, summary, state, status, started_at, low_motion, + ) +} + /// Render a tool-card header with an explicit verb family. Lets callers /// (e.g. `GenericToolCell`) bypass the legacy title→family mapping when /// they already know the actual tool name. @@ -1843,6 +1984,17 @@ fn render_tool_header_with_family( status: ToolStatus, started_at: Option, low_motion: bool, +) -> Line<'static> { + render_tool_header_with_family_and_summary(family, None, state, status, started_at, low_motion) +} + +fn render_tool_header_with_family_and_summary( + family: crate::tui::widgets::tool_card::ToolFamily, + summary: Option<&str>, + state: &str, + status: ToolStatus, + started_at: Option, + low_motion: bool, ) -> Line<'static> { // For long-running tools, append elapsed seconds so the user can see the // call isn't stuck. Threshold matches the eye's "did this hang?" reflex @@ -1859,7 +2011,7 @@ fn render_tool_header_with_family( let glyph = crate::tui::widgets::tool_card::family_glyph(family); let verb = crate::tui::widgets::tool_card::family_label(family); - Line::from(vec![ + let mut spans = vec![ Span::styled( format!("{} ", status_symbol(started_at, status, low_motion)), Style::default().fg(tool_state_color(status)), @@ -1871,7 +2023,31 @@ fn render_tool_header_with_family( Span::styled(verb.to_string(), tool_title_style()), Span::styled(" ", Style::default()), Span::styled(state_owned, tool_status_style(status)), - ]) + ]; + + if let Some(summary) = summary.and_then(normalize_header_summary) { + spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM))); + spans.push(Span::styled( + truncate_text(&summary, TOOL_HEADER_SUMMARY_LIMIT), + Style::default().fg(palette::TEXT_MUTED), + )); + } + + Line::from(spans) +} + +fn normalize_header_summary(summary: &str) -> Option { + let normalized = summary + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string(); + if normalized.is_empty() { + None + } else { + Some(normalized) + } } /// Build the "running" label with an elapsed-seconds badge for long-running @@ -1920,6 +2096,21 @@ fn render_card_detail_line( lines } +fn render_card_detail_line_single( + label: Option<&str>, + value: &str, + value_style: Style, +) -> Line<'static> { + let label_text = label.map(|text| format!("{text}:")); + let mut spans = vec![Span::styled("▏ ", Style::default().fg(palette::TEXT_DIM))]; + if let Some(label_text) = label_text { + spans.push(Span::styled(label_text, tool_detail_label_style())); + spans.push(Span::raw(" ")); + } + spans.push(Span::styled(value.to_string(), value_style)); + Line::from(spans) +} + fn tool_title_style() -> Style { active_theme().tool_title_style() } @@ -2207,6 +2398,31 @@ mod tests { ); } + #[test] + fn exec_cell_header_includes_compact_command_summary() { + let cell = ExecCell { + command: "cargo test --workspace --all-features".to_string(), + status: ToolStatus::Running, + output: None, + started_at: None, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + }; + + let header = &cell.lines_with_motion(80, true)[0]; + let visible: String = header + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect::(); + assert!(visible.contains("run running")); + assert!( + visible.contains("cargo test --workspace --all-features"), + "header should expose command target: {visible:?}" + ); + } + #[test] fn generic_tool_cell_picks_family_from_tool_name() { let cell = GenericToolCell { @@ -2767,6 +2983,63 @@ mod tests { assert!(transcript_text.contains("row 29")); } + #[test] + fn generic_tool_output_live_keeps_tail_and_omitted_count() { + let output = (0..24usize) + .map(|i| format!("line {i:02}")) + .collect::>() + .join("\n"); + let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "exec_shell".to_string(), + status: ToolStatus::Success, + input_summary: Some("command: noisy".to_string()), + output: Some(output), + prompts: None, + })); + + let live_text = + lines_text(&cell.lines_with_options(80, TranscriptRenderOptions::default())); + + assert!(live_text.contains("line 00")); + assert!(live_text.contains("line 23")); + assert!(live_text.contains("lines omitted; Alt+V for details")); + assert!( + !live_text.contains("line 12"), + "middle plain output should stay omitted in live view: {live_text}" + ); + } + + #[test] + fn tool_output_live_preserves_error_and_path_lines_from_middle() { + let output = [ + "start", + "still starting", + "middle noise 1", + "fatal: failed to read /tmp/deepseek/config.toml", + "middle noise 2", + "see https://example.test/build/log for details", + "middle noise 3", + "almost done", + "final line", + ] + .join("\n"); + let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "exec_shell".to_string(), + status: ToolStatus::Failed, + input_summary: Some("command: tool".to_string()), + output: Some(output), + prompts: None, + })); + + let live_text = + lines_text(&cell.lines_with_options(80, TranscriptRenderOptions::default())); + + assert!(live_text.contains("fatal: failed to read /tmp/deepseek/config.toml")); + assert!(live_text.contains("https://example.test/build/log")); + assert!(live_text.contains("final line")); + assert!(live_text.contains("lines omitted; Alt+V for details")); + } + // === ErrorEnvelope severity → cell color tests (#66) === /// Snapshot: an `Error`-severity cell uses the red status palette token diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 951dcf5e..0855492f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -82,7 +82,7 @@ use super::slash_menu::{ apply_slash_menu_selection, try_autocomplete_slash_command, visible_slash_menu_entries, }; use super::views::{ConfigView, HelpView, ModalKind, ViewEvent}; -use super::widgets::pending_input_preview::PendingInputPreview; +use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview}; use super::widgets::{ ChatWidget, ComposerWidget, FooterProps, FooterToast, FooterWidget, HeaderData, HeaderWidget, Renderable, @@ -3035,6 +3035,19 @@ fn reconcile_subagent_activity_state(app: &mut App) { /// end-of-turn. fn build_pending_input_preview(app: &App) -> PendingInputPreview { let mut preview = PendingInputPreview::new(); + preview.context_items = crate::tui::file_mention::pending_context_previews( + &app.input, + &app.workspace, + std::env::current_dir().ok(), + ) + .into_iter() + .map(|item| ContextPreviewItem { + kind: item.kind, + label: item.label, + detail: item.detail, + included: item.included, + }) + .collect(); preview.pending_steers = app .pending_steers .iter() diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4b3350d0..06195660 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2094,6 +2094,35 @@ fn build_pending_input_preview_populates_all_three_buckets() { assert_eq!(preview.queued_messages, vec!["queued-msg".to_string()]); } +#[test] +fn build_pending_input_preview_includes_current_context_chips() { + let tmpdir = TempDir::new().expect("tempdir"); + std::fs::write(tmpdir.path().join("guide.md"), "hello").expect("write"); + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.input = "Read @guide.md and @missing.md".to_string(); + app.cursor_position = app.input.chars().count(); + + let preview = build_pending_input_preview(&app); + + assert!( + preview + .context_items + .iter() + .any(|item| item.kind == "file" && item.label == "guide.md" && item.included), + "file mention preview missing: {:?}", + preview.context_items + ); + assert!( + preview + .context_items + .iter() + .any(|item| item.kind == "missing" && item.label == "missing.md" && !item.included), + "missing mention preview missing: {:?}", + preview.context_items + ); +} + #[test] fn render_footer_from_with_default_items_renders_mode_and_model() { // Default footer composition should show the mode chip and model diff --git a/crates/tui/src/tui/widgets/pending_input_preview.rs b/crates/tui/src/tui/widgets/pending_input_preview.rs index affe5022..785b0e9f 100644 --- a/crates/tui/src/tui/widgets/pending_input_preview.rs +++ b/crates/tui/src/tui/widgets/pending_input_preview.rs @@ -49,15 +49,28 @@ impl EditBinding { /// messages while a turn is in progress. #[derive(Debug, Clone)] pub struct PendingInputPreview { + pub context_items: Vec, pub pending_steers: Vec, pub rejected_steers: Vec, pub queued_messages: Vec, pub edit_binding: EditBinding, } +/// Compact pre-send context row shown above the composer. `included=false` +/// marks missing/skipped context distinctly from files/media that will be +/// sent or inlined. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextPreviewItem { + pub kind: String, + pub label: String, + pub detail: Option, + pub included: bool, +} + impl PendingInputPreview { pub fn new() -> Self { Self { + context_items: Vec::new(), pending_steers: Vec::new(), rejected_steers: Vec::new(), queued_messages: Vec::new(), @@ -69,7 +82,8 @@ impl PendingInputPreview { /// at `width`. Pulled out so `desired_height` can ask the same renderer /// without duplicating wrapping logic. fn lines(&self, width: u16) -> Vec> { - if (self.pending_steers.is_empty() + if (self.context_items.is_empty() + && self.pending_steers.is_empty() && self.rejected_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 @@ -84,7 +98,20 @@ impl PendingInputPreview { let mut lines: Vec> = Vec::new(); + if !self.context_items.is_empty() { + push_section_header( + &mut lines, + Line::from(vec![Span::raw("• "), Span::raw("Context for next send")]), + ); + for item in &self.context_items { + push_context_item(&mut lines, item, width); + } + } + if !self.pending_steers.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } push_section_header( &mut lines, Line::from(vec![ @@ -166,6 +193,34 @@ fn push_section_header(lines: &mut Vec>, header: Line<'static>) { lines.push(header); } +fn push_context_item(lines: &mut Vec>, item: &ContextPreviewItem, width: u16) { + let status_style = if item.included { + Style::default().fg(palette::TEXT_MUTED) + } else { + Style::default().fg(palette::STATUS_WARNING) + }; + let label_style = if item.included { + Style::default().fg(palette::TEXT_PRIMARY) + } else { + Style::default().fg(palette::TEXT_MUTED) + }; + let detail = item + .detail + .as_deref() + .filter(|detail| !detail.trim().is_empty()) + .map(|detail| format!(" · {detail}")) + .unwrap_or_default(); + let body = format!("[{}] {}{}", item.kind, item.label, detail); + let body_width = width.saturating_sub(4).max(1) as usize; + for (idx, segment) in wrap_to_width(&body, body_width).into_iter().enumerate() { + let prefix = if idx == 0 { " ↳ " } else { " " }; + lines.push(Line::from(vec![ + Span::styled(prefix.to_string(), status_style), + Span::styled(segment, label_style), + ])); + } +} + /// Render a single bucket item with `↳` prefix, truncating to /// [`PREVIEW_LINE_LIMIT`] visible rows. Multi-line input wraps at the given /// column budget and the continuation rows get the `subsequent_indent` so @@ -300,6 +355,27 @@ mod tests { assert!(rows[2].contains("edit last queued message")); } + #[test] + fn context_items_render_before_queue_buckets() { + let mut preview = PendingInputPreview::new(); + preview.context_items.push(ContextPreviewItem { + kind: "file".to_string(), + label: "src/main.rs".to_string(), + detail: Some("included".to_string()), + included: true, + }); + preview.context_items.push(ContextPreviewItem { + kind: "missing".to_string(), + label: "nope.txt".to_string(), + detail: Some("not found".to_string()), + included: false, + }); + let rows = render_to_string(&preview, 64); + assert!(rows[0].contains("Context for next send")); + assert!(rows[1].contains("[file] src/main.rs")); + assert!(rows[2].contains("[missing] nope.txt")); + } + #[test] fn pending_steer_shows_esc_hint_no_alt_up_hint() { let mut preview = PendingInputPreview::new(); diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs index 1594b169..3ae2e094 100644 --- a/crates/tui/src/tui/widgets/tool_card.rs +++ b/crates/tui/src/tui/widgets/tool_card.rs @@ -83,6 +83,49 @@ pub fn tool_family_for_name(name: &str) -> ToolFamily { } } +/// Build a compact semantic summary for a tool header from the public tool +/// name and the already-sanitized argument summary. +#[must_use] +pub fn tool_header_summary_for_name(name: &str, input_summary: Option<&str>) -> Option { + let summary = input_summary?.trim(); + if summary.is_empty() { + return None; + } + + let preferred_keys = match tool_family_for_name(name) { + ToolFamily::Read | ToolFamily::Patch => ["path", "file", "target", "content"].as_slice(), + ToolFamily::Run => ["command", "cmd", "script"].as_slice(), + ToolFamily::Find => ["query", "pattern", "path", "scope"].as_slice(), + ToolFamily::Delegate | ToolFamily::Fanout => ["prompt", "task", "model"].as_slice(), + ToolFamily::Think | ToolFamily::Generic => { + ["query", "path", "command", "prompt"].as_slice() + } + }; + + for key in preferred_keys { + if let Some(value) = summary_value(summary, key) { + return Some(value); + } + } + + Some(summary.to_string()) +} + +fn summary_value(summary: &str, key: &str) -> Option { + for part in summary.split(", ") { + let Some((part_key, value)) = part.split_once(':') else { + continue; + }; + if part_key.trim() == key { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None +} + /// The verb glyph for a family. Single grapheme so the header layout math /// in `render_tool_header` stays simple (one cell wide). #[must_use] @@ -148,7 +191,7 @@ pub fn rail_glyph(rail: CardRail) -> &'static str { mod tests { use super::{ CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_family_for_name, - tool_family_for_title, + tool_family_for_title, tool_header_summary_for_name, }; #[test] @@ -177,6 +220,29 @@ mod tests { ); } + #[test] + fn tool_header_summary_prefers_family_specific_arguments() { + assert_eq!( + tool_header_summary_for_name("read_file", Some("path: src/main.rs, limit: 20")) + .as_deref(), + Some("src/main.rs") + ); + assert_eq!( + tool_header_summary_for_name("exec_shell", Some("command: cargo test, cwd: /repo")) + .as_deref(), + Some("cargo test") + ); + assert_eq!( + tool_header_summary_for_name("grep_files", Some("pattern: TODO, path: crates")) + .as_deref(), + Some("TODO") + ); + assert_eq!( + tool_header_summary_for_name("unknown", Some("alpha: beta")).as_deref(), + Some("alpha: beta") + ); + } + #[test] fn each_family_has_a_glyph_and_label() { // Smoke test — surface accidental empties from a future refactor.