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 CHANGED
@@ -60,7 +60,7 @@ dependencies = [
60
60
 
61
61
  [[package]]
62
62
  name = "anveesa"
63
- version = "0.5.0"
63
+ version = "0.5.2"
64
64
  dependencies = [
65
65
  "anyhow",
66
66
  "base64",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "anveesa"
3
- version = "0.5.0"
3
+ version = "0.5.2"
4
4
  edition = "2024"
5
5
  default-run = "anveesa"
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anveesa",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "A terminal CLI that wraps AI providers (OpenAI-compatible APIs and local CLIs) into a single unified command",
5
5
  "main": "bin/anveesa.js",
6
6
  "bin": {
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 a URL and return it as plain text. Strips HTML tags automatically.",
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
- "max_chars": { "type": "integer", "description": "Max characters to return (default 40000)." }
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 max_chars = args.max_chars.unwrap_or(40_000);
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
- let char_count = text.chars().count();
1060
- let truncated = char_count > max_chars;
1061
- let text: String = text.chars().take(max_chars).collect();
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
- Ok(json!({
1064
- "ok": true,
1065
- "url": url,
1066
- "content_type": content_type,
1067
- "text": text,
1068
- "truncated": truncated,
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
- app.messages.push(Msg::FileOp { verb, path, added, removed, diff });
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 &app.messages {
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(" 📄 ", Style::default().fg(Color::Rgb(229, 192, 123))),
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
- // Show inline diff (up to 40 lines)
1213
- for (is_add, line) in diff.iter().take(40) {
1214
- let (prefix, color) = if *is_add {
1215
- (" + ", Color::Rgb(152, 195, 121))
1216
- } else {
1217
- (" - ", Color::Rgb(224, 108, 117))
1218
- };
1219
- let bg = if *is_add { Color::Rgb(20, 35, 20) } else { Color::Rgb(35, 20, 20) };
1220
- lines.push(Line::from(Span::styled(
1221
- format!("{prefix}{}", &line.trim_end().chars().take(width.saturating_sub(6)).collect::<String>()),
1222
- Style::default().fg(color).bg(bg),
1223
- )));
1224
- }
1225
- if diff.len() > 40 {
1226
- lines.push(Line::from(Span::styled(
1227
- format!(" … {} more lines", diff.len() - 40),
1228
- Style::default().fg(Color::DarkGray),
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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];