anveesa 0.5.1 → 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.1"
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.1"
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.1",
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/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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];