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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/lib.rs +2 -3
- package/src/tui.rs +502 -430
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,40 @@ 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
|
-
Tool {
|
|
43
|
-
|
|
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
|
-
// ──
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
107
|
-
key_rx: mpsc::UnboundedReceiver<
|
|
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
|
-
|
|
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<
|
|
145
|
+
key_rx: mpsc::UnboundedReceiver<Event>,
|
|
128
146
|
) -> Self {
|
|
129
147
|
let (stream_tx, stream_rx) = mpsc::unbounded_channel();
|
|
130
|
-
let
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
241
|
+
// ── Event handling ────────────────────────────────────────────────────────────
|
|
228
242
|
|
|
229
|
-
async fn
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
284
|
+
// ── Streaming mode: scroll only ───────────────────────────────────────────
|
|
254
285
|
if app.mode == Mode::Streaming {
|
|
255
|
-
// Allow scrolling during stream
|
|
256
286
|
match code {
|
|
257
|
-
KeyCode::PageUp => {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
308
|
-
let start = app.input_cursor -
|
|
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
|
|
318
|
-
app.input.drain(app.input_cursor..app.input_cursor +
|
|
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
|
-
|
|
324
|
-
KeyCode::
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
match
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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.
|
|
431
|
-
|
|
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 (
|
|
571
|
+
let (stream_tx_inner, stream_rx_inner) = mpsc::unbounded_channel::<StreamEvent>();
|
|
448
572
|
|
|
449
|
-
// Clone
|
|
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.
|
|
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
|
|
588
|
+
workspace_context,
|
|
463
589
|
history,
|
|
464
590
|
image,
|
|
465
591
|
};
|
|
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
|
-
}
|
|
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
|
-
//
|
|
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)); }
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
}
|
|
535
|
-
app.
|
|
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
|
-
//
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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 = (
|
|
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),
|
|
621
|
-
Constraint::Min(3),
|
|
622
|
-
Constraint::Length(input_height),
|
|
623
|
-
Constraint::Length(1),
|
|
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
|
|
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
|
-
|
|
641
|
-
Style::default()
|
|
642
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
677
|
-
let color = if
|
|
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
|
-
|
|
681
|
-
|
|
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::
|
|
687
|
-
lines.push(Line::from(
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
//
|
|
699
|
-
if
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
|
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(
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
|
899
|
+
let label_w = label.chars().count();
|
|
751
900
|
let display = format!("{label}{}", app.input);
|
|
752
901
|
|
|
753
|
-
|
|
754
|
-
.style(Style::default().fg(Color::White))
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
760
|
-
let
|
|
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 +
|
|
764
|
-
inner.y +
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
840
|
-
if
|
|
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
|
-
"
|
|
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 =
|
|
851
|
-
let
|
|
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!(" ┌─{
|
|
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
|
-
|
|
866
|
-
out.push(highlighted);
|
|
989
|
+
out.push(highlight_code_line(raw, &code_lang));
|
|
867
990
|
} else {
|
|
868
|
-
|
|
869
|
-
|
|
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![
|
|
994
|
+
vec![raw.to_string()]
|
|
873
995
|
};
|
|
874
|
-
for l in
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
|
989
|
-
let
|
|
990
|
-
|
|
991
|
-
|
|
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
|
|
1089
|
+
let mut in_string = false;
|
|
1090
|
+
let mut string_char = '"';
|
|
999
1091
|
|
|
1000
|
-
let
|
|
1001
|
-
if
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
|
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(
|
|
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
|
|
1028
|
-
if c == '/' && chars.peek() == Some(&'/') {
|
|
1029
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1053
|
-
let mut num =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1072
|
-
spans.push(Span::styled(c.to_string(),
|
|
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
|
-
|
|
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
|
-
));
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
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);
|