Polish tool cards and context previews (#151)
This commit is contained in:
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user