anveesa 0.3.7 → 0.3.9

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