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 +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/lib.rs +2 -2
- package/src/provider/mod.rs +2 -2
- package/src/provider/openai_compatible.rs +9 -7
- package/src/tui.rs +220 -54
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
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
|
|
package/src/provider/mod.rs
CHANGED
|
@@ -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
|
-
///
|
|
53
|
-
pub
|
|
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
|
|
826
|
-
let user_content =
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
429
|
-
KeyCode::Char('v')
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
741
|
-
let
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
let
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
749
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
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.
|
|
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 ·
|
|
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 =
|
|
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
|
|