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 +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/tui.rs +182 -28
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|