Polish tool cards and context previews (#151)

This commit is contained in:
Hunter Bown
2026-04-28 17:36:00 -05:00
committed by GitHub
parent 97846cd63a
commit d7b033d59e
7 changed files with 976 additions and 102 deletions
+234 -3
View File
@@ -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<Line<'static>> {
let mut lines = Vec::new();
let mut old_line: Option<usize> = None;
let mut new_line: Option<usize> = 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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
lines
}
#[must_use]
pub fn summarize_diff(diff: &str) -> Vec<DiffFileSummary> {
let mut summaries = Vec::new();
let mut current: Option<DiffFileSummary> = 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(|| "<file>".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: "<file>".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: "<file>".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: "<file>".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<String> {
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<String> {
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<Line<'static>> {
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<usize>,
new_line: Option<usize>,
marker: char,
style: Style,
) -> Vec<Line<'static>> {
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<usize>, new_line: Option<usize>) -> String {
fn format_line_numbers(old_line: Option<usize>, new_line: Option<usize>, marker: char) -> String {
let old = old_line
.map(|value| {
format!(
@@ -165,7 +329,7 @@ fn format_line_numbers(old_line: Option<usize>, new_line: Option<usize>) -> 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<Line<'static>> {
@@ -216,3 +380,70 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
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::<Vec<_>>();
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:?}"
);
}
}
+186
View File
@@ -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<String>,
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<PathBuf>,
) -> Vec<FileMentionPreview> {
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<MediaAttachmentReference> {
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:?}"
);
}
}
+369 -96
View File
@@ -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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<
lines
}
fn command_header_summary(command: &str) -> String {
command
.lines()
.next()
.unwrap_or(command)
.trim_start_matches("$ ")
.trim()
.to_string()
}
fn exploring_header_summary(entries: &[ExploringEntry]) -> Option<String> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<usize> = 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<OutputRow> {
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<usize> {
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<usize> {
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<Line<'static>>,
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<Line<'static>> {
@@ -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<Instant>,
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<Instant>,
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<Instant>,
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<String> {
let normalized = summary
.split_whitespace()
.collect::<Vec<_>>()
.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::<String>();
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::<Vec<_>>()
.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
+14 -1
View File
@@ -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()
+29
View File
@@ -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
@@ -49,15 +49,28 @@ impl EditBinding {
/// messages while a turn is in progress.
#[derive(Debug, Clone)]
pub struct PendingInputPreview {
pub context_items: Vec<ContextPreviewItem>,
pub pending_steers: Vec<String>,
pub rejected_steers: Vec<String>,
pub queued_messages: Vec<String>,
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<String>,
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<Line<'static>> {
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<Line<'static>> = 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<Line<'static>>, header: Line<'static>) {
lines.push(header);
}
fn push_context_item(lines: &mut Vec<Line<'static>>, 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();
+67 -1
View File
@@ -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<String> {
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<String> {
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.