anveesa 0.3.6 → 0.3.7

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