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