anveesa 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/tools.rs +179 -20
- package/src/tui.rs +182 -28
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
package/src/tools.rs
CHANGED
|
@@ -258,12 +258,14 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
|
|
|
258
258
|
"type": "function",
|
|
259
259
|
"function": {
|
|
260
260
|
"name": "fetch_url",
|
|
261
|
-
"description": "Fetch the content of
|
|
261
|
+
"description": "Fetch a URL. mode=\"text\" (default): returns plain text with HTML tags stripped. mode=\"raw\": returns the full HTML source unchanged. mode=\"deep\": returns HTML source PLUS the full content of every linked CSS file (and JS bundles if include_js=true) in one call — use this when you need to inspect design tokens, Tailwind classes, color variables, font imports, or component structure without multiple round-trips.",
|
|
262
262
|
"parameters": {
|
|
263
263
|
"type": "object",
|
|
264
264
|
"properties": {
|
|
265
265
|
"url": { "type": "string", "description": "URL to fetch." },
|
|
266
|
-
"
|
|
266
|
+
"mode": { "type": "string", "description": "\"text\" (default, strips HTML), \"raw\" (full HTML source), \"deep\" (HTML source + fetch all linked CSS assets, and JS if include_js=true)." },
|
|
267
|
+
"max_chars": { "type": "integer", "description": "Max chars per resource (default 40000 for text, 60000 for raw/deep HTML, 30000 per asset)." },
|
|
268
|
+
"include_js": { "type": "boolean", "description": "deep mode only — also fetch linked JS bundles (default false; bundles can be large)." }
|
|
267
269
|
},
|
|
268
270
|
"required": ["url"]
|
|
269
271
|
}
|
|
@@ -987,6 +989,84 @@ fn scrape_ddg_html(html: &str, max: usize) -> Vec<Value> {
|
|
|
987
989
|
results
|
|
988
990
|
}
|
|
989
991
|
|
|
992
|
+
fn tag_attr(tag: &str, attr: &str) -> Option<String> {
|
|
993
|
+
let dq = format!("{attr}=\"");
|
|
994
|
+
let sq = format!("{attr}='");
|
|
995
|
+
if let Some(s) = tag.find(&dq) {
|
|
996
|
+
let start = s + dq.len();
|
|
997
|
+
tag[start..].find('"').map(|e| tag[start..start + e].to_string())
|
|
998
|
+
} else if let Some(s) = tag.find(&sq) {
|
|
999
|
+
let start = s + sq.len();
|
|
1000
|
+
tag[start..].find('\'').map(|e| tag[start..start + e].to_string())
|
|
1001
|
+
} else {
|
|
1002
|
+
None
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
fn url_origin(url: &str) -> String {
|
|
1007
|
+
let skip = if url.starts_with("https://") { 8 } else if url.starts_with("http://") { 7 } else { return String::new() };
|
|
1008
|
+
let scheme = &url[..skip - 3];
|
|
1009
|
+
let host = url[skip..].split('/').next().unwrap_or("");
|
|
1010
|
+
format!("{scheme}://{host}")
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
fn url_base_path(url: &str) -> String {
|
|
1014
|
+
let skip = if url.starts_with("https://") { 8 } else if url.starts_with("http://") { 7 } else { return "/".to_string() };
|
|
1015
|
+
let rest = &url[skip..];
|
|
1016
|
+
let path = rest.split_once('/').map(|(_, p)| format!("/{p}")).unwrap_or_default();
|
|
1017
|
+
path.rfind('/').map(|i| path[..i + 1].to_string()).unwrap_or_else(|| "/".to_string())
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
fn resolve_asset_url(href: &str, origin: &str, base_path: &str) -> Option<String> {
|
|
1021
|
+
let h = href.trim();
|
|
1022
|
+
if h.is_empty() { return None; }
|
|
1023
|
+
if h.starts_with("http://") || h.starts_with("https://") {
|
|
1024
|
+
Some(h.to_string())
|
|
1025
|
+
} else if h.starts_with("//") {
|
|
1026
|
+
let scheme = if origin.starts_with("https") { "https" } else { "http" };
|
|
1027
|
+
Some(format!("{scheme}:{h}"))
|
|
1028
|
+
} else if h.starts_with('/') {
|
|
1029
|
+
if origin.is_empty() { None } else { Some(format!("{origin}{h}")) }
|
|
1030
|
+
} else if !origin.is_empty() {
|
|
1031
|
+
Some(format!("{origin}{base_path}{h}"))
|
|
1032
|
+
} else {
|
|
1033
|
+
None
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
fn extract_asset_urls(html: &str, base_url: &str, include_js: bool) -> Vec<String> {
|
|
1038
|
+
let origin = url_origin(base_url);
|
|
1039
|
+
let base_path = url_base_path(base_url);
|
|
1040
|
+
let mut urls: Vec<String> = Vec::new();
|
|
1041
|
+
let mut pos = 0;
|
|
1042
|
+
|
|
1043
|
+
while pos < html.len() {
|
|
1044
|
+
let Some(lt) = html[pos..].find('<') else { break };
|
|
1045
|
+
let abs = pos + lt;
|
|
1046
|
+
let Some(gt) = html[abs..].find('>') else { break };
|
|
1047
|
+
let tag = &html[abs..abs + gt + 1];
|
|
1048
|
+
let tag_lo = tag.to_lowercase();
|
|
1049
|
+
pos = abs + gt + 1;
|
|
1050
|
+
|
|
1051
|
+
let href = if tag_lo.starts_with("<link") {
|
|
1052
|
+
let rel = tag_attr(&tag_lo, "rel").unwrap_or_default();
|
|
1053
|
+
let as_ = tag_attr(&tag_lo, "as").unwrap_or_default();
|
|
1054
|
+
if rel == "stylesheet" || (rel == "preload" && as_ == "style") {
|
|
1055
|
+
tag_attr(tag, "href").or_else(|| tag_attr(&tag_lo, "href"))
|
|
1056
|
+
} else { None }
|
|
1057
|
+
} else if include_js && tag_lo.starts_with("<script") {
|
|
1058
|
+
tag_attr(tag, "src").or_else(|| tag_attr(&tag_lo, "src"))
|
|
1059
|
+
} else { None };
|
|
1060
|
+
|
|
1061
|
+
if let Some(h) = href {
|
|
1062
|
+
if let Some(resolved) = resolve_asset_url(&h, &origin, &base_path) {
|
|
1063
|
+
if !urls.contains(&resolved) { urls.push(resolved); }
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
urls
|
|
1068
|
+
}
|
|
1069
|
+
|
|
990
1070
|
fn extract_attr<'a>(html: &'a str, attr: &str) -> Option<&'a str> {
|
|
991
1071
|
let key = format!("{attr}=\"");
|
|
992
1072
|
let start = html.find(&key)? + key.len();
|
|
@@ -1027,14 +1107,18 @@ async fn fetch_url(arguments: &str) -> Result<Value> {
|
|
|
1027
1107
|
url: String,
|
|
1028
1108
|
#[serde(default)]
|
|
1029
1109
|
max_chars: Option<usize>,
|
|
1110
|
+
#[serde(default)]
|
|
1111
|
+
mode: Option<String>,
|
|
1112
|
+
#[serde(default)]
|
|
1113
|
+
include_js: Option<bool>,
|
|
1030
1114
|
}
|
|
1031
1115
|
let args: Args = parse_args(arguments)?;
|
|
1032
|
-
let url = args.url.trim();
|
|
1116
|
+
let url = args.url.trim().to_string();
|
|
1033
1117
|
if url.is_empty() { bail!("url is required"); }
|
|
1034
|
-
let
|
|
1118
|
+
let mode = args.mode.as_deref().unwrap_or("text").to_string();
|
|
1035
1119
|
|
|
1036
1120
|
let response = http_client()
|
|
1037
|
-
.get(url)
|
|
1121
|
+
.get(&url)
|
|
1038
1122
|
.send()
|
|
1039
1123
|
.await
|
|
1040
1124
|
.with_context(|| format!("failed to fetch {url}"))?;
|
|
@@ -1050,23 +1134,98 @@ async fn fetch_url(arguments: &str) -> Result<Value> {
|
|
|
1050
1134
|
.to_string();
|
|
1051
1135
|
|
|
1052
1136
|
let body = response.text().await.context("failed to read response body")?;
|
|
1053
|
-
let text = if content_type.contains("html") || content_type.contains("xml") {
|
|
1054
|
-
html_to_text(&body)
|
|
1055
|
-
} else {
|
|
1056
|
-
body
|
|
1057
|
-
};
|
|
1058
1137
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1138
|
+
match mode.as_str() {
|
|
1139
|
+
"raw" => {
|
|
1140
|
+
let max = args.max_chars.unwrap_or(80_000);
|
|
1141
|
+
let char_count = body.chars().count();
|
|
1142
|
+
let truncated = char_count > max;
|
|
1143
|
+
let html: String = body.chars().take(max).collect();
|
|
1144
|
+
Ok(json!({
|
|
1145
|
+
"ok": true,
|
|
1146
|
+
"url": url,
|
|
1147
|
+
"content_type": content_type,
|
|
1148
|
+
"html": html,
|
|
1149
|
+
"char_count": char_count,
|
|
1150
|
+
"truncated": truncated,
|
|
1151
|
+
}))
|
|
1152
|
+
}
|
|
1153
|
+
"deep" => {
|
|
1154
|
+
const ASSET_MAX: usize = 30_000;
|
|
1155
|
+
const MAX_ASSETS: usize = 10;
|
|
1156
|
+
let html_max = args.max_chars.unwrap_or(60_000);
|
|
1157
|
+
let include_js = args.include_js.unwrap_or(false);
|
|
1158
|
+
|
|
1159
|
+
let asset_urls: Vec<String> = extract_asset_urls(&body, &url, include_js)
|
|
1160
|
+
.into_iter()
|
|
1161
|
+
.take(MAX_ASSETS)
|
|
1162
|
+
.collect();
|
|
1062
1163
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1164
|
+
let mut handles = Vec::new();
|
|
1165
|
+
for asset_url in asset_urls {
|
|
1166
|
+
handles.push(tokio::spawn(async move {
|
|
1167
|
+
let Ok(resp) = http_client().get(&asset_url).send().await else { return None; };
|
|
1168
|
+
if !resp.status().is_success() { return None; }
|
|
1169
|
+
let ct = resp.headers()
|
|
1170
|
+
.get("content-type")
|
|
1171
|
+
.and_then(|v| v.to_str().ok())
|
|
1172
|
+
.unwrap_or("")
|
|
1173
|
+
.to_string();
|
|
1174
|
+
let Ok(content) = resp.text().await else { return None; };
|
|
1175
|
+
let kind = if ct.contains("css") || asset_url.ends_with(".css") { "css" }
|
|
1176
|
+
else if ct.contains("javascript") || asset_url.contains(".js") { "js" }
|
|
1177
|
+
else { "other" };
|
|
1178
|
+
let char_count = content.chars().count();
|
|
1179
|
+
let truncated = char_count > ASSET_MAX;
|
|
1180
|
+
let trimmed: String = content.chars().take(ASSET_MAX).collect();
|
|
1181
|
+
Some(json!({
|
|
1182
|
+
"url": asset_url,
|
|
1183
|
+
"type": kind,
|
|
1184
|
+
"char_count": char_count,
|
|
1185
|
+
"truncated": truncated,
|
|
1186
|
+
"content": trimmed,
|
|
1187
|
+
}))
|
|
1188
|
+
}));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
let mut assets: Vec<Value> = Vec::new();
|
|
1192
|
+
for h in handles {
|
|
1193
|
+
if let Ok(Some(a)) = h.await { assets.push(a); }
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
let html_chars = body.chars().count();
|
|
1197
|
+
let html_truncated = html_chars > html_max;
|
|
1198
|
+
let html: String = body.chars().take(html_max).collect();
|
|
1199
|
+
|
|
1200
|
+
Ok(json!({
|
|
1201
|
+
"ok": true,
|
|
1202
|
+
"url": url,
|
|
1203
|
+
"html": html,
|
|
1204
|
+
"html_chars": html_chars,
|
|
1205
|
+
"html_truncated": html_truncated,
|
|
1206
|
+
"assets": assets,
|
|
1207
|
+
}))
|
|
1208
|
+
}
|
|
1209
|
+
_ => {
|
|
1210
|
+
// "text" mode — current behaviour
|
|
1211
|
+
let max = args.max_chars.unwrap_or(40_000);
|
|
1212
|
+
let text = if content_type.contains("html") || content_type.contains("xml") {
|
|
1213
|
+
html_to_text(&body)
|
|
1214
|
+
} else {
|
|
1215
|
+
body
|
|
1216
|
+
};
|
|
1217
|
+
let char_count = text.chars().count();
|
|
1218
|
+
let truncated = char_count > max;
|
|
1219
|
+
let text: String = text.chars().take(max).collect();
|
|
1220
|
+
Ok(json!({
|
|
1221
|
+
"ok": true,
|
|
1222
|
+
"url": url,
|
|
1223
|
+
"content_type": content_type,
|
|
1224
|
+
"text": text,
|
|
1225
|
+
"truncated": truncated,
|
|
1226
|
+
}))
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1070
1229
|
}
|
|
1071
1230
|
|
|
1072
1231
|
fn html_to_text(html: &str) -> String {
|
package/src/tui.rs
CHANGED
|
@@ -46,7 +46,7 @@ enum Msg {
|
|
|
46
46
|
User { text: String },
|
|
47
47
|
Assistant { text: String },
|
|
48
48
|
Tool { done: bool, ok: bool, text: String, elapsed_ms: Option<u128> },
|
|
49
|
-
FileOp { verb: String, path: String, added: usize, removed: usize, diff: Vec<(bool, String)
|
|
49
|
+
FileOp { verb: String, path: String, added: usize, removed: usize, diff: Vec<(bool, String)>, collapsed: bool },
|
|
50
50
|
Error(String),
|
|
51
51
|
System(String),
|
|
52
52
|
Separator, // thin line between turns — "AI is done, your turn"
|
|
@@ -114,6 +114,13 @@ pub struct App {
|
|
|
114
114
|
session_cost_usd: f64,
|
|
115
115
|
cwd: String,
|
|
116
116
|
|
|
117
|
+
// message navigation / collapsing
|
|
118
|
+
msg_focus: Option<usize>,
|
|
119
|
+
msg_line_offsets: Vec<usize>,
|
|
120
|
+
|
|
121
|
+
// tab completion state: (original input, candidates, current index)
|
|
122
|
+
tab_state: Option<(String, Vec<String>, usize)>,
|
|
123
|
+
|
|
117
124
|
// mode
|
|
118
125
|
mode: Mode,
|
|
119
126
|
confirm: Option<PendingConfirm>,
|
|
@@ -200,6 +207,10 @@ impl App {
|
|
|
200
207
|
session_cost_usd: 0.0,
|
|
201
208
|
cwd,
|
|
202
209
|
|
|
210
|
+
msg_focus: None,
|
|
211
|
+
msg_line_offsets: Vec::new(),
|
|
212
|
+
tab_state: None,
|
|
213
|
+
|
|
203
214
|
mode: Mode::Input,
|
|
204
215
|
confirm: None,
|
|
205
216
|
mouse_capture: true,
|
|
@@ -393,11 +404,50 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
|
|
|
393
404
|
app.input_cursor += 1;
|
|
394
405
|
app.hist_idx = None;
|
|
395
406
|
}
|
|
407
|
+
KeyCode::Tab => {
|
|
408
|
+
tab_complete(app);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
KeyCode::Char('[') if app.input.is_empty() => {
|
|
412
|
+
let cur = app.msg_focus.unwrap_or(app.messages.len());
|
|
413
|
+
let prev = app.messages[..cur].iter().rposition(|m| matches!(m, Msg::FileOp { .. }));
|
|
414
|
+
if let Some(idx) = prev {
|
|
415
|
+
app.msg_focus = Some(idx);
|
|
416
|
+
app.auto_scroll = false;
|
|
417
|
+
if let Some(&off) = app.msg_line_offsets.get(idx) {
|
|
418
|
+
app.scroll = off.saturating_sub(2);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
KeyCode::Char(']') if app.input.is_empty() => {
|
|
423
|
+
let start = app.msg_focus.map(|i| i + 1).unwrap_or(0);
|
|
424
|
+
let next = app.messages[start..].iter().position(|m| matches!(m, Msg::FileOp { .. })).map(|i| start + i);
|
|
425
|
+
if let Some(idx) = next {
|
|
426
|
+
app.msg_focus = Some(idx);
|
|
427
|
+
app.auto_scroll = false;
|
|
428
|
+
if let Some(&off) = app.msg_line_offsets.get(idx) {
|
|
429
|
+
app.scroll = off.saturating_sub(2);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
KeyCode::Esc if app.msg_focus.is_some() => {
|
|
434
|
+
app.msg_focus = None;
|
|
435
|
+
}
|
|
436
|
+
|
|
396
437
|
KeyCode::Enter => {
|
|
438
|
+
// Toggle collapse on focused FileOp if one is selected
|
|
439
|
+
if let Some(idx) = app.msg_focus {
|
|
440
|
+
if let Some(Msg::FileOp { collapsed, .. }) = app.messages.get_mut(idx) {
|
|
441
|
+
*collapsed = !*collapsed;
|
|
442
|
+
return Ok(());
|
|
443
|
+
}
|
|
444
|
+
}
|
|
397
445
|
let text = app.input.trim().to_string();
|
|
398
446
|
if text.is_empty() {
|
|
399
447
|
return Ok(());
|
|
400
448
|
}
|
|
449
|
+
app.msg_focus = None;
|
|
450
|
+
app.tab_state = None;
|
|
401
451
|
if !handle_slash_command(app, &text) {
|
|
402
452
|
submit_prompt(app, text).await?;
|
|
403
453
|
}
|
|
@@ -459,6 +509,7 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
|
|
|
459
509
|
app.input.drain(start..app.input_cursor);
|
|
460
510
|
app.input_cursor = start;
|
|
461
511
|
app.hist_idx = None;
|
|
512
|
+
app.tab_state = None;
|
|
462
513
|
}
|
|
463
514
|
}
|
|
464
515
|
KeyCode::Delete => {
|
|
@@ -466,6 +517,7 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
|
|
|
466
517
|
let len = next_char_len(&app.input, app.input_cursor);
|
|
467
518
|
app.input.drain(app.input_cursor..app.input_cursor + len);
|
|
468
519
|
app.hist_idx = None;
|
|
520
|
+
app.tab_state = None;
|
|
469
521
|
}
|
|
470
522
|
}
|
|
471
523
|
|
|
@@ -536,6 +588,8 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
|
|
|
536
588
|
app.input.insert_str(app.input_cursor, &s);
|
|
537
589
|
app.input_cursor += s.len();
|
|
538
590
|
app.hist_idx = None;
|
|
591
|
+
app.msg_focus = None;
|
|
592
|
+
app.tab_state = None;
|
|
539
593
|
}
|
|
540
594
|
|
|
541
595
|
_ => {}
|
|
@@ -577,6 +631,8 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
577
631
|
/model [name] · /provider [name] · /status · /exit\n\
|
|
578
632
|
\n\
|
|
579
633
|
Keys: ↑/↓ history ←/→ cursor Home/End Shift+Enter newline\n\
|
|
634
|
+
Tab complete /command or file path (press again to cycle)\n\
|
|
635
|
+
[ ] navigate between file diffs Enter expand/collapse focused diff\n\
|
|
580
636
|
j/k scroll (when input empty) PageUp/Dn scroll\n\
|
|
581
637
|
Ctrl+V paste (image or text) Ctrl+M scroll/select mode\n\
|
|
582
638
|
Ctrl+W delete-word Ctrl+U clear line\n\
|
|
@@ -728,6 +784,87 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
728
784
|
}
|
|
729
785
|
}
|
|
730
786
|
|
|
787
|
+
// ── Tab completion ────────────────────────────────────────────────────────────
|
|
788
|
+
|
|
789
|
+
const SLASH_COMMANDS: &[&str] = &[
|
|
790
|
+
"/clear", "/compact", "/copy", "/exit", "/export",
|
|
791
|
+
"/help", "/model", "/provider", "/quit", "/status", "/undo",
|
|
792
|
+
];
|
|
793
|
+
|
|
794
|
+
fn tab_complete(app: &mut App) {
|
|
795
|
+
let input = app.input.clone();
|
|
796
|
+
|
|
797
|
+
// If we're still on the same completion cycle, advance the index
|
|
798
|
+
let continuing = app.tab_state.as_ref().map(|(_, cands, idx)| {
|
|
799
|
+
cands.get(*idx).map(|s| s == &input).unwrap_or(false)
|
|
800
|
+
}).unwrap_or(false);
|
|
801
|
+
|
|
802
|
+
if continuing {
|
|
803
|
+
if let Some((_, cands, idx)) = &mut app.tab_state {
|
|
804
|
+
*idx = (*idx + 1) % cands.len();
|
|
805
|
+
let next = cands[*idx].clone();
|
|
806
|
+
app.input = next;
|
|
807
|
+
app.input_cursor = app.input.len();
|
|
808
|
+
}
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
let cands = compute_tab_completions(&input, &app.cwd);
|
|
813
|
+
if cands.is_empty() { return; }
|
|
814
|
+
|
|
815
|
+
app.input = cands[0].clone();
|
|
816
|
+
app.input_cursor = app.input.len();
|
|
817
|
+
app.tab_state = Some((input, cands, 0));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
fn compute_tab_completions(input: &str, cwd: &str) -> Vec<String> {
|
|
821
|
+
// Slash command completion: /pro → /provider, /model, …
|
|
822
|
+
if input.starts_with('/') && !input.contains(' ') {
|
|
823
|
+
let matches: Vec<String> = SLASH_COMMANDS.iter()
|
|
824
|
+
.filter(|c| c.starts_with(input))
|
|
825
|
+
.map(|s| s.to_string())
|
|
826
|
+
.collect();
|
|
827
|
+
if !matches.is_empty() { return matches; }
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// File path completion after "/export <path>"
|
|
831
|
+
if let Some(partial) = input.strip_prefix("/export ") {
|
|
832
|
+
let paths = tab_complete_path(partial, cwd);
|
|
833
|
+
return paths.into_iter().map(|p| format!("/export {p}")).collect();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
vec![]
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
fn tab_complete_path(partial: &str, cwd: &str) -> Vec<String> {
|
|
840
|
+
let (dir_part, file_part) = if let Some(i) = partial.rfind('/') {
|
|
841
|
+
(&partial[..i + 1], &partial[i + 1..])
|
|
842
|
+
} else {
|
|
843
|
+
("", partial)
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
let search_dir = if dir_part.is_empty() {
|
|
847
|
+
std::path::PathBuf::from(cwd)
|
|
848
|
+
} else if dir_part.starts_with('/') {
|
|
849
|
+
std::path::PathBuf::from(dir_part)
|
|
850
|
+
} else {
|
|
851
|
+
std::path::Path::new(cwd).join(dir_part)
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
let Ok(entries) = std::fs::read_dir(&search_dir) else { return vec![]; };
|
|
855
|
+
let mut out: Vec<String> = entries
|
|
856
|
+
.filter_map(|e| e.ok())
|
|
857
|
+
.filter_map(|e| {
|
|
858
|
+
let name = e.file_name().into_string().ok()?;
|
|
859
|
+
if !name.starts_with(file_part) { return None; }
|
|
860
|
+
let trail = if e.file_type().map(|t| t.is_dir()).unwrap_or(false) { "/" } else { "" };
|
|
861
|
+
Some(format!("{dir_part}{name}{trail}"))
|
|
862
|
+
})
|
|
863
|
+
.collect();
|
|
864
|
+
out.sort();
|
|
865
|
+
out
|
|
866
|
+
}
|
|
867
|
+
|
|
731
868
|
async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
|
|
732
869
|
// Save to input history
|
|
733
870
|
if app.input_history.last().map(|s| s.as_str()) != Some(&text) {
|
|
@@ -874,11 +1011,11 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
|
|
|
874
1011
|
TuiEvent::FileOp { verb, path, added, removed, diff } => {
|
|
875
1012
|
flush_streaming_buf(app);
|
|
876
1013
|
commit_pending_tool(app, true);
|
|
877
|
-
// Snapshot for /undo (read current content before the write is reflected in messages)
|
|
878
1014
|
let old_content = std::fs::read_to_string(&path).ok();
|
|
879
1015
|
if app.undo_stack.len() >= 20 { app.undo_stack.remove(0); }
|
|
880
1016
|
app.undo_stack.push((path.clone(), old_content));
|
|
881
|
-
|
|
1017
|
+
let collapsed = diff.len() > 8;
|
|
1018
|
+
app.messages.push(Msg::FileOp { verb, path, added, removed, diff, collapsed });
|
|
882
1019
|
}
|
|
883
1020
|
TuiEvent::Confirm { summary, diff, reply } => {
|
|
884
1021
|
flush_streaming_buf(app);
|
|
@@ -1165,8 +1302,12 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
1165
1302
|
fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
1166
1303
|
let width = area.width.saturating_sub(4) as usize;
|
|
1167
1304
|
let mut lines: Vec<Line<'static>> = vec![Line::from("")];
|
|
1305
|
+
let mut msg_offsets: Vec<usize> = Vec::with_capacity(app.messages.len());
|
|
1168
1306
|
|
|
1169
|
-
for msg in
|
|
1307
|
+
for (msg_idx, msg) in app.messages.iter().enumerate() {
|
|
1308
|
+
msg_offsets.push(lines.len());
|
|
1309
|
+
let focused = app.msg_focus == Some(msg_idx);
|
|
1310
|
+
let _ = focused; // used below in FileOp branch
|
|
1170
1311
|
match msg {
|
|
1171
1312
|
Msg::User { text } => {
|
|
1172
1313
|
lines.push(user_header());
|
|
@@ -1201,32 +1342,43 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
1201
1342
|
]));
|
|
1202
1343
|
lines.push(Line::from(""));
|
|
1203
1344
|
}
|
|
1204
|
-
Msg::FileOp { verb, path, added, removed, diff } => {
|
|
1345
|
+
Msg::FileOp { verb, path, added, removed, diff, collapsed } => {
|
|
1346
|
+
let focus_icon = if focused { "►" } else { " " };
|
|
1347
|
+
let header_bg = if focused { Color::Rgb(25, 25, 50) } else { Color::Reset };
|
|
1348
|
+
let toggle_hint = if *collapsed {
|
|
1349
|
+
format!(" [▶ {} lines]", diff.len())
|
|
1350
|
+
} else if diff.len() > 8 {
|
|
1351
|
+
" [▼ collapse]".to_string()
|
|
1352
|
+
} else {
|
|
1353
|
+
String::new()
|
|
1354
|
+
};
|
|
1205
1355
|
lines.push(Line::from(vec![
|
|
1206
|
-
Span::styled("
|
|
1207
|
-
Span::styled(format!("{verb} "), Style::default().fg(Color::White)),
|
|
1208
|
-
Span::styled(path.clone(), Style::default().fg(Color::Rgb(97, 175, 239))),
|
|
1209
|
-
Span::styled(format!(" +{added}"), Style::default().fg(Color::Rgb(152, 195, 121))),
|
|
1210
|
-
Span::styled(format!(" -{removed}"), Style::default().fg(Color::Rgb(224, 108, 117))),
|
|
1356
|
+
Span::styled(format!(" {focus_icon}📄 "), Style::default().fg(Color::Rgb(229, 192, 123)).bg(header_bg)),
|
|
1357
|
+
Span::styled(format!("{verb} "), Style::default().fg(Color::White).bg(header_bg)),
|
|
1358
|
+
Span::styled(path.clone(), Style::default().fg(Color::Rgb(97, 175, 239)).bg(header_bg)),
|
|
1359
|
+
Span::styled(format!(" +{added}"), Style::default().fg(Color::Rgb(152, 195, 121)).bg(header_bg)),
|
|
1360
|
+
Span::styled(format!(" -{removed}"), Style::default().fg(Color::Rgb(224, 108, 117)).bg(header_bg)),
|
|
1361
|
+
Span::styled(toggle_hint, Style::default().fg(Color::Rgb(80, 80, 100)).bg(header_bg)),
|
|
1211
1362
|
]));
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1363
|
+
if !collapsed {
|
|
1364
|
+
for (is_add, line) in diff.iter().take(40) {
|
|
1365
|
+
let (prefix, color) = if *is_add {
|
|
1366
|
+
(" + ", Color::Rgb(152, 195, 121))
|
|
1367
|
+
} else {
|
|
1368
|
+
(" - ", Color::Rgb(224, 108, 117))
|
|
1369
|
+
};
|
|
1370
|
+
let bg = if *is_add { Color::Rgb(20, 35, 20) } else { Color::Rgb(35, 20, 20) };
|
|
1371
|
+
lines.push(Line::from(Span::styled(
|
|
1372
|
+
format!("{prefix}{}", &line.trim_end().chars().take(width.saturating_sub(6)).collect::<String>()),
|
|
1373
|
+
Style::default().fg(color).bg(bg),
|
|
1374
|
+
)));
|
|
1375
|
+
}
|
|
1376
|
+
if diff.len() > 40 {
|
|
1377
|
+
lines.push(Line::from(Span::styled(
|
|
1378
|
+
format!(" … {} more lines", diff.len() - 40),
|
|
1379
|
+
Style::default().fg(Color::DarkGray),
|
|
1380
|
+
)));
|
|
1381
|
+
}
|
|
1230
1382
|
}
|
|
1231
1383
|
lines.push(Line::from(""));
|
|
1232
1384
|
}
|
|
@@ -1258,6 +1410,8 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
1258
1410
|
}
|
|
1259
1411
|
}
|
|
1260
1412
|
|
|
1413
|
+
app.msg_line_offsets = msg_offsets;
|
|
1414
|
+
|
|
1261
1415
|
// Live pending tool (running, not yet committed) — animated with elapsed time
|
|
1262
1416
|
if let Some(tool) = &app.pending_tool {
|
|
1263
1417
|
let dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|