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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/lib.rs +2 -3
- package/src/tui.rs +459 -422
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
|
|
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,
|
|
22
|
+
StreamEvent, ToolConfirmPreview, Usage,
|
|
19
23
|
},
|
|
20
24
|
};
|
|
21
25
|
|
|
22
|
-
// ── Public event type
|
|
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
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
107
|
-
key_rx: mpsc::UnboundedReceiver<
|
|
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
|
-
|
|
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<
|
|
139
|
+
key_rx: mpsc::UnboundedReceiver<Event>,
|
|
128
140
|
) -> Self {
|
|
129
141
|
let (stream_tx, stream_rx) = mpsc::unbounded_channel();
|
|
130
|
-
let
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
234
|
+
// ── Event handling ────────────────────────────────────────────────────────────
|
|
228
235
|
|
|
229
|
-
async fn
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
277
|
+
// ── Streaming mode: scroll only ───────────────────────────────────────────
|
|
254
278
|
if app.mode == Mode::Streaming {
|
|
255
|
-
// Allow scrolling during stream
|
|
256
279
|
match code {
|
|
257
|
-
KeyCode::PageUp => {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
308
|
-
let start = app.input_cursor -
|
|
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
|
|
318
|
-
app.input.drain(app.input_cursor..app.input_cursor +
|
|
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
|
-
|
|
324
|
-
KeyCode::
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
match
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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.
|
|
431
|
-
|
|
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 (
|
|
564
|
+
let (stream_tx_inner, stream_rx_inner) = mpsc::unbounded_channel::<StreamEvent>();
|
|
448
565
|
|
|
449
|
-
// Clone
|
|
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.
|
|
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
|
|
581
|
+
workspace_context,
|
|
463
582
|
history,
|
|
464
583
|
image,
|
|
465
584
|
};
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
//
|
|
484
|
-
|
|
485
|
-
let
|
|
486
|
-
|
|
487
|
-
let
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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 = (
|
|
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),
|
|
621
|
-
Constraint::Min(3),
|
|
622
|
-
Constraint::Length(input_height),
|
|
623
|
-
Constraint::Length(1),
|
|
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
|
|
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
|
-
|
|
641
|
-
Style::default()
|
|
642
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
681
|
-
|
|
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::
|
|
687
|
-
lines.push(Line::from(
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
//
|
|
807
|
+
// In-progress streaming
|
|
699
808
|
if !app.streaming_buf.is_empty() || app.mode == Mode::Streaming {
|
|
700
|
-
lines.push(
|
|
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
|
|
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(
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
|
864
|
+
let label_w = label.chars().count();
|
|
751
865
|
let display = format!("{label}{}", app.input);
|
|
752
866
|
|
|
753
|
-
|
|
754
|
-
.style(Style::default().fg(Color::White))
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
760
|
-
let
|
|
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 +
|
|
764
|
-
inner.y +
|
|
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
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
format!(" ⚠ Allow: {summary}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
840
|
-
if
|
|
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
|
-
"
|
|
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 =
|
|
851
|
-
let
|
|
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!(" ┌─{
|
|
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
|
-
|
|
866
|
-
out.push(highlighted);
|
|
954
|
+
out.push(highlight_code_line(raw, &code_lang));
|
|
867
955
|
} else {
|
|
868
|
-
|
|
869
|
-
|
|
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![
|
|
959
|
+
vec![raw.to_string()]
|
|
873
960
|
};
|
|
874
|
-
for l in
|
|
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
|
-
|
|
909
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
|
989
|
-
let
|
|
990
|
-
|
|
991
|
-
|
|
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
|
|
1054
|
+
let mut in_string = false;
|
|
1055
|
+
let mut string_char = '"';
|
|
999
1056
|
|
|
1000
|
-
let
|
|
1001
|
-
if
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
|
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(
|
|
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
|
|
1028
|
-
if c == '/' && chars.peek() == Some(&'/') {
|
|
1029
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1053
|
-
let mut num =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1072
|
-
spans.push(Span::styled(c.to_string(),
|
|
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
|
-
|
|
1076
|
-
|
|
1077
|
-
//
|
|
1078
|
-
let
|
|
1079
|
-
if
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
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);
|