- 1
//! Plain-text formatting helpers for the headless CLI's non-interactive - 2
//! output (tool-call summaries, unified diffs, ANSI stripping). - 3
//! - 4
//! These are adapted from the now-removed `vak-tui` crate, keeping only the - 5
//! plain (non-ANSI, non-interactive) code paths — no crossterm/ratatui - 6
//! rendering, keymap, palette, editor, or event loop involved. - 7
- 8
use similar::{ChangeTag, TextDiff}; - 9
- 10
/// Summarize a tool call's JSON args into a short one-line hint for the CLI's - 11
/// progress output (e.g. `▸ bash echo hi`). - 12
pub fn summarize_args(name: &str, args_json: &str) -> String { - 13
let Ok(v) = serde_json::from_str::<serde_json::Value>(args_json) else { - 14
return trunc_cells(args_json, 60); - 15
}; - 16
let pick = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); - 17
let hint = match name { - 18
"bash" => pick("command"), - 19
"read" | "write" | "edit" => pick("path"), - 20
"glob" => pick("pattern"), - 21
"webfetch" => pick("url"), - 22
"grep" => { - 23
let base = v.get("path").and_then(|x| x.as_str()).unwrap_or("."); - 24
format!("{} in {base}", pick("pattern")) - 25
} - 26
_ => v.to_string(), - 27
}; - 28
trunc_cells(&hint, 90) - 29
} - 30
- 31
/// Truncate `s` to at most `max` display columns (accounting for - 32
/// double-width characters), appending an ellipsis when truncated. - 33
pub fn trunc_cells(s: &str, max: usize) -> String { - 34
let mut w = 0usize; - 35
for (i, c) in s.char_indices() { - 36
w += char_width(c); - 37
if w > max { - 38
return format!("{}…", &s[..i]); - 39
} - 40
} - 41
s.to_string() - 42
} - 43
- 44
fn char_width(c: char) -> usize { - 45
let cp = u32::from(c); - 46
if cp < 0x20 || (0x7F..0xA0).contains(&cp) { - 47
return 0; - 48
} - 49
if is_zero_width(cp) { - 50
return 0; - 51
} - 52
if is_wide(cp) { 2 } else { 1 } - 53
} - 54
- 55
fn is_zero_width(cp: u32) -> bool { - 56
matches!( - 57
cp, - 58
0x0300..=0x036F - 59
| 0x0483..=0x0489 - 60
| 0x0591..=0x05BD - 61
| 0x05BF - 62
| 0x05C1..=0x05C2 - 63
| 0x05C4..=0x05C5 - 64
| 0x05C7 - 65
| 0x0610..=0x061A - 66
| 0x064B..=0x065F - 67
| 0x0670 - 68
| 0x06D6..=0x06DC - 69
| 0x06DF..=0x06E4 - 70
| 0x06E7..=0x06E8 - 71
| 0x06EA..=0x06ED - 72
| 0x0711 - 73
| 0x0730..=0x074A - 74
| 0x07A6..=0x07B0 - 75
| 0x0816..=0x0819 - 76
| 0x081B..=0x0823 - 77
| 0x0825..=0x0827 - 78
| 0x0829..=0x082D - 79
| 0x0E31 - 80
| 0x0E34..=0x0E3A - 81
| 0x0E47..=0x0E4E - 82
| 0x135D..=0x135F - 83
| 0x1AB0..=0x1AFF - 84
| 0x1DC0..=0x1DFF - 85
| 0x200B..=0x200F - 86
| 0x202A..=0x202E - 87
| 0x2060..=0x2064 - 88
| 0x206A..=0x206F - 89
| 0x20D0..=0x20F0 - 90
| 0xFE00..=0xFE0F - 91
| 0xFE20..=0xFE2F - 92
| 0xFEFF - 93
| 0xE0100..=0xE01EF - 94
) - 95
} - 96
- 97
fn is_wide(cp: u32) -> bool { - 98
matches!( - 99
cp, - 100
0x1100..=0x115F - 101
| 0x2329..=0x232A - 102
| 0x2600..=0x27BF - 103
| 0x2E80..=0x303E - 104
| 0x3041..=0x33FF - 105
| 0x3400..=0x4DBF - 106
| 0x4E00..=0x9FFF - 107
| 0xA000..=0xA4CF - 108
| 0xA960..=0xA97F - 109
| 0xAC00..=0xD7A3 - 110
| 0xF900..=0xFAFF - 111
| 0xFE10..=0xFE19 - 112
| 0xFE30..=0xFE6F - 113
| 0xFF00..=0xFF60 - 114
| 0xFFE0..=0xFFE6 - 115
| 0x1B000..=0x1B2FF - 116
| 0x1F000..=0x1FFFD - 117
| 0x20000..=0x3FFFD - 118
) - 119
} - 120
- 121
/// Render a plain (no ANSI) unified diff for the `edit` tool's args JSON. - 122
/// Mirrors `vak_tui::app::edit_diff_text` but skips styling entirely since - 123
/// the CLI's plain output has no use for it. - 124
pub fn edit_diff_text(args_json: &str, max_lines: usize) -> Option<String> { - 125
let v: serde_json::Value = serde_json::from_str(args_json).ok()?; - 126
let pairs: Vec<(String, String)> = - 127
if let Some(edits) = v.get("edits").and_then(|e| e.as_array()) { - 128
edits - 129
.iter() - 130
.filter_map(|e| { - 131
let old = e.get("old_string").and_then(|x| x.as_str())?; - 132
let new = e.get("new_string").and_then(|x| x.as_str())?; - 133
Some((old.to_string(), new.to_string())) - 134
}) - 135
.collect() - 136
} else { - 137
let old = v.get("old_string").and_then(|x| x.as_str())?; - 138
let new = v.get("new_string").and_then(|x| x.as_str())?; - 139
vec![(old.to_string(), new.to_string())] - 140
}; - 141
let mut out = String::new(); - 142
let mut any = false; - 143
for (old, new) in pairs { - 144
if old.is_empty() && new.is_empty() { - 145
continue; - 146
} - 147
out.push_str(&unified_diff(&old, &new, max_lines)); - 148
out.push('\n'); - 149
any = true; - 150
} - 151
any.then_some(out) - 152
} - 153
- 154
/// Plain unified diff renderer, adapted from `vak_tui::diffview::unified` - 155
/// with ANSI styling stripped since the caller only wants plain text. - 156
fn unified_diff(old: &str, new: &str, max_lines: usize) -> String { - 157
let diff = TextDiff::from_lines(old, new); - 158
let mut lines: Vec<String> = Vec::new(); - 159
for group in diff.grouped_ops(3) { - 160
if group.is_empty() { - 161
continue; - 162
} - 163
let mut old_start = usize::MAX; - 164
let mut old_end = 0; - 165
let mut new_start = usize::MAX; - 166
let mut new_end = 0; - 167
for op in &group { - 168
let o = op.old_range(); - 169
let n = op.new_range(); - 170
old_start = old_start.min(o.start); - 171
old_end = old_end.max(o.end); - 172
new_start = new_start.min(n.start); - 173
new_end = new_end.max(n.end); - 174
} - 175
lines.push(format!( - 176
"@@ -{},{} +{},{} @@", - 177
old_start.saturating_add(1), - 178
old_end - old_start, - 179
new_start.saturating_add(1), - 180
new_end - new_start - 181
)); - 182
for op in &group { - 183
for change in diff.iter_changes(op) { - 184
let body = change.value().trim_end_matches(['\n', '\r']); - 185
lines.push(match change.tag() { - 186
ChangeTag::Equal => body.to_string(), - 187
ChangeTag::Delete => format!("- {body}"), - 188
ChangeTag::Insert => format!("+ {body}"), - 189
}); - 190
} - 191
} - 192
} - 193
render_capped(&lines, max_lines) - 194
} - 195
- 196
fn render_capped(lines: &[String], max_lines: usize) -> String { - 197
let mut out = String::new(); - 198
let keep = if lines.len() > max_lines { - 199
max_lines.saturating_sub(1) - 200
} else { - 201
lines.len() - 202
}; - 203
for line in &lines[..keep] { - 204
out.push_str(line); - 205
out.push('\n'); - 206
} - 207
if lines.len() > keep { - 208
out.push_str(&format!("… (+{} more)", lines.len() - keep)); - 209
} else if !out.is_empty() { - 210
out.truncate(out.len() - 1); - 211
} - 212
out - 213
} - 214
- 215
/// Strip ANSI escape sequences from a string (used when a tool's captured - 216
/// output includes color codes that plain CLI output should not show). - 217
pub fn strip_ansi(s: &str) -> String { - 218
let mut out = String::with_capacity(s.len()); - 219
let mut chars = s.chars().peekable(); - 220
while let Some(c) = chars.next() { - 221
if c == '\x1b' && chars.peek() == Some(&'[') { - 222
chars.next(); - 223
for c in chars.by_ref() { - 224
if c.is_ascii_alphabetic() { - 225
break; - 226
} - 227
} - 228
} else { - 229
out.push(c); - 230
} - 231
} - 232
out - 233
} - 234
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.