anveesa 0.5.1 → 0.5.3

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.1"
63
+ version = "0.5.3"
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.1"
3
+ version = "0.5.3"
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.1",
3
+ "version": "0.5.3",
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/lib.rs CHANGED
@@ -636,7 +636,7 @@ async fn ask_streaming(
636
636
  history: &[ChatMessage],
637
637
  workspace_context: Option<&str>,
638
638
  policy: ApprovalPolicy,
639
- image: Option<ImageAttachment>,
639
+ image: Option<ImageAttachment>, // single-image path kept for REPL compatibility
640
640
  mode: RenderMode,
641
641
  ) -> Result<TurnResult> {
642
642
  let provider_name = config
@@ -652,7 +652,7 @@ async fn ask_streaming(
652
652
  system: options.system.clone(),
653
653
  workspace_context: workspace_context.map(str::to_string),
654
654
  history: history.to_vec(),
655
- image,
655
+ images: image.into_iter().collect(),
656
656
  mcp: None, // REPL path: MCP not yet wired here
657
657
  };
658
658
 
@@ -49,8 +49,8 @@ pub struct PromptRequest {
49
49
  pub system: Option<String>,
50
50
  pub workspace_context: Option<String>,
51
51
  pub history: Vec<ChatMessage>,
52
- /// Optional image grabbed from the clipboard for the current turn only.
53
- pub image: Option<ImageAttachment>,
52
+ /// Images attached to the current turn (clipboard paste or explicit attach).
53
+ pub images: Vec<ImageAttachment>,
54
54
  /// Connected MCP servers (runtime only, not part of session history).
55
55
  pub mcp: Option<std::sync::Arc<crate::mcp::McpManager>>,
56
56
  }
@@ -822,13 +822,15 @@ fn build_messages(
822
822
  messages.push(json!({ "role": role, "content": message.content }));
823
823
  }
824
824
 
825
- // Current user turn — multimodal when a clipboard image is attached.
826
- let user_content = match &request.image {
827
- Some(img) => json!([
828
- { "type": "text", "text": &request.prompt },
829
- { "type": "image_url", "image_url": { "url": format!("data:{};base64,{}", img.mime, img.data) } }
830
- ]),
831
- None => json!(&request.prompt),
825
+ // Current user turn — multimodal when images are attached.
826
+ let user_content = if request.images.is_empty() {
827
+ json!(&request.prompt)
828
+ } else {
829
+ let mut parts = vec![json!({ "type": "text", "text": &request.prompt })];
830
+ for img in &request.images {
831
+ parts.push(json!({ "type": "image_url", "image_url": { "url": format!("data:{};base64,{}", img.mime, img.data) } }));
832
+ }
833
+ json!(parts)
832
834
  };
833
835
  messages.push(json!({ "role": "user", "content": user_content }));
834
836
 
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"
@@ -98,7 +98,7 @@ pub struct App {
98
98
  input_history: Vec<String>,
99
99
  hist_idx: Option<usize>,
100
100
  hist_saved: String,
101
- pending_image: Option<ImageAttachment>,
101
+ pending_images: Vec<ImageAttachment>,
102
102
  last_image_fp: Option<String>,
103
103
  images_available: bool,
104
104
 
@@ -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>,
@@ -186,7 +193,7 @@ impl App {
186
193
  input_history,
187
194
  hist_idx: None,
188
195
  hist_saved: String::new(),
189
- pending_image: None,
196
+ pending_images: Vec::new(),
190
197
  last_image_fp: None,
191
198
  images_available,
192
199
 
@@ -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,
@@ -313,11 +324,9 @@ async fn handle_event(app: &mut App, event: Event) -> Result<()> {
313
324
  Event::Paste(text) => {
314
325
  if app.mode != Mode::Input { return Ok(()); }
315
326
  if text.trim().is_empty() {
316
- // Empty paste = user pasted an image (terminal can't forward it as text)
317
- // Try to grab it directly from the clipboard
318
327
  if app.images_available {
319
328
  if let Some(img) = crate::grab_clipboard_image() {
320
- app.pending_image = Some(img);
329
+ app.pending_images.push(img);
321
330
  app.last_image_fp = None;
322
331
  return Ok(());
323
332
  }
@@ -327,6 +336,7 @@ async fn handle_event(app: &mut App, event: Event) -> Result<()> {
327
336
  app.input.insert_str(app.input_cursor, &normalized);
328
337
  app.input_cursor += normalized.len();
329
338
  app.hist_idx = None;
339
+ app.tab_state = None;
330
340
  }
331
341
  }
332
342
  Event::Resize(_, _) => {}
@@ -393,11 +403,50 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
393
403
  app.input_cursor += 1;
394
404
  app.hist_idx = None;
395
405
  }
406
+ KeyCode::Tab => {
407
+ tab_complete(app);
408
+ }
409
+
410
+ KeyCode::Char('[') if app.input.is_empty() => {
411
+ let cur = app.msg_focus.unwrap_or(app.messages.len());
412
+ let prev = app.messages[..cur].iter().rposition(|m| matches!(m, Msg::FileOp { .. }));
413
+ if let Some(idx) = prev {
414
+ app.msg_focus = Some(idx);
415
+ app.auto_scroll = false;
416
+ if let Some(&off) = app.msg_line_offsets.get(idx) {
417
+ app.scroll = off.saturating_sub(2);
418
+ }
419
+ }
420
+ }
421
+ KeyCode::Char(']') if app.input.is_empty() => {
422
+ let start = app.msg_focus.map(|i| i + 1).unwrap_or(0);
423
+ let next = app.messages[start..].iter().position(|m| matches!(m, Msg::FileOp { .. })).map(|i| start + i);
424
+ if let Some(idx) = next {
425
+ app.msg_focus = Some(idx);
426
+ app.auto_scroll = false;
427
+ if let Some(&off) = app.msg_line_offsets.get(idx) {
428
+ app.scroll = off.saturating_sub(2);
429
+ }
430
+ }
431
+ }
432
+ KeyCode::Esc if app.msg_focus.is_some() => {
433
+ app.msg_focus = None;
434
+ }
435
+
396
436
  KeyCode::Enter => {
437
+ // Toggle collapse on focused FileOp if one is selected
438
+ if let Some(idx) = app.msg_focus {
439
+ if let Some(Msg::FileOp { collapsed, .. }) = app.messages.get_mut(idx) {
440
+ *collapsed = !*collapsed;
441
+ return Ok(());
442
+ }
443
+ }
397
444
  let text = app.input.trim().to_string();
398
445
  if text.is_empty() {
399
446
  return Ok(());
400
447
  }
448
+ app.msg_focus = None;
449
+ app.tab_state = None;
401
450
  if !handle_slash_command(app, &text) {
402
451
  submit_prompt(app, text).await?;
403
452
  }
@@ -425,22 +474,25 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
425
474
  delete_word_before(&mut app.input, &mut app.input_cursor);
426
475
  app.hist_idx = None;
427
476
  }
428
- // Ctrl+V universal paste: image first, then clipboard text
429
- KeyCode::Char('v') if modifiers.contains(KeyModifiers::CONTROL) => {
477
+ // Ctrl+V (all platforms) or Cmd+V (macOS) — image first, then text
478
+ KeyCode::Char('v')
479
+ if modifiers.contains(KeyModifiers::CONTROL)
480
+ || (cfg!(target_os = "macos") && modifiers.contains(KeyModifiers::SUPER)) =>
481
+ {
430
482
  if app.images_available {
431
483
  if let Some(img) = crate::grab_clipboard_image() {
432
- app.pending_image = Some(img);
484
+ app.pending_images.push(img);
433
485
  app.last_image_fp = None;
434
486
  return Ok(());
435
487
  }
436
488
  }
437
- // No image — fall back to clipboard text
438
489
  if let Some(text) = crate::read_clipboard_text() {
439
490
  if !text.is_empty() {
440
491
  let normalized = text.replace('\r', "\n");
441
492
  app.input.insert_str(app.input_cursor, &normalized);
442
493
  app.input_cursor += normalized.len();
443
494
  app.hist_idx = None;
495
+ app.tab_state = None;
444
496
  }
445
497
  }
446
498
  }
@@ -459,6 +511,7 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
459
511
  app.input.drain(start..app.input_cursor);
460
512
  app.input_cursor = start;
461
513
  app.hist_idx = None;
514
+ app.tab_state = None;
462
515
  }
463
516
  }
464
517
  KeyCode::Delete => {
@@ -466,6 +519,7 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
466
519
  let len = next_char_len(&app.input, app.input_cursor);
467
520
  app.input.drain(app.input_cursor..app.input_cursor + len);
468
521
  app.hist_idx = None;
522
+ app.tab_state = None;
469
523
  }
470
524
  }
471
525
 
@@ -536,6 +590,8 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
536
590
  app.input.insert_str(app.input_cursor, &s);
537
591
  app.input_cursor += s.len();
538
592
  app.hist_idx = None;
593
+ app.msg_focus = None;
594
+ app.tab_state = None;
539
595
  }
540
596
 
541
597
  _ => {}
@@ -556,7 +612,7 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
556
612
  app.streaming_buf.clear();
557
613
  app.accumulated_response.clear();
558
614
  app.usage = Usage::default();
559
- app.pending_image = None;
615
+ app.pending_images.clear();
560
616
  app.seen_paths.clear();
561
617
  app.undo_stack.clear();
562
618
  app.input.clear();
@@ -577,8 +633,10 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
577
633
  /model [name] · /provider [name] · /status · /exit\n\
578
634
  \n\
579
635
  Keys: ↑/↓ history ←/→ cursor Home/End Shift+Enter newline\n\
636
+ Tab complete /command or file path (press again to cycle)\n\
637
+ [ ] navigate between file diffs Enter expand/collapse focused diff\n\
580
638
  j/k scroll (when input empty) PageUp/Dn scroll\n\
581
- Ctrl+V paste (image or text) Ctrl+M scroll/select mode\n\
639
+ ⌘V (macOS) / Ctrl+V paste image or text (repeat to queue multiple images)\n\
582
640
  Ctrl+W delete-word Ctrl+U clear line\n\
583
641
  \n\
584
642
  Search: set BRAVE_SEARCH_API_KEY or SERPER_API_KEY for better results".into(),
@@ -728,6 +786,87 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
728
786
  }
729
787
  }
730
788
 
789
+ // ── Tab completion ────────────────────────────────────────────────────────────
790
+
791
+ const SLASH_COMMANDS: &[&str] = &[
792
+ "/clear", "/compact", "/copy", "/exit", "/export",
793
+ "/help", "/model", "/provider", "/quit", "/status", "/undo",
794
+ ];
795
+
796
+ fn tab_complete(app: &mut App) {
797
+ let input = app.input.clone();
798
+
799
+ // If we're still on the same completion cycle, advance the index
800
+ let continuing = app.tab_state.as_ref().map(|(_, cands, idx)| {
801
+ cands.get(*idx).map(|s| s == &input).unwrap_or(false)
802
+ }).unwrap_or(false);
803
+
804
+ if continuing {
805
+ if let Some((_, cands, idx)) = &mut app.tab_state {
806
+ *idx = (*idx + 1) % cands.len();
807
+ let next = cands[*idx].clone();
808
+ app.input = next;
809
+ app.input_cursor = app.input.len();
810
+ }
811
+ return;
812
+ }
813
+
814
+ let cands = compute_tab_completions(&input, &app.cwd);
815
+ if cands.is_empty() { return; }
816
+
817
+ app.input = cands[0].clone();
818
+ app.input_cursor = app.input.len();
819
+ app.tab_state = Some((input, cands, 0));
820
+ }
821
+
822
+ fn compute_tab_completions(input: &str, cwd: &str) -> Vec<String> {
823
+ // Slash command completion: /pro → /provider, /model, …
824
+ if input.starts_with('/') && !input.contains(' ') {
825
+ let matches: Vec<String> = SLASH_COMMANDS.iter()
826
+ .filter(|c| c.starts_with(input))
827
+ .map(|s| s.to_string())
828
+ .collect();
829
+ if !matches.is_empty() { return matches; }
830
+ }
831
+
832
+ // File path completion after "/export <path>"
833
+ if let Some(partial) = input.strip_prefix("/export ") {
834
+ let paths = tab_complete_path(partial, cwd);
835
+ return paths.into_iter().map(|p| format!("/export {p}")).collect();
836
+ }
837
+
838
+ vec![]
839
+ }
840
+
841
+ fn tab_complete_path(partial: &str, cwd: &str) -> Vec<String> {
842
+ let (dir_part, file_part) = if let Some(i) = partial.rfind('/') {
843
+ (&partial[..i + 1], &partial[i + 1..])
844
+ } else {
845
+ ("", partial)
846
+ };
847
+
848
+ let search_dir = if dir_part.is_empty() {
849
+ std::path::PathBuf::from(cwd)
850
+ } else if dir_part.starts_with('/') {
851
+ std::path::PathBuf::from(dir_part)
852
+ } else {
853
+ std::path::Path::new(cwd).join(dir_part)
854
+ };
855
+
856
+ let Ok(entries) = std::fs::read_dir(&search_dir) else { return vec![]; };
857
+ let mut out: Vec<String> = entries
858
+ .filter_map(|e| e.ok())
859
+ .filter_map(|e| {
860
+ let name = e.file_name().into_string().ok()?;
861
+ if !name.starts_with(file_part) { return None; }
862
+ let trail = if e.file_type().map(|t| t.is_dir()).unwrap_or(false) { "/" } else { "" };
863
+ Some(format!("{dir_part}{name}{trail}"))
864
+ })
865
+ .collect();
866
+ out.sort();
867
+ out
868
+ }
869
+
731
870
  async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
732
871
  // Save to input history
733
872
  if app.input_history.last().map(|s| s.as_str()) != Some(&text) {
@@ -737,17 +876,24 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
737
876
  app.pending_prompt = text.clone();
738
877
  app.accumulated_response.clear();
739
878
 
740
- // Auto-attach clipboard image if nothing was explicitly Ctrl+V'd
741
- let image = app.pending_image.take().or_else(|| {
742
- if !app.images_available { return None; }
743
- let img = crate::grab_clipboard_image()?;
744
- let fp = crate::image_fingerprint(&img);
745
- if app.last_image_fp.as_deref() == Some(&fp) {
746
- return None; // same image as last time
879
+ // Collect explicitly pasted images; if none, auto-attach a new clipboard image once.
880
+ let images: Vec<crate::provider::ImageAttachment> = if !app.pending_images.is_empty() {
881
+ std::mem::take(&mut app.pending_images)
882
+ } else if app.images_available {
883
+ if let Some(img) = crate::grab_clipboard_image() {
884
+ let fp = crate::image_fingerprint(&img);
885
+ if app.last_image_fp.as_deref() == Some(&fp) {
886
+ vec![]
887
+ } else {
888
+ app.last_image_fp = Some(fp);
889
+ vec![img]
890
+ }
891
+ } else {
892
+ vec![]
747
893
  }
748
- app.last_image_fp = Some(fp);
749
- Some(img)
750
- });
894
+ } else {
895
+ vec![]
896
+ };
751
897
 
752
898
  app.messages.push(Msg::User { text: text.clone() });
753
899
  app.input.clear();
@@ -787,7 +933,7 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
787
933
  system: options.system.clone(),
788
934
  workspace_context,
789
935
  history,
790
- image,
936
+ images,
791
937
  mcp: mcp_arc,
792
938
  };
793
939
  let result = crate::provider::ask(&config, &provider_name, request, policy, &stream_tx_inner).await;
@@ -874,11 +1020,11 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
874
1020
  TuiEvent::FileOp { verb, path, added, removed, diff } => {
875
1021
  flush_streaming_buf(app);
876
1022
  commit_pending_tool(app, true);
877
- // Snapshot for /undo (read current content before the write is reflected in messages)
878
1023
  let old_content = std::fs::read_to_string(&path).ok();
879
1024
  if app.undo_stack.len() >= 20 { app.undo_stack.remove(0); }
880
1025
  app.undo_stack.push((path.clone(), old_content));
881
- app.messages.push(Msg::FileOp { verb, path, added, removed, diff });
1026
+ let collapsed = diff.len() > 8;
1027
+ app.messages.push(Msg::FileOp { verb, path, added, removed, diff, collapsed });
882
1028
  }
883
1029
  TuiEvent::Confirm { summary, diff, reply } => {
884
1030
  flush_streaming_buf(app);
@@ -1165,8 +1311,12 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App) {
1165
1311
  fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
1166
1312
  let width = area.width.saturating_sub(4) as usize;
1167
1313
  let mut lines: Vec<Line<'static>> = vec![Line::from("")];
1314
+ let mut msg_offsets: Vec<usize> = Vec::with_capacity(app.messages.len());
1168
1315
 
1169
- for msg in &app.messages {
1316
+ for (msg_idx, msg) in app.messages.iter().enumerate() {
1317
+ msg_offsets.push(lines.len());
1318
+ let focused = app.msg_focus == Some(msg_idx);
1319
+ let _ = focused; // used below in FileOp branch
1170
1320
  match msg {
1171
1321
  Msg::User { text } => {
1172
1322
  lines.push(user_header());
@@ -1201,32 +1351,43 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
1201
1351
  ]));
1202
1352
  lines.push(Line::from(""));
1203
1353
  }
1204
- Msg::FileOp { verb, path, added, removed, diff } => {
1354
+ Msg::FileOp { verb, path, added, removed, diff, collapsed } => {
1355
+ let focus_icon = if focused { "►" } else { " " };
1356
+ let header_bg = if focused { Color::Rgb(25, 25, 50) } else { Color::Reset };
1357
+ let toggle_hint = if *collapsed {
1358
+ format!(" [▶ {} lines]", diff.len())
1359
+ } else if diff.len() > 8 {
1360
+ " [▼ collapse]".to_string()
1361
+ } else {
1362
+ String::new()
1363
+ };
1205
1364
  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))),
1365
+ Span::styled(format!(" {focus_icon}📄 "), Style::default().fg(Color::Rgb(229, 192, 123)).bg(header_bg)),
1366
+ Span::styled(format!("{verb} "), Style::default().fg(Color::White).bg(header_bg)),
1367
+ Span::styled(path.clone(), Style::default().fg(Color::Rgb(97, 175, 239)).bg(header_bg)),
1368
+ Span::styled(format!(" +{added}"), Style::default().fg(Color::Rgb(152, 195, 121)).bg(header_bg)),
1369
+ Span::styled(format!(" -{removed}"), Style::default().fg(Color::Rgb(224, 108, 117)).bg(header_bg)),
1370
+ Span::styled(toggle_hint, Style::default().fg(Color::Rgb(80, 80, 100)).bg(header_bg)),
1211
1371
  ]));
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
- )));
1372
+ if !collapsed {
1373
+ for (is_add, line) in diff.iter().take(40) {
1374
+ let (prefix, color) = if *is_add {
1375
+ (" + ", Color::Rgb(152, 195, 121))
1376
+ } else {
1377
+ (" - ", Color::Rgb(224, 108, 117))
1378
+ };
1379
+ let bg = if *is_add { Color::Rgb(20, 35, 20) } else { Color::Rgb(35, 20, 20) };
1380
+ lines.push(Line::from(Span::styled(
1381
+ format!("{prefix}{}", &line.trim_end().chars().take(width.saturating_sub(6)).collect::<String>()),
1382
+ Style::default().fg(color).bg(bg),
1383
+ )));
1384
+ }
1385
+ if diff.len() > 40 {
1386
+ lines.push(Line::from(Span::styled(
1387
+ format!(" … {} more lines", diff.len() - 40),
1388
+ Style::default().fg(Color::DarkGray),
1389
+ )));
1390
+ }
1230
1391
  }
1231
1392
  lines.push(Line::from(""));
1232
1393
  }
@@ -1258,6 +1419,8 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
1258
1419
  }
1259
1420
  }
1260
1421
 
1422
+ app.msg_line_offsets = msg_offsets;
1423
+
1261
1424
  // Live pending tool (running, not yet committed) — animated with elapsed time
1262
1425
  if let Some(tool) = &app.pending_tool {
1263
1426
  let dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -1371,10 +1534,9 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
1371
1534
  return;
1372
1535
  }
1373
1536
 
1374
- if app.input.is_empty() && app.pending_image.is_none() {
1375
- // Placeholder hint
1537
+ if app.input.is_empty() && app.pending_images.is_empty() {
1376
1538
  frame.render_widget(
1377
- Paragraph::new(" ❯ Ask anything… (↑/↓ history · Ctrl+V paste image)")
1539
+ Paragraph::new(" ❯ Ask anything… (↑/↓ history · V paste image)")
1378
1540
  .style(Style::default().fg(Color::Rgb(60, 60, 80))),
1379
1541
  inner,
1380
1542
  );
@@ -1382,7 +1544,11 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
1382
1544
  return;
1383
1545
  }
1384
1546
 
1385
- let label = if app.pending_image.is_some() { " [📎] ❯ " } else { " ❯ " };
1547
+ let label = match app.pending_images.len() {
1548
+ 0 => " ❯ ".to_string(),
1549
+ 1 => " [📎] ❯ ".to_string(),
1550
+ n => format!(" [📎 ×{n}] ❯ "),
1551
+ };
1386
1552
  let label_w = label.chars().count();
1387
1553
  let display = format!("{label}{}", app.input);
1388
1554