anveesa 0.3.6 → 0.3.8

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/src/tui.rs ADDED
@@ -0,0 +1,1159 @@
1
+ use std::{path::PathBuf, time::Duration};
2
+
3
+ use anyhow::{Context, Result};
4
+ use crossterm::event::{
5
+ DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
6
+ MouseEvent, MouseEventKind,
7
+ };
8
+ use ratatui::{
9
+ DefaultTerminal, Frame,
10
+ layout::{Constraint, Layout, Rect},
11
+ style::{Color, Modifier, Style},
12
+ text::{Line, Span},
13
+ widgets::{Block, Borders, Paragraph, Wrap},
14
+ };
15
+ use tokio::sync::{mpsc, oneshot};
16
+
17
+ use crate::{
18
+ cli::AskOptions,
19
+ config::AppConfig,
20
+ provider::{
21
+ ApprovalDecision, ApprovalPolicy, ChatMessage, ChatRole, ImageAttachment, PromptRequest,
22
+ StreamEvent, ToolConfirmPreview, Usage,
23
+ },
24
+ };
25
+
26
+ // ── Public stream event type ──────────────────────────────────────────────────
27
+
28
+ pub enum TuiEvent {
29
+ Token(String),
30
+ Status(String),
31
+ ToolCall(String),
32
+ ToolDone { summary: String, ok: bool },
33
+ FileOp { verb: String, path: String, added: usize, removed: usize },
34
+ Confirm { summary: String, reply: oneshot::Sender<ApprovalDecision> },
35
+ Usage(Usage),
36
+ Error(String),
37
+ PlanSet(Vec<String>),
38
+ PlanTaskDone(usize),
39
+ }
40
+
41
+ // ── Display message types ─────────────────────────────────────────────────────
42
+
43
+ #[derive(Debug)]
44
+ enum Msg {
45
+ User { text: String },
46
+ Assistant { text: String },
47
+ Tool { icon: &'static str, text: String, ok: bool },
48
+ FileOp { verb: String, path: String, added: usize, removed: usize },
49
+ Error(String),
50
+ System(String),
51
+ }
52
+
53
+ #[derive(Debug)]
54
+ struct PendingConfirm {
55
+ summary: String,
56
+ reply: oneshot::Sender<ApprovalDecision>,
57
+ }
58
+
59
+ #[derive(Debug, PartialEq)]
60
+ enum Mode {
61
+ Input,
62
+ Streaming,
63
+ Confirming,
64
+ }
65
+
66
+ // ── Application state ─────────────────────────────────────────────────────────
67
+
68
+ pub struct App {
69
+ // conversation display
70
+ messages: Vec<Msg>,
71
+ streaming_buf: String,
72
+ accumulated_response: String, // full assistant text across tool calls
73
+ tool_status: String,
74
+ plan_tasks: Vec<String>,
75
+ plan_done: Vec<bool>,
76
+
77
+ // pending turn tracking
78
+ pending_prompt: String,
79
+
80
+ // input
81
+ input: String,
82
+ input_cursor: usize,
83
+ input_history: Vec<String>,
84
+ hist_idx: Option<usize>,
85
+ hist_saved: String,
86
+ pending_image: Option<ImageAttachment>,
87
+ last_image_fp: Option<String>,
88
+ images_available: bool,
89
+
90
+ // scroll
91
+ scroll: usize,
92
+ auto_scroll: bool,
93
+ total_lines: usize,
94
+
95
+ // status info
96
+ provider: String,
97
+ model: String,
98
+ usage: Usage,
99
+ cwd: String,
100
+
101
+ // mode
102
+ mode: Mode,
103
+ confirm: Option<PendingConfirm>,
104
+
105
+ // history & session
106
+ history: Vec<ChatMessage>,
107
+ session_path: Option<PathBuf>,
108
+ pub last_saved_at: u64,
109
+
110
+ // request params
111
+ pub config: AppConfig,
112
+ pub options: AskOptions,
113
+ pub workspace_context: Option<String>,
114
+ pub policy: ApprovalPolicy,
115
+
116
+ // channels
117
+ stream_rx: mpsc::UnboundedReceiver<TuiEvent>,
118
+ stream_tx: mpsc::UnboundedSender<TuiEvent>,
119
+ key_rx: mpsc::UnboundedReceiver<Event>,
120
+
121
+ quit: bool,
122
+ spinner_frame: usize,
123
+ }
124
+
125
+ impl App {
126
+ pub fn new(
127
+ provider: String,
128
+ model: String,
129
+ cwd: String,
130
+ history: Vec<ChatMessage>,
131
+ images_available: bool,
132
+ session_path: Option<PathBuf>,
133
+ last_saved_at: u64,
134
+ input_history: Vec<String>,
135
+ config: AppConfig,
136
+ options: AskOptions,
137
+ workspace_context: Option<String>,
138
+ policy: ApprovalPolicy,
139
+ key_rx: mpsc::UnboundedReceiver<Event>,
140
+ ) -> Self {
141
+ let (stream_tx, stream_rx) = mpsc::unbounded_channel();
142
+ let messages = history
143
+ .iter()
144
+ .map(|m| match m.role {
145
+ ChatRole::User => Msg::User { text: m.content.clone() },
146
+ ChatRole::Assistant => Msg::Assistant { text: m.content.clone() },
147
+ })
148
+ .collect();
149
+
150
+ Self {
151
+ messages,
152
+ streaming_buf: String::new(),
153
+ accumulated_response: String::new(),
154
+ tool_status: String::new(),
155
+ plan_tasks: vec![],
156
+ plan_done: vec![],
157
+ pending_prompt: String::new(),
158
+
159
+ input: String::new(),
160
+ input_cursor: 0,
161
+ input_history,
162
+ hist_idx: None,
163
+ hist_saved: String::new(),
164
+ pending_image: None,
165
+ last_image_fp: None,
166
+ images_available,
167
+
168
+ scroll: usize::MAX,
169
+ auto_scroll: true,
170
+ total_lines: 0,
171
+
172
+ provider,
173
+ model,
174
+ usage: Usage::default(),
175
+ cwd,
176
+
177
+ mode: Mode::Input,
178
+ confirm: None,
179
+
180
+ history,
181
+ session_path,
182
+ last_saved_at,
183
+
184
+ config,
185
+ options,
186
+ workspace_context,
187
+ policy,
188
+
189
+ stream_rx,
190
+ stream_tx,
191
+ key_rx,
192
+
193
+ quit: false,
194
+ spinner_frame: 0,
195
+ }
196
+ }
197
+ }
198
+
199
+ // ── Main TUI loop ─────────────────────────────────────────────────────────────
200
+
201
+ pub async fn run(mut app: App) -> Result<Vec<ChatMessage>> {
202
+ crossterm::execute!(std::io::stdout(), EnableMouseCapture)?;
203
+ let mut terminal = ratatui::init();
204
+ terminal.clear()?;
205
+ let result = event_loop(&mut terminal, &mut app).await;
206
+ ratatui::restore();
207
+ crossterm::execute!(std::io::stdout(), DisableMouseCapture)?;
208
+ result
209
+ }
210
+
211
+ async fn event_loop(terminal: &mut DefaultTerminal, app: &mut App) -> Result<Vec<ChatMessage>> {
212
+ loop {
213
+ terminal.draw(|f| render(f, app))?;
214
+ if app.quit {
215
+ break;
216
+ }
217
+ tokio::select! {
218
+ Some(ev) = app.key_rx.recv() => {
219
+ handle_event(app, ev).await?;
220
+ }
221
+ Some(tui_ev) = app.stream_rx.recv() => {
222
+ handle_stream_event(app, tui_ev).await;
223
+ }
224
+ _ = tokio::time::sleep(Duration::from_millis(80)) => {
225
+ if app.mode == Mode::Streaming {
226
+ app.spinner_frame = app.spinner_frame.wrapping_add(1);
227
+ }
228
+ }
229
+ }
230
+ }
231
+ Ok(app.history.clone())
232
+ }
233
+
234
+ // ── Event handling ────────────────────────────────────────────────────────────
235
+
236
+ async fn handle_event(app: &mut App, event: Event) -> Result<()> {
237
+ match event {
238
+ Event::Mouse(MouseEvent { kind, .. }) => handle_mouse(app, kind),
239
+ Event::Key(key) => handle_key(app, key).await?,
240
+ Event::Resize(_, _) => {}
241
+ _ => {}
242
+ }
243
+ Ok(())
244
+ }
245
+
246
+ fn handle_mouse(app: &mut App, kind: MouseEventKind) {
247
+ match kind {
248
+ MouseEventKind::ScrollUp => {
249
+ app.auto_scroll = false;
250
+ app.scroll = app.scroll.saturating_sub(3);
251
+ }
252
+ MouseEventKind::ScrollDown => {
253
+ app.scroll = app.scroll.saturating_add(3);
254
+ if app.scroll >= app.total_lines {
255
+ app.auto_scroll = true;
256
+ }
257
+ }
258
+ _ => {}
259
+ }
260
+ }
261
+
262
+ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -> Result<()> {
263
+ // ── Confirming mode: y/a/n only ───────────────────────────────────────────
264
+ if app.mode == Mode::Confirming {
265
+ if let Some(confirm) = app.confirm.take() {
266
+ let decision = match code {
267
+ KeyCode::Char('y') | KeyCode::Enter => ApprovalDecision::AllowOnce,
268
+ KeyCode::Char('a') => ApprovalDecision::AllowForTurn,
269
+ _ => ApprovalDecision::Deny,
270
+ };
271
+ let _ = confirm.reply.send(decision);
272
+ app.mode = Mode::Streaming;
273
+ }
274
+ return Ok(());
275
+ }
276
+
277
+ // ── Streaming mode: scroll only ───────────────────────────────────────────
278
+ if app.mode == Mode::Streaming {
279
+ match code {
280
+ KeyCode::PageUp => {
281
+ app.auto_scroll = false;
282
+ app.scroll = app.scroll.saturating_sub(10);
283
+ }
284
+ KeyCode::PageDown => {
285
+ app.scroll = app.scroll.saturating_add(10);
286
+ if app.scroll >= app.total_lines {
287
+ app.auto_scroll = true;
288
+ }
289
+ }
290
+ _ => {}
291
+ }
292
+ return Ok(());
293
+ }
294
+
295
+ // ── Input mode ────────────────────────────────────────────────────────────
296
+ match code {
297
+ // Submit (Enter) or newline (Shift+Enter)
298
+ KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => {
299
+ app.input.insert(app.input_cursor, '\n');
300
+ app.input_cursor += 1;
301
+ app.hist_idx = None;
302
+ }
303
+ KeyCode::Enter => {
304
+ let text = app.input.trim().to_string();
305
+ if text.is_empty() {
306
+ return Ok(());
307
+ }
308
+ if !handle_slash_command(app, &text) {
309
+ submit_prompt(app, text).await?;
310
+ }
311
+ }
312
+
313
+ // Ctrl shortcuts
314
+ KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
315
+ if app.input.is_empty() {
316
+ app.quit = true;
317
+ } else {
318
+ app.input.clear();
319
+ app.input_cursor = 0;
320
+ app.hist_idx = None;
321
+ }
322
+ }
323
+ KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) && app.input.is_empty() => {
324
+ app.quit = true;
325
+ }
326
+ KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => {
327
+ app.input.drain(..app.input_cursor);
328
+ app.input_cursor = 0;
329
+ app.hist_idx = None;
330
+ }
331
+ KeyCode::Char('w') if modifiers.contains(KeyModifiers::CONTROL) => {
332
+ delete_word_before(&mut app.input, &mut app.input_cursor);
333
+ app.hist_idx = None;
334
+ }
335
+ KeyCode::Char('v') if modifiers.contains(KeyModifiers::CONTROL) && app.images_available => {
336
+ if let Some(img) = crate::grab_clipboard_image() {
337
+ app.pending_image = Some(img);
338
+ app.last_image_fp = None; // force re-attach
339
+ }
340
+ }
341
+
342
+ // Editing
343
+ KeyCode::Backspace => {
344
+ if app.input_cursor > 0 {
345
+ let len = prev_char_len(&app.input, app.input_cursor);
346
+ let start = app.input_cursor - len;
347
+ app.input.drain(start..app.input_cursor);
348
+ app.input_cursor = start;
349
+ app.hist_idx = None;
350
+ }
351
+ }
352
+ KeyCode::Delete => {
353
+ if app.input_cursor < app.input.len() {
354
+ let len = next_char_len(&app.input, app.input_cursor);
355
+ app.input.drain(app.input_cursor..app.input_cursor + len);
356
+ app.hist_idx = None;
357
+ }
358
+ }
359
+
360
+ // Cursor movement
361
+ KeyCode::Left => move_cursor_left(&app.input.clone(), &mut app.input_cursor),
362
+ KeyCode::Right => move_cursor_right(&app.input.clone(), &mut app.input_cursor),
363
+ KeyCode::Home => app.input_cursor = 0,
364
+ KeyCode::End => app.input_cursor = app.input.len(),
365
+
366
+ // History navigation
367
+ KeyCode::Up => {
368
+ if !app.input_history.is_empty() {
369
+ let new_idx = match app.hist_idx {
370
+ None => {
371
+ app.hist_saved = app.input.clone();
372
+ app.input_history.len() - 1
373
+ }
374
+ Some(0) => 0,
375
+ Some(i) => i - 1,
376
+ };
377
+ app.hist_idx = Some(new_idx);
378
+ app.input = app.input_history[new_idx].clone();
379
+ app.input_cursor = app.input.len();
380
+ }
381
+ }
382
+ KeyCode::Down => {
383
+ match app.hist_idx {
384
+ None => {}
385
+ Some(i) if i + 1 >= app.input_history.len() => {
386
+ app.hist_idx = None;
387
+ app.input = std::mem::take(&mut app.hist_saved);
388
+ app.input_cursor = app.input.len();
389
+ }
390
+ Some(i) => {
391
+ app.hist_idx = Some(i + 1);
392
+ app.input = app.input_history[i + 1].clone();
393
+ app.input_cursor = app.input.len();
394
+ }
395
+ }
396
+ }
397
+
398
+ // Scroll
399
+ KeyCode::PageUp => {
400
+ app.auto_scroll = false;
401
+ app.scroll = app.scroll.saturating_sub(10);
402
+ }
403
+ KeyCode::PageDown => {
404
+ app.scroll = app.scroll.saturating_add(10);
405
+ if app.scroll >= app.total_lines {
406
+ app.auto_scroll = true;
407
+ }
408
+ }
409
+
410
+ // Printable characters
411
+ KeyCode::Char(c) => {
412
+ let s = c.to_string();
413
+ app.input.insert_str(app.input_cursor, &s);
414
+ app.input_cursor += s.len();
415
+ app.hist_idx = None;
416
+ }
417
+
418
+ _ => {}
419
+ }
420
+ Ok(())
421
+ }
422
+
423
+ // Returns true if the command was consumed (don't send to AI).
424
+ fn handle_slash_command(app: &mut App, text: &str) -> bool {
425
+ match text {
426
+ "/exit" | "/quit" | ":q" => {
427
+ app.quit = true;
428
+ true
429
+ }
430
+ "/clear" => {
431
+ app.messages.clear();
432
+ app.history.clear();
433
+ app.streaming_buf.clear();
434
+ app.accumulated_response.clear();
435
+ app.usage = Usage::default();
436
+ app.pending_image = None;
437
+ app.input.clear();
438
+ app.input_cursor = 0;
439
+ if let Some(path) = &app.session_path {
440
+ let _ = std::fs::remove_file(path);
441
+ }
442
+ true
443
+ }
444
+ "/help" => {
445
+ app.messages.push(Msg::System(
446
+ "Commands: /clear /export [path] /model [name] /provider [name] /status /exit\n\
447
+ Keys: ↑/↓ history ←/→ cursor Home/End Shift+Enter newline\n\
448
+ Ctrl+W delete-word Ctrl+U clear-line Ctrl+V paste image\n\
449
+ PageUp/Dn or scroll wheel to scroll history".into(),
450
+ ));
451
+ app.input.clear();
452
+ app.input_cursor = 0;
453
+ true
454
+ }
455
+ "/status" => {
456
+ let u = &app.usage;
457
+ app.messages.push(Msg::System(format!(
458
+ "provider: {} model: {} turns: {} tokens: {}↓ {}↑ {} total",
459
+ app.provider,
460
+ app.model,
461
+ app.history.len() / 2,
462
+ u.prompt_tokens,
463
+ u.completion_tokens,
464
+ u.total_tokens,
465
+ )));
466
+ app.input.clear();
467
+ app.input_cursor = 0;
468
+ true
469
+ }
470
+ s if s.starts_with("/export") => {
471
+ let arg = s.strip_prefix("/export").unwrap().trim();
472
+ let path = if arg.is_empty() {
473
+ std::path::PathBuf::from(format!("anveesa-export-{}.md", crate::unix_now()))
474
+ } else {
475
+ std::path::PathBuf::from(arg)
476
+ };
477
+ match crate::export_conversation(&path, &app.history) {
478
+ Ok(()) => app.messages.push(Msg::System(format!("Exported → {}", path.display()))),
479
+ Err(e) => app.messages.push(Msg::Error(format!("export failed: {e:#}"))),
480
+ }
481
+ app.input.clear();
482
+ app.input_cursor = 0;
483
+ true
484
+ }
485
+ s if s.starts_with("/model") => {
486
+ let arg = s.strip_prefix("/model").unwrap().trim();
487
+ if arg.is_empty() {
488
+ let current = app.model.clone();
489
+ app.messages.push(Msg::System(format!("current model: {current}")));
490
+ } else {
491
+ app.model = arg.to_string();
492
+ app.options.model = Some(arg.to_string());
493
+ app.messages.push(Msg::System(format!("switched to model: {arg}")));
494
+ }
495
+ app.input.clear();
496
+ app.input_cursor = 0;
497
+ true
498
+ }
499
+ s if s.starts_with("/provider") => {
500
+ let arg = s.strip_prefix("/provider").unwrap().trim();
501
+ if arg.is_empty() {
502
+ let current = app.provider.clone();
503
+ app.messages.push(Msg::System(format!("current provider: {current}")));
504
+ } else {
505
+ // Validate provider exists
506
+ if app.config.providers.contains_key(arg) {
507
+ app.provider = arg.to_string();
508
+ app.options.provider = Some(arg.to_string());
509
+ // Update model to provider default
510
+ if let Some(m) = app.config.providers.get(arg)
511
+ .and_then(|p| p.default_model())
512
+ {
513
+ app.model = m.to_string();
514
+ app.options.model = Some(m.to_string());
515
+ }
516
+ app.messages.push(Msg::System(format!("switched to provider: {arg}")));
517
+ } else {
518
+ app.messages.push(Msg::Error(format!("unknown provider '{arg}'")));
519
+ }
520
+ }
521
+ app.input.clear();
522
+ app.input_cursor = 0;
523
+ true
524
+ }
525
+ _ => false,
526
+ }
527
+ }
528
+
529
+ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
530
+ // Save to input history
531
+ if app.input_history.last().map(|s| s.as_str()) != Some(&text) {
532
+ app.input_history.push(text.clone());
533
+ }
534
+ app.hist_idx = None;
535
+ app.pending_prompt = text.clone();
536
+ app.accumulated_response.clear();
537
+
538
+ // Auto-attach clipboard image if nothing was explicitly Ctrl+V'd
539
+ let image = app.pending_image.take().or_else(|| {
540
+ if !app.images_available { return None; }
541
+ let img = crate::grab_clipboard_image()?;
542
+ let fp = crate::image_fingerprint(&img);
543
+ if app.last_image_fp.as_deref() == Some(&fp) {
544
+ return None; // same image as last time
545
+ }
546
+ app.last_image_fp = Some(fp);
547
+ Some(img)
548
+ });
549
+
550
+ app.messages.push(Msg::User { text: text.clone() });
551
+ app.input.clear();
552
+ app.input_cursor = 0;
553
+ app.auto_scroll = true;
554
+ app.mode = Mode::Streaming;
555
+ app.tool_status = "Thinking".to_string();
556
+ app.spinner_frame = 0;
557
+
558
+ let provider_name = app
559
+ .config
560
+ .provider_name(app.options.provider.as_deref())
561
+ .context("unknown provider")?
562
+ .to_string();
563
+
564
+ let (stream_tx_inner, stream_rx_inner) = mpsc::unbounded_channel::<StreamEvent>();
565
+
566
+ // Clone everything needed for the spawned tasks
567
+ let config = app.config.clone();
568
+ let options = app.options.clone();
569
+ let history = app.history.clone();
570
+ let workspace_context = app.workspace_context.clone();
571
+ let policy = app.policy;
572
+ let tui_tx = app.stream_tx.clone();
573
+ let tui_tx2 = app.stream_tx.clone();
574
+
575
+ // Task 1: call the provider
576
+ tokio::spawn(async move {
577
+ let request = PromptRequest {
578
+ prompt: text,
579
+ model: options.model.clone(),
580
+ system: options.system.clone(),
581
+ workspace_context,
582
+ history,
583
+ image,
584
+ };
585
+ let result = crate::provider::ask(&config, &provider_name, request, policy, &stream_tx_inner).await;
586
+ drop(stream_tx_inner);
587
+ match result {
588
+ Ok(turn) => {
589
+ let _ = tui_tx.send(TuiEvent::Usage(turn.usage.unwrap_or_default()));
590
+ }
591
+ Err(e) => {
592
+ let _ = tui_tx.send(TuiEvent::Error(format!("{e:#}")));
593
+ }
594
+ }
595
+ });
596
+
597
+ // Task 2: relay StreamEvents → TuiEvents
598
+ tokio::spawn(async move {
599
+ let mut rx = stream_rx_inner;
600
+ while let Some(ev) = rx.recv().await {
601
+ let tui_ev = match ev {
602
+ StreamEvent::Token(t) => TuiEvent::Token(t),
603
+ StreamEvent::Status { message } => TuiEvent::Status(message),
604
+ StreamEvent::ToolCall { summary } => TuiEvent::ToolCall(summary),
605
+ StreamEvent::ToolResult { summary, ok, .. } => TuiEvent::ToolDone { summary, ok },
606
+ StreamEvent::FileOp { verb, path, added, removed, .. } =>
607
+ TuiEvent::FileOp { verb, path, added, removed },
608
+ StreamEvent::Confirm { preview, reply } => {
609
+ let summary = match &preview {
610
+ ToolConfirmPreview::FileOp { verb, path, added, removed, .. } =>
611
+ format!("{verb} {path} +{added} -{removed}"),
612
+ ToolConfirmPreview::CreateDir { path } => format!("mkdir {path}"),
613
+ ToolConfirmPreview::Generic { summary } => summary.clone(),
614
+ };
615
+ TuiEvent::Confirm { summary, reply }
616
+ }
617
+ StreamEvent::Usage(u) => TuiEvent::Usage(u),
618
+ StreamEvent::PlanSet { tasks } => TuiEvent::PlanSet(tasks),
619
+ StreamEvent::PlanTaskDone { index } => TuiEvent::PlanTaskDone(index),
620
+ };
621
+ if tui_tx2.send(tui_ev).is_err() { break; }
622
+ }
623
+ });
624
+
625
+ Ok(())
626
+ }
627
+
628
+ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
629
+ match ev {
630
+ TuiEvent::Token(text) => {
631
+ app.streaming_buf.push_str(&text);
632
+ app.auto_scroll = true;
633
+ }
634
+ TuiEvent::Status(msg) => {
635
+ app.tool_status = msg;
636
+ }
637
+ TuiEvent::ToolCall(summary) => {
638
+ flush_streaming_buf(app);
639
+ app.messages.push(Msg::Tool { icon: "⚙", text: summary, ok: true });
640
+ app.tool_status = "Running".to_string();
641
+ }
642
+ TuiEvent::ToolDone { summary, ok } => {
643
+ if let Some(Msg::Tool { text, ok: tool_ok, .. }) = app.messages.last_mut() {
644
+ *text = summary;
645
+ *tool_ok = ok;
646
+ }
647
+ app.tool_status = "Thinking".to_string();
648
+ }
649
+ TuiEvent::FileOp { verb, path, added, removed } => {
650
+ flush_streaming_buf(app);
651
+ app.messages.push(Msg::FileOp { verb, path, added, removed });
652
+ }
653
+ TuiEvent::Confirm { summary, reply } => {
654
+ flush_streaming_buf(app);
655
+ app.confirm = Some(PendingConfirm { summary, reply });
656
+ app.mode = Mode::Confirming;
657
+ }
658
+ TuiEvent::Usage(u) => {
659
+ app.usage.prompt_tokens += u.prompt_tokens;
660
+ app.usage.completion_tokens += u.completion_tokens;
661
+ app.usage.total_tokens += u.total_tokens;
662
+ app.usage.cache_read_tokens += u.cache_read_tokens;
663
+ app.usage.cache_write_tokens += u.cache_write_tokens;
664
+ finish_turn(app);
665
+ }
666
+ TuiEvent::Error(msg) => {
667
+ flush_streaming_buf(app);
668
+ app.messages.push(Msg::Error(msg));
669
+ app.mode = Mode::Input;
670
+ app.tool_status.clear();
671
+ }
672
+ TuiEvent::PlanSet(tasks) => {
673
+ app.plan_done = vec![false; tasks.len()];
674
+ app.plan_tasks = tasks;
675
+ }
676
+ TuiEvent::PlanTaskDone(i) => {
677
+ if i < app.plan_done.len() { app.plan_done[i] = true; }
678
+ }
679
+ }
680
+ }
681
+
682
+ /// Flush streaming_buf to messages and accumulated_response.
683
+ fn flush_streaming_buf(app: &mut App) {
684
+ if !app.streaming_buf.is_empty() {
685
+ let text = std::mem::take(&mut app.streaming_buf);
686
+ app.accumulated_response.push_str(&text);
687
+ app.messages.push(Msg::Assistant { text });
688
+ }
689
+ }
690
+
691
+ /// Commit the completed turn to history and save session.
692
+ fn finish_turn(app: &mut App) {
693
+ flush_streaming_buf(app);
694
+ let response = std::mem::take(&mut app.accumulated_response);
695
+ if !response.is_empty() {
696
+ let prompt = std::mem::take(&mut app.pending_prompt);
697
+ app.history.push(ChatMessage::user(prompt));
698
+ app.history.push(ChatMessage::assistant(response));
699
+ if let Some(path) = &app.session_path {
700
+ if let Ok(cwd) = std::env::current_dir() {
701
+ let _ = crate::save_interactive_session_pub(
702
+ path, &cwd, &app.provider, &app.options, &app.history,
703
+ );
704
+ app.last_saved_at = crate::unix_now();
705
+ }
706
+ }
707
+ }
708
+ app.mode = Mode::Input;
709
+ app.tool_status.clear();
710
+ }
711
+
712
+ // ── Rendering ─────────────────────────────────────────────────────────────────
713
+
714
+ fn render(frame: &mut Frame, app: &mut App) {
715
+ let area = frame.area();
716
+ let input_lines = app.input.lines().count().max(1);
717
+ let input_height = (input_lines as u16).clamp(1, 5) + 2;
718
+
719
+ let chunks = Layout::vertical([
720
+ Constraint::Length(1),
721
+ Constraint::Min(3),
722
+ Constraint::Length(input_height),
723
+ Constraint::Length(1),
724
+ ])
725
+ .split(area);
726
+
727
+ render_header(frame, chunks[0], app);
728
+ render_messages(frame, chunks[1], app);
729
+ render_input(frame, chunks[2], app);
730
+ render_status(frame, chunks[3], app);
731
+ }
732
+
733
+ fn render_header(frame: &mut Frame, area: Rect, app: &App) {
734
+ let version = env!("CARGO_PKG_VERSION");
735
+ let token_str = if app.usage.total_tokens > 0 {
736
+ format!(" {}↓ {}↑", app.usage.prompt_tokens, app.usage.completion_tokens)
737
+ } else {
738
+ String::new()
739
+ };
740
+ let left = format!(" anveesa v{version}{token_str}");
741
+ let right = format!("{} · {} ", app.provider, app.model);
742
+ let gap = (area.width as usize).saturating_sub(left.chars().count() + right.chars().count());
743
+ let title = format!("{left}{}{right}", " ".repeat(gap));
744
+ frame.render_widget(
745
+ Paragraph::new(title).style(Style::default().fg(Color::Black).bg(Color::Rgb(97, 175, 239))),
746
+ area,
747
+ );
748
+ }
749
+
750
+ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
751
+ let width = area.width.saturating_sub(4) as usize;
752
+ let mut lines: Vec<Line<'static>> = vec![Line::from("")];
753
+
754
+ for msg in &app.messages {
755
+ match msg {
756
+ Msg::User { text } => {
757
+ lines.push(user_header());
758
+ for l in wrap_text(text, width) {
759
+ lines.push(Line::from(format!(" {l}")));
760
+ }
761
+ lines.push(Line::from(""));
762
+ }
763
+ Msg::Assistant { text } => {
764
+ lines.push(assistant_header(&app.model));
765
+ for l in format_assistant_lines(text, width) {
766
+ lines.push(l);
767
+ }
768
+ lines.push(Line::from(""));
769
+ }
770
+ Msg::Tool { icon, text, ok } => {
771
+ let color = if *ok { Color::Rgb(229, 192, 123) } else { Color::Rgb(224, 108, 117) };
772
+ lines.push(Line::from(Span::styled(
773
+ format!(" {icon} {text}"),
774
+ Style::default().fg(color),
775
+ )));
776
+ lines.push(Line::from(""));
777
+ }
778
+ Msg::FileOp { verb, path, added, removed } => {
779
+ lines.push(Line::from(vec![
780
+ Span::styled(" 📄 ", Style::default().fg(Color::Rgb(229, 192, 123))),
781
+ Span::styled(format!("{verb} "), Style::default().fg(Color::White)),
782
+ Span::styled(path.clone(), Style::default().fg(Color::Rgb(97, 175, 239))),
783
+ Span::styled(format!(" +{added}"), Style::default().fg(Color::Rgb(152, 195, 121))),
784
+ Span::styled(format!(" -{removed}"), Style::default().fg(Color::Rgb(224, 108, 117))),
785
+ ]));
786
+ lines.push(Line::from(""));
787
+ }
788
+ Msg::Error(msg) => {
789
+ lines.push(Line::from(Span::styled(
790
+ format!(" ✗ {msg}"),
791
+ Style::default().fg(Color::Rgb(224, 108, 117)),
792
+ )));
793
+ lines.push(Line::from(""));
794
+ }
795
+ Msg::System(msg) => {
796
+ for l in msg.lines() {
797
+ lines.push(Line::from(Span::styled(
798
+ format!(" · {l}"),
799
+ Style::default().fg(Color::DarkGray),
800
+ )));
801
+ }
802
+ lines.push(Line::from(""));
803
+ }
804
+ }
805
+ }
806
+
807
+ // In-progress streaming
808
+ if !app.streaming_buf.is_empty() || app.mode == Mode::Streaming {
809
+ lines.push(assistant_header(&app.model));
810
+ if !app.streaming_buf.is_empty() {
811
+ for l in format_assistant_lines(&app.streaming_buf, width) {
812
+ lines.push(l);
813
+ }
814
+ } else {
815
+ let dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
816
+ let dot = dots[app.spinner_frame % dots.len()];
817
+ let status = if app.tool_status.is_empty() { "Thinking" } else { &app.tool_status };
818
+ lines.push(Line::from(Span::styled(
819
+ format!(" {dot} {status}"),
820
+ Style::default().fg(Color::DarkGray),
821
+ )));
822
+ }
823
+ lines.push(Line::from(""));
824
+ }
825
+
826
+ let total = lines.len();
827
+ app.total_lines = total;
828
+ let visible = area.height as usize;
829
+ let scroll = if app.auto_scroll || app.scroll == usize::MAX {
830
+ total.saturating_sub(visible)
831
+ } else {
832
+ app.scroll.min(total.saturating_sub(visible))
833
+ };
834
+ app.scroll = scroll;
835
+
836
+ frame.render_widget(
837
+ Paragraph::new(lines).scroll((scroll as u16, 0)),
838
+ area,
839
+ );
840
+ }
841
+
842
+ fn user_header() -> Line<'static> {
843
+ Line::from(Span::styled(
844
+ " ● You",
845
+ Style::default().fg(Color::Rgb(97, 175, 239)).add_modifier(Modifier::BOLD),
846
+ ))
847
+ }
848
+
849
+ fn assistant_header(model: &str) -> Line<'static> {
850
+ Line::from(Span::styled(
851
+ format!(" ● {model}"),
852
+ Style::default().fg(Color::Rgb(152, 195, 121)).add_modifier(Modifier::BOLD),
853
+ ))
854
+ }
855
+
856
+ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
857
+ let block = Block::default()
858
+ .borders(Borders::TOP)
859
+ .border_style(Style::default().fg(Color::Rgb(60, 60, 80)));
860
+ let inner = block.inner(area);
861
+ frame.render_widget(block, area);
862
+
863
+ let label = if app.pending_image.is_some() { " [📎] ❯ " } else { " ❯ " };
864
+ let label_w = label.chars().count();
865
+ let display = format!("{label}{}", app.input);
866
+
867
+ frame.render_widget(
868
+ Paragraph::new(display).style(Style::default().fg(Color::White)).wrap(Wrap { trim: false }),
869
+ inner,
870
+ );
871
+
872
+ // Position cursor
873
+ let cursor_chars = label_w + app.input[..app.input_cursor].chars().count();
874
+ let w = inner.width.max(1) as usize;
875
+ frame.set_cursor_position((
876
+ inner.x + (cursor_chars % w) as u16,
877
+ inner.y + (cursor_chars / w) as u16,
878
+ ));
879
+ }
880
+
881
+ fn render_status(frame: &mut Frame, area: Rect, app: &App) {
882
+ let (text, style) = if app.mode == Mode::Confirming {
883
+ let summary = app.confirm.as_ref().map(|c| c.summary.as_str()).unwrap_or("?");
884
+ (
885
+ format!(" ⚠ Allow: {summary} [y]es [a]ll [n]o "),
886
+ Style::default().fg(Color::Black).bg(Color::Rgb(229, 192, 123)),
887
+ )
888
+ } else {
889
+ let hints = "PageUp/Dn · scroll · /help";
890
+ let left = format!(" {}", app.cwd);
891
+ let right = format!("{hints} ");
892
+ let gap = (area.width as usize)
893
+ .saturating_sub(left.chars().count() + right.chars().count());
894
+ (
895
+ format!("{left}{}{right}", " ".repeat(gap)),
896
+ Style::default().fg(Color::DarkGray).bg(Color::Rgb(30, 30, 46)),
897
+ )
898
+ };
899
+ frame.render_widget(Paragraph::new(text).style(style), area);
900
+ }
901
+
902
+ // ── Text formatting ───────────────────────────────────────────────────────────
903
+
904
+ fn wrap_text(text: &str, width: usize) -> Vec<String> {
905
+ if width == 0 { return vec![text.to_string()]; }
906
+ let mut out = Vec::new();
907
+ for line in text.lines() {
908
+ if line.is_empty() { out.push(String::new()); continue; }
909
+ let mut current = String::new();
910
+ let mut col = 0usize;
911
+ for word in line.split_whitespace() {
912
+ let wlen = word.chars().count();
913
+ if col > 0 && col + 1 + wlen > width {
914
+ out.push(current.clone());
915
+ current.clear();
916
+ col = 0;
917
+ }
918
+ if col > 0 { current.push(' '); col += 1; }
919
+ current.push_str(word);
920
+ col += wlen;
921
+ }
922
+ out.push(current);
923
+ }
924
+ out
925
+ }
926
+
927
+ fn format_assistant_lines(text: &str, width: usize) -> Vec<Line<'static>> {
928
+ let mut out = Vec::new();
929
+ let mut in_code = false;
930
+ let mut code_lang = String::new();
931
+
932
+ for raw in text.lines() {
933
+ if raw.starts_with("```") {
934
+ if in_code {
935
+ in_code = false;
936
+ code_lang.clear();
937
+ out.push(Line::from(Span::styled(
938
+ " └──────────────────────".to_string(),
939
+ Style::default().fg(Color::Rgb(50, 50, 70)),
940
+ )));
941
+ } else {
942
+ in_code = true;
943
+ code_lang = raw[3..].trim().to_string();
944
+ let lang = if code_lang.is_empty() { String::new() } else { format!(" {} ", code_lang) };
945
+ out.push(Line::from(Span::styled(
946
+ format!(" ┌─{lang}"),
947
+ Style::default().fg(Color::Rgb(50, 50, 70)),
948
+ )));
949
+ }
950
+ continue;
951
+ }
952
+
953
+ if in_code {
954
+ out.push(highlight_code_line(raw, &code_lang));
955
+ } else {
956
+ let wrapped = if width > 4 && raw.chars().count() + 4 > width {
957
+ wrap_text(raw, width.saturating_sub(4))
958
+ } else {
959
+ vec![raw.to_string()]
960
+ };
961
+ for l in wrapped {
962
+ out.push(format_prose_line(&l));
963
+ }
964
+ }
965
+ }
966
+ out
967
+ }
968
+
969
+ fn format_prose_line(line: &str) -> Line<'static> {
970
+ if line.is_empty() { return Line::from(""); }
971
+
972
+ if line.starts_with("### ") {
973
+ return Line::from(Span::styled(
974
+ format!(" {}", &line[4..]),
975
+ Style::default().fg(Color::Rgb(198, 160, 246)).add_modifier(Modifier::BOLD),
976
+ ));
977
+ }
978
+ if line.starts_with("## ") {
979
+ return Line::from(Span::styled(
980
+ format!(" {}", &line[3..]),
981
+ Style::default().fg(Color::Rgb(198, 160, 246)).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
982
+ ));
983
+ }
984
+ if line.starts_with("# ") {
985
+ return Line::from(Span::styled(
986
+ format!(" {}", &line[2..]),
987
+ Style::default().fg(Color::Rgb(198, 160, 246)).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
988
+ ));
989
+ }
990
+
991
+ let (prefix, rest) = if let Some(s) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
992
+ (" • ", s)
993
+ } else {
994
+ (" ", line)
995
+ };
996
+
997
+ Line::from(parse_inline(&format!("{prefix}{rest}")))
998
+ }
999
+
1000
+ fn parse_inline(text: &str) -> Vec<Span<'static>> {
1001
+ let mut spans = Vec::new();
1002
+ let mut chars = text.chars().peekable();
1003
+ let mut buf = String::new();
1004
+
1005
+ while let Some(c) = chars.next() {
1006
+ if c == '`' {
1007
+ if !buf.is_empty() { spans.push(Span::raw(buf.clone())); buf.clear(); }
1008
+ let mut code = String::new();
1009
+ for ch in chars.by_ref() { if ch == '`' { break; } code.push(ch); }
1010
+ spans.push(Span::styled(code, Style::default().fg(Color::Rgb(229, 192, 123)).bg(Color::Rgb(40, 40, 55))));
1011
+ } else if c == '*' && chars.peek() == Some(&'*') {
1012
+ chars.next();
1013
+ if !buf.is_empty() { spans.push(Span::raw(buf.clone())); buf.clear(); }
1014
+ let mut bold = String::new();
1015
+ loop {
1016
+ match chars.next() {
1017
+ Some('*') if chars.peek() == Some(&'*') => { chars.next(); break; }
1018
+ Some(ch) => bold.push(ch),
1019
+ None => break,
1020
+ }
1021
+ }
1022
+ spans.push(Span::styled(bold, Style::default().add_modifier(Modifier::BOLD)));
1023
+ } else if c == '*' {
1024
+ if !buf.is_empty() { spans.push(Span::raw(buf.clone())); buf.clear(); }
1025
+ let mut italic = String::new();
1026
+ for ch in chars.by_ref() { if ch == '*' { break; } italic.push(ch); }
1027
+ spans.push(Span::styled(italic, Style::default().add_modifier(Modifier::ITALIC)));
1028
+ } else {
1029
+ buf.push(c);
1030
+ }
1031
+ }
1032
+ if !buf.is_empty() { spans.push(Span::raw(buf)); }
1033
+ spans
1034
+ }
1035
+
1036
+ fn highlight_code_line(line: &str, _lang: &str) -> Line<'static> {
1037
+ static KEYWORDS: &[&str] = &[
1038
+ "fn", "let", "mut", "const", "struct", "enum", "impl", "trait", "use", "pub",
1039
+ "mod", "return", "if", "else", "for", "while", "loop", "match", "async", "await",
1040
+ "self", "Self", "true", "false", "Some", "None", "Ok", "Err", "type", "where",
1041
+ "def", "class", "import", "from", "pass", "with", "as", "in", "not", "and", "or",
1042
+ "var", "function", "new", "this", "typeof", "instanceof", "yield", "break", "continue",
1043
+ "int", "str", "bool", "float", "None", "True", "False", "null", "undefined",
1044
+ "interface", "extends", "implements", "static", "final", "void", "package",
1045
+ ];
1046
+
1047
+ let bg = Color::Rgb(28, 28, 40);
1048
+ let mut spans: Vec<Span<'static>> = vec![
1049
+ Span::styled(" ".to_string(), Style::default().bg(bg)),
1050
+ ];
1051
+
1052
+ let mut chars = line.chars().peekable();
1053
+ let mut buf = String::new();
1054
+ let mut in_string = false;
1055
+ let mut string_char = '"';
1056
+
1057
+ let flush = |buf: &mut String, spans: &mut Vec<Span<'static>>| {
1058
+ if buf.is_empty() { return; }
1059
+ let s = buf.clone();
1060
+ let style = if KEYWORDS.contains(&s.as_str()) {
1061
+ Style::default().fg(Color::Rgb(198, 120, 221)).bg(bg)
1062
+ } else {
1063
+ Style::default().fg(Color::Rgb(171, 178, 191)).bg(bg)
1064
+ };
1065
+ spans.push(Span::styled(s, style));
1066
+ buf.clear();
1067
+ };
1068
+
1069
+ while let Some(c) = chars.next() {
1070
+ if in_string {
1071
+ buf.push(c);
1072
+ if c == string_char {
1073
+ let s = buf.clone();
1074
+ spans.push(Span::styled(s, Style::default().fg(Color::Rgb(152, 195, 121)).bg(bg)));
1075
+ buf.clear();
1076
+ in_string = false;
1077
+ }
1078
+ continue;
1079
+ }
1080
+
1081
+ // Line comments
1082
+ if (c == '/' && chars.peek() == Some(&'/')) || c == '#' {
1083
+ flush(&mut buf, &mut spans);
1084
+ let rest: String = std::iter::once(c).chain(chars.by_ref()).collect();
1085
+ spans.push(Span::styled(rest, Style::default().fg(Color::Rgb(92, 99, 112)).bg(bg)));
1086
+ break;
1087
+ }
1088
+
1089
+ // String start
1090
+ if c == '"' || c == '\'' {
1091
+ flush(&mut buf, &mut spans);
1092
+ in_string = true;
1093
+ string_char = c;
1094
+ buf.push(c);
1095
+ continue;
1096
+ }
1097
+
1098
+ // Numbers
1099
+ if c.is_ascii_digit() && buf.is_empty() {
1100
+ flush(&mut buf, &mut spans);
1101
+ let mut num = c.to_string();
1102
+ while let Some(&n) = chars.peek() {
1103
+ if n.is_ascii_alphanumeric() || n == '.' || n == '_' { num.push(n); chars.next(); }
1104
+ else { break; }
1105
+ }
1106
+ spans.push(Span::styled(num, Style::default().fg(Color::Rgb(209, 154, 102)).bg(bg)));
1107
+ continue;
1108
+ }
1109
+
1110
+ if c.is_alphanumeric() || c == '_' {
1111
+ buf.push(c);
1112
+ } else {
1113
+ flush(&mut buf, &mut spans);
1114
+ spans.push(Span::styled(c.to_string(), Style::default().fg(Color::Rgb(171, 178, 191)).bg(bg)));
1115
+ }
1116
+ }
1117
+ flush(&mut buf, &mut spans);
1118
+
1119
+ // Fill remainder with bg color
1120
+ let content_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
1121
+ if content_len < 84 {
1122
+ spans.push(Span::styled(" ".repeat(84 - content_len), Style::default().bg(bg)));
1123
+ }
1124
+
1125
+ Line::from(spans)
1126
+ }
1127
+
1128
+ // ── String/cursor helpers ─────────────────────────────────────────────────────
1129
+
1130
+ fn prev_char_len(s: &str, pos: usize) -> usize {
1131
+ s[..pos].chars().next_back().map(|c| c.len_utf8()).unwrap_or(0)
1132
+ }
1133
+
1134
+ fn next_char_len(s: &str, pos: usize) -> usize {
1135
+ s[pos..].chars().next().map(|c| c.len_utf8()).unwrap_or(0)
1136
+ }
1137
+
1138
+ fn move_cursor_left(s: &str, pos: &mut usize) {
1139
+ *pos = pos.saturating_sub(prev_char_len(s, *pos));
1140
+ }
1141
+
1142
+ fn move_cursor_right(s: &str, pos: &mut usize) {
1143
+ *pos = (*pos + next_char_len(s, *pos)).min(s.len());
1144
+ }
1145
+
1146
+ fn delete_word_before(s: &mut String, pos: &mut usize) {
1147
+ while *pos > 0 && s[..*pos].ends_with(|c: char| c == ' ' || c == '\n') {
1148
+ let len = prev_char_len(s, *pos);
1149
+ let start = *pos - len;
1150
+ s.drain(start..*pos);
1151
+ *pos = start;
1152
+ }
1153
+ while *pos > 0 && !s[..*pos].ends_with(|c: char| c == ' ' || c == '\n') {
1154
+ let len = prev_char_len(s, *pos);
1155
+ let start = *pos - len;
1156
+ s.drain(start..*pos);
1157
+ *pos = start;
1158
+ }
1159
+ }