|
1
|
//! Full-screen terminal chat UI. |
|
2
|
//! |
|
3
|
//! Two phases: a loading spinner while the model initializes, then |
|
4
|
//! interactive chat. Uses `tokio::select!` to multiplex terminal events |
|
5
|
//! with streaming LLM tokens. |
|
6
|
|
|
7
|
// ── Think-block stripping ───────────────────────────────────────────────────── |
|
8
|
|
|
9
|
/// Split out `<think>…</think>` blocks from a model response. |
|
10
|
/// |
|
11
|
/// Qwen 3 emits reasoning inside `<think>` tags before the actual answer. |
|
12
|
/// Returns `(thinking_text, visible_reply)`. Either may be empty. |
|
13
|
pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) { |
|
14
|
let mut thinking = String::new(); |
|
15
|
let mut remainder = raw; |
|
16
|
|
|
17
|
while let Some(start) = remainder.find("<think>") { |
|
18
|
let before = &remainder[..start]; |
|
19
|
if let Some(end) = remainder[start..].find("</think>") { |
|
20
|
let block = &remainder[start + 7..start + end]; |
|
21
|
thinking.push_str(block.trim()); |
|
22
|
remainder = &remainder[start + end + 8..]; |
|
23
|
if !before.trim().is_empty() { |
|
24
|
// rare: text before <think> — keep it visible |
|
25
|
let mut combined = before.to_string(); |
|
26
|
combined.push_str(remainder); |
|
27
|
return (thinking, combined.trim().to_string()); |
|
28
|
} |
|
29
|
} else { |
|
30
|
// unclosed tag — model probably ran out of tokens |
|
31
|
thinking.push_str(remainder[start + 7..].trim()); |
|
32
|
remainder = before; |
|
33
|
break; |
|
34
|
} |
|
35
|
} |
|
36
|
|
|
37
|
(thinking, remainder.trim().to_string()) |
|
38
|
} |
|
39
|
|
|
40
|
// ── Unix-only TUI ───────────────────────────────────────────────────────────── |
|
41
|
// |
|
42
|
// macOS + Linux only. Windows uses ACP mode instead. |
|
43
|
|
|
44
|
#[cfg(unix)] |
|
45
|
mod tui { |
|
46
|
use std::future::pending; |
|
47
|
use std::sync::Arc; |
|
48
|
use std::sync::mpsc as std_mpsc; |
|
49
|
|
|
50
|
use anyhow::Result; |
|
51
|
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; |
|
52
|
use futures::StreamExt; |
|
53
|
use onde::inference::{ChatEngine, SamplingConfig, StreamChunk, ToolDefinition, ToolResult}; |
|
54
|
|
|
55
|
use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items}; |
|
56
|
use ratatui::{ |
|
57
|
Frame, |
|
58
|
layout::{Constraint, Layout, Position}, |
|
59
|
style::{Color, Modifier, Style}, |
|
60
|
text::{Line, Span}, |
|
61
|
widgets::{Block, Borders, Clear, Paragraph, Wrap}, |
|
62
|
}; |
|
63
|
use tokio::sync::mpsc; |
|
64
|
use tokio::time::{Duration, Instant, interval}; |
|
65
|
|
|
66
|
// ── Message types ───────────────────────────────────────────────────────── |
|
67
|
|
|
68
|
#[derive(Clone, Copy, PartialEq, Eq)] |
|
69
|
enum Role { |
|
70
|
User, |
|
71
|
Assistant, |
|
72
|
System, |
|
73
|
/// rainbow-colored banner art |
|
74
|
Banner, |
|
75
|
} |
|
76
|
|
|
77
|
struct ChatMessage { |
|
78
|
role: Role, |
|
79
|
text: String, |
|
80
|
/// Qwen 3 reasoning extracted from `<think>` tags, if any. |
|
81
|
think_block: Option<String>, |
|
82
|
} |
|
83
|
|
|
84
|
impl ChatMessage { |
|
85
|
fn user(text: impl Into<String>) -> Self { |
|
86
|
Self { |
|
87
|
role: Role::User, |
|
88
|
text: text.into(), |
|
89
|
think_block: None, |
|
90
|
} |
|
91
|
} |
|
92
|
|
|
93
|
fn assistant(text: impl Into<String>) -> Self { |
|
94
|
let raw = text.into(); |
|
95
|
let (think, visible) = super::strip_think_blocks(&raw); |
|
96
|
Self { |
|
97
|
role: Role::Assistant, |
|
98
|
text: visible, |
|
99
|
think_block: if think.is_empty() { None } else { Some(think) }, |
|
100
|
} |
|
101
|
} |
|
102
|
|
|
103
|
fn system(text: impl Into<String>) -> Self { |
|
104
|
Self { |
|
105
|
role: Role::System, |
|
106
|
text: text.into(), |
|
107
|
think_block: None, |
|
108
|
} |
|
109
|
} |
|
110
|
|
|
111
|
fn banner(text: impl Into<String>) -> Self { |
|
112
|
Self { |
|
113
|
role: Role::Banner, |
|
114
|
text: text.into(), |
|
115
|
think_block: None, |
|
116
|
} |
|
117
|
} |
|
118
|
} |
|
119
|
|
|
120
|
// ── Inference updates from background task ──────────────────────────────── |
|
121
|
|
|
122
|
enum InferenceUpdate { |
|
123
|
/// show tool name in chat while it runs |
|
124
|
ToolUse(String), |
|
125
|
Response(String), |
|
126
|
Error(String), |
|
127
|
} |
|
128
|
|
|
129
|
enum ModelLoadUpdate { |
|
130
|
Loaded(String), |
|
131
|
Error(String), |
|
132
|
} |
|
133
|
|
|
134
|
// ── App state ───────────────────────────────────────────────────────────── |
|
135
|
|
|
136
|
struct App { |
|
137
|
messages: Vec<ChatMessage>, |
|
138
|
input: String, |
|
139
|
cursor: usize, |
|
140
|
scroll_offset: u16, |
|
141
|
stream_rx: Option<mpsc::Receiver<StreamChunk>>, |
|
142
|
stream_buf: String, |
|
143
|
inference_rx: Option<mpsc::Receiver<InferenceUpdate>>, |
|
144
|
model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>, |
|
145
|
thinking: bool, |
|
146
|
thinking_tick: u8, |
|
147
|
quit: bool, |
|
148
|
/// toggled periodically so the streaming cursor blinks |
|
149
|
blink_on: bool, |
|
150
|
blink_counter: u8, |
|
151
|
switching_model: bool, |
|
152
|
/// stashed until ModelLoadUpdate::Loaded applies it to `app.tool_calling` |
|
153
|
pending_tool_calling: Option<bool>, |
|
154
|
/// suppresses the spurious "disconnected" error when we drop model_load_rx on cancel |
|
155
|
model_load_cancelled: bool, |
|
156
|
|
|
157
|
// ── Loading-phase state ─────────────────────────────────────────────── |
|
158
|
is_loading: bool, |
|
159
|
load_tick: u32, |
|
160
|
/// keeps the loading view visible so the user can read the error |
|
161
|
load_error: Option<String>, |
|
162
|
load_start: Instant, |
|
163
|
load_model_name: String, |
|
164
|
|
|
165
|
// ── Model picker state ──────────────────────────────────────────────── |
|
166
|
show_model_picker: bool, |
|
167
|
model_picker_index: usize, |
|
168
|
model_picker_items: Vec<ModelPickerItem>, |
|
169
|
current_model_name: String, |
|
170
|
tool_calling: bool, |
|
171
|
|
|
172
|
// ── Model-switch download progress ──────────────────────────────────── |
|
173
|
switching_model_id: Option<String>, |
|
174
|
/// (downloaded, expected) bytes — polled every tick during a model switch |
|
175
|
download_progress: Option<(u64, u64)>, |
|
176
|
} |
|
177
|
|
|
178
|
const BANNER_ART: &str = "\ |
|
179
|
77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777 |
|
180
|
77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777 |
|
181
|
1111111125555555555555555555555511113222311159 5002 088 3081771691111111111111 |
|
182
|
1111111111111111111111111111131136841 1482853332007 05 9043332891 400811111111111 |
|
183
|
1111111111111111111111111111111201 109 304 40 00 79 100041111111111 |
|
184
|
333333255555555555555555555552392 102 503 90 7000000005 903 0000023333333333 |
|
185
|
333333245454545454545454545433381 7600000 302 61 780 109 20009533333333333 |
|
186
|
3333333333333333333333333333333402 7001 08 761 202 902 90003333333333333 |
|
187
|
2222255555555555555555555555250899901 49 304 403 08 108 300042222222222222 |
|
188
|
2222222222222222222222222222269 106 03 901 06 505 402 000052222222222222 |
|
189
|
2222255555555555555555555555299 708 1002 80 00 90852222222222222 |
|
190
|
55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555 |
|
191
|
88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888"; |
|
192
|
|
|
193
|
const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; |
|
194
|
|
|
195
|
impl App { |
|
196
|
fn new(load_model_name: String) -> Self { |
|
197
|
let items = build_model_picker_items(); |
|
198
|
let tool_calling = items |
|
199
|
.iter() |
|
200
|
.find(|m| m.display_name == load_model_name) |
|
201
|
.map(|m| m.tool_calling) |
|
202
|
.unwrap_or(true); |
|
203
|
Self { |
|
204
|
messages: Vec::new(), |
|
205
|
input: String::new(), |
|
206
|
cursor: 0, |
|
207
|
scroll_offset: 0, |
|
208
|
stream_rx: None, |
|
209
|
stream_buf: String::new(), |
|
210
|
inference_rx: None, |
|
211
|
model_load_rx: None, |
|
212
|
thinking: false, |
|
213
|
thinking_tick: 0, |
|
214
|
quit: false, |
|
215
|
blink_on: true, |
|
216
|
blink_counter: 0, |
|
217
|
switching_model: false, |
|
218
|
pending_tool_calling: None, |
|
219
|
model_load_cancelled: false, |
|
220
|
switching_model_id: None, |
|
221
|
download_progress: None, |
|
222
|
is_loading: true, |
|
223
|
load_tick: 0, |
|
224
|
load_error: None, |
|
225
|
load_start: Instant::now(), |
|
226
|
load_model_name: load_model_name.clone(), |
|
227
|
show_model_picker: false, |
|
228
|
model_picker_index: 0, |
|
229
|
model_picker_items: items, |
|
230
|
current_model_name: crate::setup::load_selected_model_name() |
|
231
|
.unwrap_or_else(|| load_model_name.clone()), |
|
232
|
tool_calling, |
|
233
|
} |
|
234
|
} |
|
235
|
|
|
236
|
fn is_busy(&self) -> bool { |
|
237
|
self.is_streaming() || self.thinking || self.switching_model |
|
238
|
} |
|
239
|
|
|
240
|
fn switching_frame(&self) -> &'static str { |
|
241
|
let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len(); |
|
242
|
THINKING_FRAMES[idx] |
|
243
|
} |
|
244
|
|
|
245
|
fn is_streaming(&self) -> bool { |
|
246
|
self.stream_rx.is_some() |
|
247
|
} |
|
248
|
|
|
249
|
fn finalize_stream(&mut self) { |
|
250
|
self.stream_rx = None; |
|
251
|
if !self.stream_buf.is_empty() { |
|
252
|
let text = std::mem::take(&mut self.stream_buf); |
|
253
|
self.messages.push(ChatMessage::assistant(text)); |
|
254
|
} |
|
255
|
self.blink_on = false; |
|
256
|
} |
|
257
|
|
|
258
|
fn push_stream_delta(&mut self, delta: &str) { |
|
259
|
self.stream_buf.push_str(delta); |
|
260
|
self.blink_counter = self.blink_counter.wrapping_add(1); |
|
261
|
self.blink_on = self.blink_counter % 4 < 2; |
|
262
|
} |
|
263
|
|
|
264
|
fn start_thinking(&mut self) { |
|
265
|
self.thinking = true; |
|
266
|
self.thinking_tick = 0; |
|
267
|
} |
|
268
|
|
|
269
|
fn stop_thinking(&mut self) { |
|
270
|
self.thinking = false; |
|
271
|
self.inference_rx = None; |
|
272
|
} |
|
273
|
|
|
274
|
fn tick_thinking(&mut self) { |
|
275
|
self.thinking_tick = self.thinking_tick.wrapping_add(1); |
|
276
|
} |
|
277
|
|
|
278
|
fn thinking_frame(&self) -> &'static str { |
|
279
|
let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len(); |
|
280
|
THINKING_FRAMES[idx] |
|
281
|
} |
|
282
|
|
|
283
|
fn tick(&mut self) { |
|
284
|
self.load_tick = self.load_tick.wrapping_add(1); |
|
285
|
} |
|
286
|
|
|
287
|
/// check how much of the model has landed on disk so far |
|
288
|
fn poll_download_progress(&mut self) { |
|
289
|
let Some(ref model_id) = self.switching_model_id else { |
|
290
|
return; |
|
291
|
}; |
|
292
|
let cache_path = onde::hf_cache::model_cache_path(model_id); |
|
293
|
let downloaded = cache_path |
|
294
|
.as_ref() |
|
295
|
.filter(|p| p.exists()) |
|
296
|
.map(|p| dir_size_recursive(p)) |
|
297
|
.unwrap_or(0); |
|
298
|
let expected = onde::inference::models::SUPPORTED_MODEL_INFO |
|
299
|
.iter() |
|
300
|
.find(|m| m.id == model_id.as_str()) |
|
301
|
.map(|m| m.expected_size_bytes) |
|
302
|
.unwrap_or(0); |
|
303
|
self.download_progress = Some((downloaded, expected)); |
|
304
|
} |
|
305
|
|
|
306
|
/// switch to chat phase and show the welcome banner |
|
307
|
fn finish_loading(&mut self) { |
|
308
|
self.is_loading = false; |
|
309
|
for line in BANNER_ART.lines() { |
|
310
|
self.messages.push(ChatMessage::banner(line)); |
|
311
|
} |
|
312
|
self.messages.push(ChatMessage::system("")); |
|
313
|
self.messages.push(ChatMessage::system( |
|
314
|
"In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit", |
|
315
|
)); |
|
316
|
self.messages.push(ChatMessage::system(format!( |
|
317
|
"Current model: {}", |
|
318
|
self.current_model_name |
|
319
|
))); |
|
320
|
self.messages |
|
321
|
.push(ChatMessage::system("Type /help for commands.")); |
|
322
|
} |
|
323
|
|
|
324
|
/// store the error but stay in loading view so the user can read it |
|
325
|
fn set_load_error(&mut self, error: String) { |
|
326
|
self.load_error = Some(error); |
|
327
|
// is_loading stays true so render_loading() keeps rendering. |
|
328
|
} |
|
329
|
|
|
330
|
fn open_model_picker(&mut self, engine: &ChatEngine) { |
|
331
|
let current = crate::setup::load_selected_model(); |
|
332
|
let current_name = crate::setup::load_selected_model_name().unwrap_or_else(|| { |
|
333
|
futures::executor::block_on(engine.info()) |
|
334
|
.model_name |
|
335
|
.unwrap_or_else(|| self.current_model_name.clone()) |
|
336
|
}); |
|
337
|
|
|
338
|
self.model_picker_items = build_model_picker_items(); |
|
339
|
self.model_picker_index = current |
|
340
|
.as_ref() |
|
341
|
.and_then(|selected| { |
|
342
|
self.model_picker_items.iter().position(|item| { |
|
343
|
item.config.model_id == selected.model_id |
|
344
|
&& item |
|
345
|
.config |
|
346
|
.files |
|
347
|
.iter() |
|
348
|
.any(|file| file == &selected.gguf_file) |
|
349
|
}) |
|
350
|
}) |
|
351
|
.or_else(|| { |
|
352
|
self.model_picker_items |
|
353
|
.iter() |
|
354
|
.position(|item| item.display_name == current_name) |
|
355
|
}) |
|
356
|
.unwrap_or(0); |
|
357
|
self.show_model_picker = true; |
|
358
|
} |
|
359
|
|
|
360
|
fn close_model_picker(&mut self) { |
|
361
|
self.show_model_picker = false; |
|
362
|
} |
|
363
|
|
|
364
|
fn move_model_picker_up(&mut self) { |
|
365
|
if self.model_picker_items.is_empty() { |
|
366
|
return; |
|
367
|
} |
|
368
|
if self.model_picker_index == 0 { |
|
369
|
self.model_picker_index = self.model_picker_items.len().saturating_sub(1); |
|
370
|
} else { |
|
371
|
self.model_picker_index -= 1; |
|
372
|
} |
|
373
|
} |
|
374
|
|
|
375
|
fn move_model_picker_down(&mut self) { |
|
376
|
if self.model_picker_items.is_empty() { |
|
377
|
return; |
|
378
|
} |
|
379
|
self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len(); |
|
380
|
} |
|
381
|
|
|
382
|
/// rough line count for scroll math |
|
383
|
fn total_message_lines(&self, width: u16) -> u16 { |
|
384
|
if width == 0 { |
|
385
|
return 0; |
|
386
|
} |
|
387
|
let w = width.saturating_sub(2) as usize; |
|
388
|
let mut lines: u16 = 0; |
|
389
|
for msg in &self.messages { |
|
390
|
lines += wrapped_line_count(&msg.text, msg.role, w); |
|
391
|
} |
|
392
|
if !self.stream_buf.is_empty() { |
|
393
|
lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w); |
|
394
|
} |
|
395
|
if self.thinking || self.switching_model { |
|
396
|
lines += 1; |
|
397
|
} |
|
398
|
lines |
|
399
|
} |
|
400
|
|
|
401
|
fn auto_scroll(&mut self, visible_height: u16, width: u16) { |
|
402
|
let total = self.total_message_lines(width); |
|
403
|
if total > visible_height { |
|
404
|
self.scroll_offset = total - visible_height; |
|
405
|
} else { |
|
406
|
self.scroll_offset = 0; |
|
407
|
} |
|
408
|
} |
|
409
|
} |
|
410
|
|
|
411
|
fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 { |
|
412
|
let prefix_len = match role { |
|
413
|
Role::User => 6, // "you > " |
|
414
|
Role::Assistant => 8, // "siGit > " |
|
415
|
Role::System | Role::Banner => 0, |
|
416
|
}; |
|
417
|
let effective = if width > prefix_len { |
|
418
|
width - prefix_len |
|
419
|
} else { |
|
420
|
1 |
|
421
|
}; |
|
422
|
|
|
423
|
let mut count: u16 = 0; |
|
424
|
for line in text.split('\n') { |
|
425
|
if line.is_empty() { |
|
426
|
count += 1; |
|
427
|
} else { |
|
428
|
count += ((line.len() as f64) / (effective as f64)).ceil() as u16; |
|
429
|
} |
|
430
|
} |
|
431
|
count.max(1) |
|
432
|
} |
|
433
|
|
|
434
|
// ── Model picker ───────────────────────────────────────────────────────── |
|
435
|
// |
|
436
|
// picker data types live in crate::models so Windows (ACP-only) can use them too |
|
437
|
|
|
438
|
fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { |
|
439
|
let popup = centered_rect(82, 72, area); |
|
440
|
|
|
441
|
// clear the background so text doesn't bleed through |
|
442
|
frame.render_widget(Clear, popup); |
|
443
|
|
|
444
|
let block = Block::default() |
|
445
|
.title(" Select a model… ") |
|
446
|
.borders(Borders::ALL) |
|
447
|
.border_style(Style::default().fg(Color::DarkGray)) |
|
448
|
.style(Style::default().bg(Color::Black)); |
|
449
|
|
|
450
|
let inner = block.inner(popup); |
|
451
|
frame.render_widget(block, popup); |
|
452
|
|
|
453
|
let mut lines = Vec::new(); |
|
454
|
let mut last_section: Option<ModelSource> = None; |
|
455
|
|
|
456
|
for (index, item) in app.model_picker_items.iter().enumerate() { |
|
457
|
if last_section != Some(item.source) { |
|
458
|
if last_section.is_some() { |
|
459
|
lines.push(Line::from("").style(Style::default().bg(Color::Black))); |
|
460
|
} |
|
461
|
|
|
462
|
let (section_mark, section_name, section_style) = match item.source { |
|
463
|
ModelSource::Onde => ( |
|
464
|
"◉", |
|
465
|
"Onde Inference", |
|
466
|
Style::default() |
|
467
|
.fg(Color::Green) |
|
468
|
.bg(Color::Black) |
|
469
|
.add_modifier(Modifier::BOLD), |
|
470
|
), |
|
471
|
ModelSource::HuggingFace => ( |
|
472
|
"○", |
|
473
|
"Hugging Face cache", |
|
474
|
Style::default() |
|
475
|
.fg(Color::Cyan) |
|
476
|
.bg(Color::Black) |
|
477
|
.add_modifier(Modifier::BOLD), |
|
478
|
), |
|
479
|
ModelSource::Available => ( |
|
480
|
"↓", |
|
481
|
"Available for download", |
|
482
|
Style::default() |
|
483
|
.fg(Color::Blue) |
|
484
|
.bg(Color::Black) |
|
485
|
.add_modifier(Modifier::BOLD), |
|
486
|
), |
|
487
|
ModelSource::Fallback => ( |
|
488
|
"◎", |
|
489
|
"Fallback", |
|
490
|
Style::default() |
|
491
|
.fg(Color::Yellow) |
|
492
|
.bg(Color::Black) |
|
493
|
.add_modifier(Modifier::BOLD), |
|
494
|
), |
|
495
|
}; |
|
496
|
|
|
497
|
lines.push( |
|
498
|
Line::from(vec![ |
|
499
|
Span::styled(format!("{section_mark} "), section_style), |
|
500
|
Span::styled(section_name, section_style), |
|
501
|
]) |
|
502
|
.style(Style::default().bg(Color::Black)), |
|
503
|
); |
|
504
|
last_section = Some(item.source); |
|
505
|
} |
|
506
|
|
|
507
|
let selected = index == app.model_picker_index; |
|
508
|
let current = item.display_name == app.current_model_name; |
|
509
|
let marker = if selected { "› " } else { " " }; |
|
510
|
let tool_badge = if item.tool_calling { |
|
511
|
" ✓ tool calling" |
|
512
|
} else { |
|
513
|
"" |
|
514
|
}; |
|
515
|
let health_badge = match item.cache_health { |
|
516
|
ModelCacheHealth::Complete => "", |
|
517
|
ModelCacheHealth::Incomplete => " ! incomplete cache", |
|
518
|
ModelCacheHealth::NotDownloaded => " ↓ download", |
|
519
|
}; |
|
520
|
let current_badge = if current { " ← current" } else { "" }; |
|
521
|
let disabled_badge = match item.cache_health { |
|
522
|
ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "", |
|
523
|
ModelCacheHealth::Incomplete => " (unselectable)", |
|
524
|
}; |
|
525
|
let brand_mark = match item.source { |
|
526
|
ModelSource::Onde => "◉", |
|
527
|
ModelSource::HuggingFace => "○", |
|
528
|
ModelSource::Available => "↓", |
|
529
|
ModelSource::Fallback => "◎", |
|
530
|
}; |
|
531
|
let source = format!(" [{} {}]", brand_mark, item.source_label); |
|
532
|
|
|
533
|
let base_style = if selected { |
|
534
|
Style::default().fg(Color::Black).bg(Color::Green) |
|
535
|
} else { |
|
536
|
Style::default().fg(Color::White).bg(Color::Black) |
|
537
|
}; |
|
538
|
|
|
539
|
let source_style = if selected { |
|
540
|
Style::default().fg(Color::Black).bg(Color::Green) |
|
541
|
} else { |
|
542
|
match item.source { |
|
543
|
ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black), |
|
544
|
ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black), |
|
545
|
ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black), |
|
546
|
ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black), |
|
547
|
} |
|
548
|
}; |
|
549
|
|
|
550
|
let health_style = if selected { |
|
551
|
Style::default().fg(Color::Red).bg(Color::Green) |
|
552
|
} else { |
|
553
|
Style::default().fg(Color::Red).bg(Color::Black) |
|
554
|
}; |
|
555
|
|
|
556
|
lines.push(Line::from(vec![ |
|
557
|
Span::styled( |
|
558
|
format!("{marker}{} {}", item.display_name, item.description), |
|
559
|
base_style, |
|
560
|
), |
|
561
|
Span::styled( |
|
562
|
tool_badge.to_string(), |
|
563
|
if selected { |
|
564
|
Style::default().fg(Color::Black).bg(Color::Green) |
|
565
|
} else { |
|
566
|
Style::default().fg(Color::Green).bg(Color::Black) |
|
567
|
}, |
|
568
|
), |
|
569
|
Span::styled(health_badge.to_string(), health_style), |
|
570
|
Span::styled( |
|
571
|
disabled_badge.to_string(), |
|
572
|
if selected { |
|
573
|
Style::default().fg(Color::Black).bg(Color::Green) |
|
574
|
} else { |
|
575
|
Style::default().fg(Color::DarkGray).bg(Color::Black) |
|
576
|
}, |
|
577
|
), |
|
578
|
Span::styled( |
|
579
|
current_badge.to_string(), |
|
580
|
if selected { |
|
581
|
Style::default().fg(Color::Black).bg(Color::Green) |
|
582
|
} else { |
|
583
|
Style::default().fg(Color::Cyan).bg(Color::Black) |
|
584
|
}, |
|
585
|
), |
|
586
|
Span::styled(source, source_style), |
|
587
|
])); |
|
588
|
} |
|
589
|
|
|
590
|
frame.render_widget( |
|
591
|
Paragraph::new(lines) |
|
592
|
.wrap(Wrap { trim: false }) |
|
593
|
.style(Style::default().bg(Color::Black)), |
|
594
|
inner, |
|
595
|
); |
|
596
|
} |
|
597
|
|
|
598
|
fn centered_rect( |
|
599
|
percent_x: u16, |
|
600
|
percent_y: u16, |
|
601
|
area: ratatui::layout::Rect, |
|
602
|
) -> ratatui::layout::Rect { |
|
603
|
let vertical = Layout::vertical([ |
|
604
|
Constraint::Percentage((100 - percent_y) / 2), |
|
605
|
Constraint::Percentage(percent_y), |
|
606
|
Constraint::Percentage((100 - percent_y) / 2), |
|
607
|
]) |
|
608
|
.split(area); |
|
609
|
|
|
610
|
Layout::horizontal([ |
|
611
|
Constraint::Percentage((100 - percent_x) / 2), |
|
612
|
Constraint::Percentage(percent_x), |
|
613
|
Constraint::Percentage((100 - percent_x) / 2), |
|
614
|
]) |
|
615
|
.split(vertical[1])[1] |
|
616
|
} |
|
617
|
|
|
618
|
// ── Slash commands ──────────────────────────────────────────────────────── |
|
619
|
|
|
620
|
enum SlashCommand { |
|
621
|
Help, |
|
622
|
Clear, |
|
623
|
Status, |
|
624
|
/// picker UI, or jump straight to model N |
|
625
|
Models(Option<usize>), |
|
626
|
Exit, |
|
627
|
Unknown(String), |
|
628
|
} |
|
629
|
|
|
630
|
fn parse_slash(input: &str) -> Option<SlashCommand> { |
|
631
|
let trimmed = input.trim(); |
|
632
|
if !trimmed.starts_with('/') { |
|
633
|
return None; |
|
634
|
} |
|
635
|
let mut parts = trimmed.splitn(2, char::is_whitespace); |
|
636
|
let cmd = parts.next().unwrap_or(""); |
|
637
|
let arg = parts.next().map(|s| s.trim()); |
|
638
|
Some(match cmd { |
|
639
|
"/help" => SlashCommand::Help, |
|
640
|
"/clear" => SlashCommand::Clear, |
|
641
|
"/status" => SlashCommand::Status, |
|
642
|
"/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())), |
|
643
|
"/exit" | "/quit" | "/q" => SlashCommand::Exit, |
|
644
|
other => SlashCommand::Unknown(other.to_string()), |
|
645
|
}) |
|
646
|
} |
|
647
|
|
|
648
|
// ── Rendering ───────────────────────────────────────────────────────────── |
|
649
|
|
|
650
|
fn render(frame: &mut Frame, app: &mut App) { |
|
651
|
let area = frame.area(); |
|
652
|
|
|
653
|
if app.is_loading { |
|
654
|
let zones = Layout::vertical([ |
|
655
|
Constraint::Length(1), |
|
656
|
Constraint::Min(1), |
|
657
|
Constraint::Length(1), |
|
658
|
]) |
|
659
|
.split(area); |
|
660
|
render_loading_title(frame, app, zones[0]); |
|
661
|
render_loading(frame, app, zones[1]); |
|
662
|
render_loading_footer(frame, zones[2]); |
|
663
|
return; |
|
664
|
} |
|
665
|
|
|
666
|
let zones = Layout::vertical([ |
|
667
|
Constraint::Length(1), |
|
668
|
Constraint::Min(1), |
|
669
|
Constraint::Length(3), |
|
670
|
Constraint::Length(1), |
|
671
|
]) |
|
672
|
.split(area); |
|
673
|
|
|
674
|
render_title(frame, app, zones[0]); |
|
675
|
render_messages(frame, app, zones[1]); |
|
676
|
render_input(frame, app, zones[2]); |
|
677
|
render_footer(frame, app, zones[3]); |
|
678
|
|
|
679
|
if app.show_model_picker { |
|
680
|
render_model_picker(frame, app, area); |
|
681
|
} |
|
682
|
} |
|
683
|
|
|
684
|
fn render_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { |
|
685
|
let model_label = format!(" siGit — {} ", app.current_model_name); |
|
686
|
let tool_label = if app.tool_calling { |
|
687
|
" [tools on] " |
|
688
|
} else { |
|
689
|
" [tools off] " |
|
690
|
}; |
|
691
|
let line = Line::from(vec![ |
|
692
|
Span::styled( |
|
693
|
model_label, |
|
694
|
Style::default() |
|
695
|
.fg(Color::Black) |
|
696
|
.bg(Color::Green) |
|
697
|
.add_modifier(Modifier::BOLD), |
|
698
|
), |
|
699
|
Span::styled( |
|
700
|
tool_label, |
|
701
|
Style::default().fg(Color::Black).bg(Color::DarkGray), |
|
702
|
), |
|
703
|
]); |
|
704
|
frame.render_widget(Paragraph::new(line), area); |
|
705
|
} |
|
706
|
|
|
707
|
fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { |
|
708
|
const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]; |
|
709
|
let spin = SPINNER[(app.load_tick as usize) % SPINNER.len()]; |
|
710
|
let label = format!(" siGit {} loading {}… ", spin, app.load_model_name); |
|
711
|
let line = Line::from(Span::styled( |
|
712
|
label, |
|
713
|
Style::default() |
|
714
|
.fg(Color::Black) |
|
715
|
.bg(Color::Green) |
|
716
|
.add_modifier(Modifier::BOLD), |
|
717
|
)); |
|
718
|
frame.render_widget(Paragraph::new(line), area); |
|
719
|
} |
|
720
|
|
|
721
|
fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { |
|
722
|
let elapsed = app.load_start.elapsed().as_secs(); |
|
723
|
let elapsed_str = if elapsed < 60 { |
|
724
|
format!("{}s", elapsed) |
|
725
|
} else { |
|
726
|
format!("{}m {}s", elapsed / 60, elapsed % 60) |
|
727
|
}; |
|
728
|
|
|
729
|
let content = if let Some(ref err) = app.load_error { |
|
730
|
format!( |
|
731
|
"\n\n ✗ Failed to load model after {}.\n\n {}\n\n Press Ctrl+C to exit.", |
|
732
|
elapsed_str, err |
|
733
|
) |
|
734
|
} else { |
|
735
|
format!( |
|
736
|
"\n\n Loading model, please wait… ({})\n\n The model is being initialised. This may take a moment on first run.", |
|
737
|
elapsed_str |
|
738
|
) |
|
739
|
}; |
|
740
|
|
|
741
|
let style = if app.load_error.is_some() { |
|
742
|
Style::default().fg(Color::Red) |
|
743
|
} else { |
|
744
|
Style::default().fg(Color::White) |
|
745
|
}; |
|
746
|
|
|
747
|
frame.render_widget( |
|
748
|
Paragraph::new(content) |
|
749
|
.style(style) |
|
750
|
.wrap(Wrap { trim: false }), |
|
751
|
area, |
|
752
|
); |
|
753
|
} |
|
754
|
|
|
755
|
fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) { |
|
756
|
let line = Line::from(vec![ |
|
757
|
Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)), |
|
758
|
Span::styled(" quit", Style::default().fg(Color::DarkGray)), |
|
759
|
]); |
|
760
|
frame.render_widget(Paragraph::new(line), area); |
|
761
|
} |
|
762
|
|
|
763
|
fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { |
|
764
|
let inner_width = area.width.saturating_sub(2); |
|
765
|
let inner_height = area.height.saturating_sub(2); |
|
766
|
|
|
767
|
app.auto_scroll(inner_height, area.width); |
|
768
|
|
|
769
|
let block = Block::default() |
|
770
|
.borders(Borders::ALL) |
|
771
|
.border_style(Style::default().fg(Color::DarkGray)); |
|
772
|
|
|
773
|
let inner = block.inner(area); |
|
774
|
frame.render_widget(block, area); |
|
775
|
|
|
776
|
let mut lines: Vec<Line> = Vec::new(); |
|
777
|
|
|
778
|
for msg in &app.messages { |
|
779
|
render_chat_message(&mut lines, msg, inner_width as usize); |
|
780
|
} |
|
781
|
|
|
782
|
if !app.stream_buf.is_empty() { |
|
783
|
let fake = ChatMessage { |
|
784
|
role: Role::Assistant, |
|
785
|
text: app.stream_buf.clone(), |
|
786
|
think_block: None, |
|
787
|
}; |
|
788
|
render_chat_message(&mut lines, &fake, inner_width as usize); |
|
789
|
if app.blink_on |
|
790
|
&& let Some(last) = lines.last_mut() |
|
791
|
{ |
|
792
|
last.spans |
|
793
|
.push(Span::styled("▋", Style::default().fg(Color::Green))); |
|
794
|
} |
|
795
|
} |
|
796
|
|
|
797
|
if app.thinking { |
|
798
|
lines.push(Line::from(Span::styled( |
|
799
|
format!(" {} thinking…", app.thinking_frame()), |
|
800
|
Style::default().fg(Color::DarkGray), |
|
801
|
))); |
|
802
|
} else if app.switching_model { |
|
803
|
let frame_str = app.switching_frame(); |
|
804
|
let progress_str = if let Some((downloaded, expected)) = app.download_progress { |
|
805
|
if expected > 0 { |
|
806
|
let pct = (downloaded as f64 / expected as f64 * 100.0).min(100.0) as u8; |
|
807
|
let dl_str = format_size_human(downloaded); |
|
808
|
let ex_str = format_size_human(expected); |
|
809
|
format!(" — {dl_str} / {ex_str} ({pct}%)") |
|
810
|
} else if downloaded > 0 { |
|
811
|
format!(" — {} downloaded", format_size_human(downloaded)) |
|
812
|
} else { |
|
813
|
String::new() |
|
814
|
} |
|
815
|
} else { |
|
816
|
String::new() |
|
817
|
}; |
|
818
|
lines.push(Line::from(Span::styled( |
|
819
|
format!(" {frame_str} switching model{progress_str}…"), |
|
820
|
Style::default().fg(Color::DarkGray), |
|
821
|
))); |
|
822
|
} |
|
823
|
|
|
824
|
let total_lines = lines.len() as u16; |
|
825
|
let scroll = if total_lines > inner_height { |
|
826
|
app.scroll_offset.min(total_lines - inner_height) |
|
827
|
} else { |
|
828
|
0 |
|
829
|
}; |
|
830
|
|
|
831
|
frame.render_widget( |
|
832
|
Paragraph::new(lines) |
|
833
|
.scroll((scroll, 0)) |
|
834
|
.wrap(Wrap { trim: false }), |
|
835
|
inner, |
|
836
|
); |
|
837
|
} |
|
838
|
|
|
839
|
fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) { |
|
840
|
match msg.role { |
|
841
|
Role::Banner => { |
|
842
|
let palette = [ |
|
843
|
Color::Red, |
|
844
|
Color::Yellow, |
|
845
|
Color::Green, |
|
846
|
Color::Cyan, |
|
847
|
Color::Blue, |
|
848
|
Color::Magenta, |
|
849
|
]; |
|
850
|
let mut spans = Vec::new(); |
|
851
|
for (i, ch) in msg.text.chars().enumerate() { |
|
852
|
let color = palette[i % palette.len()]; |
|
853
|
spans.push(Span::styled(ch.to_string(), Style::default().fg(color))); |
|
854
|
} |
|
855
|
lines.push(Line::from(spans)); |
|
856
|
} |
|
857
|
Role::System => { |
|
858
|
for text_line in msg.text.split('\n') { |
|
859
|
let trimmed = text_line.trim(); |
|
860
|
let (prefix, body) = if trimmed.is_empty() { |
|
861
|
("", "") |
|
862
|
} else { |
|
863
|
(" · ", trimmed) |
|
864
|
}; |
|
865
|
|
|
866
|
lines.push(Line::from(vec![ |
|
867
|
Span::styled( |
|
868
|
prefix.to_string(), |
|
869
|
Style::default() |
|
870
|
.fg(Color::Rgb(90, 90, 98)) |
|
871
|
.add_modifier(Modifier::DIM), |
|
872
|
), |
|
873
|
Span::styled( |
|
874
|
body.to_string(), |
|
875
|
Style::default() |
|
876
|
.fg(Color::Rgb(132, 132, 145)) |
|
877
|
.add_modifier(Modifier::ITALIC | Modifier::DIM), |
|
878
|
), |
|
879
|
])); |
|
880
|
} |
|
881
|
} |
|
882
|
Role::User => { |
|
883
|
let prefix = Span::styled( |
|
884
|
"you > ".to_string(), |
|
885
|
Style::default() |
|
886
|
.fg(Color::Green) |
|
887
|
.add_modifier(Modifier::BOLD), |
|
888
|
); |
|
889
|
let mut first = true; |
|
890
|
for text_line in msg.text.split('\n') { |
|
891
|
if first { |
|
892
|
lines.push(Line::from(vec![ |
|
893
|
prefix.clone(), |
|
894
|
Span::raw(text_line.to_string()), |
|
895
|
])); |
|
896
|
first = false; |
|
897
|
} else { |
|
898
|
lines.push(Line::from(Span::raw(format!(" {text_line}")))); |
|
899
|
} |
|
900
|
} |
|
901
|
} |
|
902
|
Role::Assistant => { |
|
903
|
if let Some(ref think) = msg.think_block { |
|
904
|
lines.push(Line::from(Span::styled( |
|
905
|
" ┌ thinking ".to_string(), |
|
906
|
Style::default().fg(Color::DarkGray), |
|
907
|
))); |
|
908
|
for think_line in think.split('\n') { |
|
909
|
lines.push(Line::from(Span::styled( |
|
910
|
format!(" │ {think_line}"), |
|
911
|
Style::default().fg(Color::DarkGray), |
|
912
|
))); |
|
913
|
} |
|
914
|
lines.push(Line::from(Span::styled( |
|
915
|
" └─────────".to_string(), |
|
916
|
Style::default().fg(Color::DarkGray), |
|
917
|
))); |
|
918
|
} |
|
919
|
|
|
920
|
let prefix = Span::styled( |
|
921
|
"siGit > ".to_string(), |
|
922
|
Style::default() |
|
923
|
.fg(Color::Cyan) |
|
924
|
.add_modifier(Modifier::BOLD), |
|
925
|
); |
|
926
|
let mut first = true; |
|
927
|
for text_line in msg.text.split('\n') { |
|
928
|
if first { |
|
929
|
lines.push(Line::from(vec![ |
|
930
|
prefix.clone(), |
|
931
|
Span::raw(text_line.to_string()), |
|
932
|
])); |
|
933
|
first = false; |
|
934
|
} else { |
|
935
|
lines.push(Line::from(Span::raw(format!(" {text_line}")))); |
|
936
|
} |
|
937
|
} |
|
938
|
} |
|
939
|
} |
|
940
|
} |
|
941
|
|
|
942
|
fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { |
|
943
|
let block = Block::default() |
|
944
|
.borders(Borders::ALL) |
|
945
|
.border_style(Style::default().fg(Color::DarkGray)) |
|
946
|
.title(" message "); |
|
947
|
|
|
948
|
let inner = block.inner(area); |
|
949
|
frame.render_widget(block, area); |
|
950
|
|
|
951
|
let display = app.input.clone(); |
|
952
|
frame.render_widget( |
|
953
|
Paragraph::new(display.clone()).wrap(Wrap { trim: false }), |
|
954
|
inner, |
|
955
|
); |
|
956
|
|
|
957
|
let col = (app.cursor as u16) % inner.width; |
|
958
|
let row = (app.cursor as u16) / inner.width; |
|
959
|
frame.set_cursor_position(Position { |
|
960
|
x: inner.x + col, |
|
961
|
y: inner.y + row, |
|
962
|
}); |
|
963
|
} |
|
964
|
|
|
965
|
fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { |
|
966
|
let mut spans = vec![ |
|
967
|
Span::styled( |
|
968
|
" Enter ", |
|
969
|
Style::default().fg(Color::Black).bg(Color::Green), |
|
970
|
), |
|
971
|
Span::styled(" send ", Style::default().fg(Color::DarkGray)), |
|
972
|
Span::styled( |
|
973
|
" /help ", |
|
974
|
Style::default().fg(Color::Black).bg(Color::DarkGray), |
|
975
|
), |
|
976
|
Span::styled(" commands ", Style::default().fg(Color::DarkGray)), |
|
977
|
Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)), |
|
978
|
Span::styled(" quit", Style::default().fg(Color::DarkGray)), |
|
979
|
]; |
|
980
|
|
|
981
|
if app.thinking || app.switching_model || app.is_streaming() { |
|
982
|
spans.push(Span::styled( |
|
983
|
" (busy — Ctrl+C to cancel)", |
|
984
|
Style::default().fg(Color::Yellow), |
|
985
|
)); |
|
986
|
} |
|
987
|
|
|
988
|
frame.render_widget(Paragraph::new(Line::from(spans)), area); |
|
989
|
} |
|
990
|
|
|
991
|
fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> { |
|
992
|
if key.kind != KeyEventKind::Press { |
|
993
|
return None; |
|
994
|
} |
|
995
|
|
|
996
|
if app.show_model_picker { |
|
997
|
match key.code { |
|
998
|
KeyCode::Esc => { |
|
999
|
app.close_model_picker(); |
|
1000
|
return None; |
|
1001
|
} |
|
1002
|
KeyCode::Up => { |
|
1003
|
app.move_model_picker_up(); |
|
1004
|
return None; |
|
1005
|
} |
|
1006
|
KeyCode::Down => { |
|
1007
|
app.move_model_picker_down(); |
|
1008
|
return None; |
|
1009
|
} |
|
1010
|
KeyCode::Enter => { |
|
1011
|
return Some(format!("/models {}", app.model_picker_index + 1)); |
|
1012
|
} |
|
1013
|
_ => return None, |
|
1014
|
} |
|
1015
|
} |
|
1016
|
|
|
1017
|
match key.code { |
|
1018
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
|
1019
|
app.quit = true; |
|
1020
|
None |
|
1021
|
} |
|
1022
|
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
|
1023
|
app.quit = true; |
|
1024
|
None |
|
1025
|
} |
|
1026
|
KeyCode::Enter => { |
|
1027
|
if app.input.trim().is_empty() { |
|
1028
|
return None; |
|
1029
|
} |
|
1030
|
let text = app.input.drain(..).collect::<String>(); |
|
1031
|
app.cursor = 0; |
|
1032
|
Some(text) |
|
1033
|
} |
|
1034
|
KeyCode::Backspace => { |
|
1035
|
if app.cursor > 0 { |
|
1036
|
app.cursor -= 1; |
|
1037
|
app.input.remove(app.cursor); |
|
1038
|
} |
|
1039
|
None |
|
1040
|
} |
|
1041
|
KeyCode::Delete => { |
|
1042
|
if app.cursor < app.input.len() { |
|
1043
|
app.input.remove(app.cursor); |
|
1044
|
} |
|
1045
|
None |
|
1046
|
} |
|
1047
|
KeyCode::Left => { |
|
1048
|
app.cursor = app.cursor.saturating_sub(1); |
|
1049
|
None |
|
1050
|
} |
|
1051
|
KeyCode::Right => { |
|
1052
|
if app.cursor < app.input.len() { |
|
1053
|
app.cursor += 1; |
|
1054
|
} |
|
1055
|
None |
|
1056
|
} |
|
1057
|
KeyCode::Home => { |
|
1058
|
app.cursor = 0; |
|
1059
|
None |
|
1060
|
} |
|
1061
|
KeyCode::End => { |
|
1062
|
app.cursor = app.input.len(); |
|
1063
|
None |
|
1064
|
} |
|
1065
|
KeyCode::Char(ch) => { |
|
1066
|
app.input.insert(app.cursor, ch); |
|
1067
|
app.cursor += 1; |
|
1068
|
None |
|
1069
|
} |
|
1070
|
_ => None, |
|
1071
|
} |
|
1072
|
} |
|
1073
|
|
|
1074
|
// ── Slash command execution ─────────────────────────────────────────────── |
|
1075
|
|
|
1076
|
async fn exec_slash<B: ratatui::backend::Backend>( |
|
1077
|
app: &mut App, |
|
1078
|
cmd: SlashCommand, |
|
1079
|
engine: Arc<ChatEngine>, |
|
1080
|
terminal: &mut ratatui::Terminal<B>, |
|
1081
|
) { |
|
1082
|
match cmd { |
|
1083
|
SlashCommand::Help => { |
|
1084
|
app.messages.push(ChatMessage::system( |
|
1085
|
"/help — show this message\n\ |
|
1086
|
/models — open the model picker\n\ |
|
1087
|
/models N — switch to model N\n\ |
|
1088
|
/clear — wipe conversation history\n\ |
|
1089
|
/status — show engine status\n\ |
|
1090
|
/exit — quit chat", |
|
1091
|
)); |
|
1092
|
} |
|
1093
|
SlashCommand::Clear => { |
|
1094
|
let cleared = engine.clear_history().await; |
|
1095
|
app.messages.clear(); |
|
1096
|
app.scroll_offset = 0; |
|
1097
|
app.messages.push(ChatMessage::system(format!( |
|
1098
|
"Cleared {cleared} turn(s). History is empty.", |
|
1099
|
))); |
|
1100
|
} |
|
1101
|
SlashCommand::Status => { |
|
1102
|
let info = engine.as_ref().info().await; |
|
1103
|
let model = info.model_name.as_deref().unwrap_or("(none)"); |
|
1104
|
let mem = info.approx_memory.as_deref().unwrap_or("unknown"); |
|
1105
|
app.messages.push(ChatMessage::system(format!( |
|
1106
|
"status: {:?} model: {} memory: {} history: {} turns", |
|
1107
|
info.status, model, mem, info.history_length, |
|
1108
|
))); |
|
1109
|
} |
|
1110
|
SlashCommand::Models(selection) => match selection { |
|
1111
|
None => { |
|
1112
|
app.open_model_picker(&engine); |
|
1113
|
} |
|
1114
|
Some(n) => { |
|
1115
|
let idx = n.saturating_sub(1); |
|
1116
|
match app.model_picker_items.get(idx).cloned() { |
|
1117
|
None => { |
|
1118
|
app.messages.push(ChatMessage::system(format!( |
|
1119
|
"error: no model #{n} — type /models to see the list." |
|
1120
|
))); |
|
1121
|
} |
|
1122
|
Some(model) => { |
|
1123
|
if model.cache_health == ModelCacheHealth::Incomplete { |
|
1124
|
app.close_model_picker(); |
|
1125
|
app.messages.push(ChatMessage::system(format!( |
|
1126
|
"error: {} has an incomplete local cache and cannot be selected yet.", |
|
1127
|
model.display_name |
|
1128
|
))); |
|
1129
|
return; |
|
1130
|
} |
|
1131
|
|
|
1132
|
let loading_msg = if model.cache_health |
|
1133
|
== ModelCacheHealth::NotDownloaded |
|
1134
|
{ |
|
1135
|
format!( |
|
1136
|
"Downloading and loading {} ({})… this may take a few minutes.", |
|
1137
|
model.display_name, model.description |
|
1138
|
) |
|
1139
|
} else { |
|
1140
|
format!("Loading {}…", model.display_name) |
|
1141
|
}; |
|
1142
|
|
|
1143
|
app.close_model_picker(); |
|
1144
|
app.messages.push(ChatMessage::system(loading_msg)); |
|
1145
|
terminal.draw(|frame| render(frame, app)).ok(); |
|
1146
|
|
|
1147
|
let (tx, rx) = mpsc::channel(1); |
|
1148
|
app.model_load_rx = Some(rx); |
|
1149
|
app.switching_model = true; |
|
1150
|
app.switching_model_id = Some(model.config.model_id.clone()); |
|
1151
|
// Only show download progress for models not yet cached. |
|
1152
|
app.download_progress = |
|
1153
|
if model.cache_health == ModelCacheHealth::NotDownloaded { |
|
1154
|
Some((0, 0)) |
|
1155
|
} else { |
|
1156
|
None |
|
1157
|
}; |
|
1158
|
|
|
1159
|
let sampling = SamplingConfig { |
|
1160
|
max_tokens: Some(model.max_tokens), |
|
1161
|
..SamplingConfig::default() |
|
1162
|
}; |
|
1163
|
|
|
1164
|
// own thread + runtime so block_in_place doesn't starve the TUI loop |
|
1165
|
let system_prompt = crate::system_prompt_for_model(model.tool_calling); |
|
1166
|
let engine_handle = Arc::clone(&engine); |
|
1167
|
let tool_calling = model.tool_calling; |
|
1168
|
std::thread::spawn(move || { |
|
1169
|
let rt = tokio::runtime::Runtime::new() |
|
1170
|
.expect("failed to create model-loader runtime"); |
|
1171
|
let update = rt.block_on(async move { |
|
1172
|
match engine_handle |
|
1173
|
.load_gguf_model( |
|
1174
|
model.config.clone(), |
|
1175
|
Some(system_prompt.to_string()), |
|
1176
|
Some(sampling), |
|
1177
|
) |
|
1178
|
.await |
|
1179
|
{ |
|
1180
|
Ok(_) => { |
|
1181
|
ModelLoadUpdate::Loaded(model.display_name.clone()) |
|
1182
|
} |
|
1183
|
Err(err) => ModelLoadUpdate::Error(err.to_string()), |
|
1184
|
} |
|
1185
|
}); |
|
1186
|
// capacity-1 channel, receiver alive while switching |
|
1187
|
let _ = tx.blocking_send(update); |
|
1188
|
}); |
|
1189
|
// applied on ModelLoadUpdate::Loaded |
|
1190
|
app.pending_tool_calling = Some(tool_calling); |
|
1191
|
} |
|
1192
|
} |
|
1193
|
} |
|
1194
|
}, |
|
1195
|
SlashCommand::Exit => { |
|
1196
|
app.quit = true; |
|
1197
|
} |
|
1198
|
SlashCommand::Unknown(cmd) => { |
|
1199
|
app.messages |
|
1200
|
.push(ChatMessage::system(format!("unknown command: {cmd}"))); |
|
1201
|
} |
|
1202
|
} |
|
1203
|
} |
|
1204
|
|
|
1205
|
// ── Background inference task ───────────────────────────────────────────── |
|
1206
|
|
|
1207
|
/// cap tool rounds so a confused model can't loop forever |
|
1208
|
const MAX_TOOL_ROUNDS: usize = 10; |
|
1209
|
|
|
1210
|
fn build_onde_tools() -> Vec<ToolDefinition> { |
|
1211
|
crate::tools::all_tools() |
|
1212
|
.into_iter() |
|
1213
|
.map(|t| ToolDefinition { |
|
1214
|
name: t.name.to_string(), |
|
1215
|
description: t.description.to_string(), |
|
1216
|
parameters_schema: t.parameters_schema.to_string(), |
|
1217
|
}) |
|
1218
|
.collect() |
|
1219
|
} |
|
1220
|
|
|
1221
|
/// run the tool-calling loop off the main thread, posting updates via `tx`. |
|
1222
|
/// dropping `tx` signals completion to the event loop. |
|
1223
|
async fn run_inference_task( |
|
1224
|
engine: Arc<ChatEngine>, |
|
1225
|
text: String, |
|
1226
|
tx: mpsc::Sender<InferenceUpdate>, |
|
1227
|
tools_enabled: bool, |
|
1228
|
) { |
|
1229
|
let onde_tools = if tools_enabled { |
|
1230
|
build_onde_tools() |
|
1231
|
} else { |
|
1232
|
vec![] |
|
1233
|
}; |
|
1234
|
|
|
1235
|
let mut result = match engine.send_message_with_tools(&text, &onde_tools).await { |
|
1236
|
Ok(r) => r, |
|
1237
|
Err(err) => { |
|
1238
|
let _ = tx.send(InferenceUpdate::Error(err.to_string())).await; |
|
1239
|
return; |
|
1240
|
} |
|
1241
|
}; |
|
1242
|
|
|
1243
|
let mut round = 0; |
|
1244
|
|
|
1245
|
while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS { |
|
1246
|
round += 1; |
|
1247
|
log::info!("tool round {} — {} call(s)", round, result.tool_calls.len()); |
|
1248
|
|
|
1249
|
let mut tool_results = Vec::new(); |
|
1250
|
|
|
1251
|
for tc in &result.tool_calls { |
|
1252
|
log::info!( |
|
1253
|
" → {}({})", |
|
1254
|
tc.function_name, |
|
1255
|
tc.arguments.chars().take(120).collect::<String>() |
|
1256
|
); |
|
1257
|
|
|
1258
|
let _ = tx |
|
1259
|
.send(InferenceUpdate::ToolUse(tc.function_name.clone())) |
|
1260
|
.await; |
|
1261
|
|
|
1262
|
let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments).await; |
|
1263
|
log::info!(" ← {} chars", output.len()); |
|
1264
|
|
|
1265
|
tool_results.push(ToolResult { |
|
1266
|
tool_call_id: tc.id.clone(), |
|
1267
|
content: output, |
|
1268
|
}); |
|
1269
|
} |
|
1270
|
|
|
1271
|
// on the last round, pass no tools so the model must produce text |
|
1272
|
let next_tools = if round < MAX_TOOL_ROUNDS { |
|
1273
|
Some(onde_tools.as_slice()) |
|
1274
|
} else { |
|
1275
|
None |
|
1276
|
}; |
|
1277
|
|
|
1278
|
match engine.send_tool_results(tool_results, next_tools).await { |
|
1279
|
Ok(r) => result = r, |
|
1280
|
Err(err) => { |
|
1281
|
let _ = tx.send(InferenceUpdate::Error(err.to_string())).await; |
|
1282
|
return; |
|
1283
|
} |
|
1284
|
} |
|
1285
|
} |
|
1286
|
|
|
1287
|
if result.tool_calls.is_empty() { |
|
1288
|
if result.text.is_empty() { |
|
1289
|
log::warn!( |
|
1290
|
"model returned empty reply — may have exhausted max_tokens on thinking" |
|
1291
|
); |
|
1292
|
let _ = tx |
|
1293
|
.send(InferenceUpdate::Error( |
|
1294
|
"(empty response — the model may have used all tokens on internal reasoning. \ |
|
1295
|
Try a shorter or simpler prompt.)" |
|
1296
|
.to_string(), |
|
1297
|
)) |
|
1298
|
.await; |
|
1299
|
} else { |
|
1300
|
let _ = tx.send(InferenceUpdate::Response(result.text)).await; |
|
1301
|
} |
|
1302
|
} |
|
1303
|
|
|
1304
|
log::info!("inference complete — {} tool round(s)", round); |
|
1305
|
// tx drops here — event loop gets None from rx.recv() |
|
1306
|
} |
|
1307
|
|
|
1308
|
// ── Main loop ───────────────────────────────────────────────────────────── |
|
1309
|
|
|
1310
|
/// entry point — blocks until the user quits. |
|
1311
|
/// caller owns terminal init/restore. `load_rx` delivers the model-load result |
|
1312
|
/// from a dedicated OS thread; we poll it non-blocking each tick. |
|
1313
|
pub async fn run_with<B: ratatui::backend::Backend>( |
|
1314
|
terminal: &mut ratatui::Terminal<B>, |
|
1315
|
engine: Arc<ChatEngine>, |
|
1316
|
load_rx: std_mpsc::Receiver<Result<(), String>>, |
|
1317
|
load_model_name: String, |
|
1318
|
) -> Result<()> { |
|
1319
|
event_loop(terminal, engine, load_rx, load_model_name).await |
|
1320
|
} |
|
1321
|
|
|
1322
|
async fn event_loop<B: ratatui::backend::Backend>( |
|
1323
|
terminal: &mut ratatui::Terminal<B>, |
|
1324
|
engine: Arc<ChatEngine>, |
|
1325
|
load_rx: std_mpsc::Receiver<Result<(), String>>, |
|
1326
|
load_model_name: String, |
|
1327
|
) -> Result<()> { |
|
1328
|
let mut app = App::new(load_model_name); |
|
1329
|
let mut event_stream = EventStream::new(); |
|
1330
|
|
|
1331
|
// 10 fps is plenty for spinners |
|
1332
|
let mut ticker = interval(Duration::from_millis(100)); |
|
1333
|
|
|
1334
|
loop { |
|
1335
|
// ── Poll the loader channel (non-blocking) ──────────────────────── |
|
1336
|
if app.is_loading { |
|
1337
|
match load_rx.try_recv() { |
|
1338
|
Ok(Ok(())) => app.finish_loading(), |
|
1339
|
Ok(Err(e)) => app.set_load_error(e), |
|
1340
|
Err(std_mpsc::TryRecvError::Empty) => {} |
|
1341
|
Err(std_mpsc::TryRecvError::Disconnected) => { |
|
1342
|
app.set_load_error("Model loader thread crashed.".to_string()); |
|
1343
|
} |
|
1344
|
} |
|
1345
|
} |
|
1346
|
|
|
1347
|
// redraw every iteration |
|
1348
|
terminal.draw(|frame| render(frame, &mut app))?; |
|
1349
|
|
|
1350
|
if let Some(rx) = app.model_load_rx.as_mut() { |
|
1351
|
match rx.try_recv() { |
|
1352
|
Ok(ModelLoadUpdate::Loaded(model_name)) => { |
|
1353
|
engine.clear_history().await; |
|
1354
|
if let Some(tc) = app.pending_tool_calling.take() { |
|
1355
|
app.tool_calling = tc; |
|
1356
|
} |
|
1357
|
app.switching_model = false; |
|
1358
|
app.switching_model_id = None; |
|
1359
|
app.download_progress = None; |
|
1360
|
app.model_load_cancelled = false; |
|
1361
|
app.model_load_rx = None; |
|
1362
|
app.current_model_name = model_name.clone(); |
|
1363
|
|
|
1364
|
let save_result = app |
|
1365
|
.model_picker_items |
|
1366
|
.iter() |
|
1367
|
.find(|item| item.display_name == model_name) |
|
1368
|
.map(|item| crate::setup::SelectedModel { |
|
1369
|
model_id: item.config.model_id.clone(), |
|
1370
|
gguf_file: item |
|
1371
|
.config |
|
1372
|
.files |
|
1373
|
.first() |
|
1374
|
.cloned() |
|
1375
|
.unwrap_or_else(String::new), |
|
1376
|
}) |
|
1377
|
.filter(|selected| !selected.gguf_file.is_empty()) |
|
1378
|
.map(|selected| crate::setup::save_selected_model(&selected)) |
|
1379
|
.unwrap_or_else(|| { |
|
1380
|
Err(format!( |
|
1381
|
"could not determine a stable identifier for {}", |
|
1382
|
model_name |
|
1383
|
)) |
|
1384
|
}); |
|
1385
|
|
|
1386
|
if let Err(error) = save_result { |
|
1387
|
app.messages.push(ChatMessage::system(format!( |
|
1388
|
"warning: switched to {} but could not save the selection: {}", |
|
1389
|
model_name, error |
|
1390
|
))); |
|
1391
|
} else { |
|
1392
|
app.messages |
|
1393
|
.push(ChatMessage::system(format!("✓ Switched to {}", model_name))); |
|
1394
|
} |
|
1395
|
} |
|
1396
|
Ok(ModelLoadUpdate::Error(error)) => { |
|
1397
|
app.switching_model = false; |
|
1398
|
app.switching_model_id = None; |
|
1399
|
app.download_progress = None; |
|
1400
|
app.model_load_cancelled = false; |
|
1401
|
app.model_load_rx = None; |
|
1402
|
app.messages |
|
1403
|
.push(ChatMessage::system(format!("error loading model: {error}"))); |
|
1404
|
} |
|
1405
|
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} |
|
1406
|
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { |
|
1407
|
let was_cancelled = app.model_load_cancelled; |
|
1408
|
app.switching_model = false; |
|
1409
|
app.switching_model_id = None; |
|
1410
|
app.download_progress = None; |
|
1411
|
app.model_load_cancelled = false; |
|
1412
|
app.model_load_rx = None; |
|
1413
|
if !was_cancelled { |
|
1414
|
app.messages.push(ChatMessage::system( |
|
1415
|
"error loading model: loader task disconnected".to_string(), |
|
1416
|
)); |
|
1417
|
} |
|
1418
|
} |
|
1419
|
} |
|
1420
|
} |
|
1421
|
|
|
1422
|
if app.quit { |
|
1423
|
break; |
|
1424
|
} |
|
1425
|
|
|
1426
|
// multiplex terminal events, streaming tokens, inference updates, |
|
1427
|
// and the thinking-spinner timer. |
|
1428
|
tokio::select! { |
|
1429
|
biased; |
|
1430
|
|
|
1431
|
// ── Spinner tick (loading phase only) ───────────────────────── |
|
1432
|
_ = ticker.tick(), if app.is_loading => { |
|
1433
|
app.tick(); |
|
1434
|
} |
|
1435
|
|
|
1436
|
// ── Streaming LLM tokens ────────────────────────────────────── |
|
1437
|
chunk = async { |
|
1438
|
match app.stream_rx.as_mut() { |
|
1439
|
Some(rx) => rx.recv().await, |
|
1440
|
None => pending().await, |
|
1441
|
} |
|
1442
|
} => { |
|
1443
|
match chunk { |
|
1444
|
Some(chunk) => { |
|
1445
|
if !chunk.delta.is_empty() { |
|
1446
|
app.push_stream_delta(&chunk.delta); |
|
1447
|
} |
|
1448
|
if chunk.done { |
|
1449
|
app.finalize_stream(); |
|
1450
|
} |
|
1451
|
} |
|
1452
|
// sender dropped without done=true |
|
1453
|
None => { |
|
1454
|
app.finalize_stream(); |
|
1455
|
} |
|
1456
|
} |
|
1457
|
} |
|
1458
|
|
|
1459
|
// ── inference updates from background task ─────────────────── |
|
1460
|
update = async { |
|
1461
|
match app.inference_rx.as_mut() { |
|
1462
|
Some(rx) => rx.recv().await, |
|
1463
|
None => pending().await, |
|
1464
|
} |
|
1465
|
} => { |
|
1466
|
match update { |
|
1467
|
Some(InferenceUpdate::ToolUse(name)) => { |
|
1468
|
app.messages.push(ChatMessage::system(format!("🔧 {name}"))); |
|
1469
|
} |
|
1470
|
Some(InferenceUpdate::Response(text)) => { |
|
1471
|
app.stop_thinking(); |
|
1472
|
app.messages.push(ChatMessage::assistant(text)); |
|
1473
|
} |
|
1474
|
Some(InferenceUpdate::Error(msg)) => { |
|
1475
|
app.stop_thinking(); |
|
1476
|
app.messages.push(ChatMessage::system(format!("error: {msg}"))); |
|
1477
|
} |
|
1478
|
None => { |
|
1479
|
// task finished, possibly with no text to show |
|
1480
|
app.stop_thinking(); |
|
1481
|
} |
|
1482
|
} |
|
1483
|
} |
|
1484
|
|
|
1485
|
// ── thinking / switching spinner tick (100ms) ──────────────── |
|
1486
|
_ = async { |
|
1487
|
if app.thinking || app.switching_model { |
|
1488
|
tokio::time::sleep(Duration::from_millis(100)).await |
|
1489
|
} else { |
|
1490
|
pending().await |
|
1491
|
} |
|
1492
|
} => { |
|
1493
|
app.tick_thinking(); |
|
1494
|
// keep the progress display fresh |
|
1495
|
if app.switching_model { |
|
1496
|
app.poll_download_progress(); |
|
1497
|
} |
|
1498
|
} |
|
1499
|
|
|
1500
|
// ── Terminal events ─────────────────────────────────────────── |
|
1501
|
maybe_event = event_stream.next() => { |
|
1502
|
let Some(Ok(event)) = maybe_event else { |
|
1503
|
break; |
|
1504
|
}; |
|
1505
|
|
|
1506
|
if let Event::Key(key) = event { |
|
1507
|
// loading phase — only quit keys work |
|
1508
|
if app.is_loading { |
|
1509
|
if key.kind == KeyEventKind::Press { |
|
1510
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); |
|
1511
|
if ctrl |
|
1512
|
&& (key.code == KeyCode::Char('c') |
|
1513
|
|| key.code == KeyCode::Char('d')) |
|
1514
|
{ |
|
1515
|
app.quit = true; |
|
1516
|
} |
|
1517
|
} |
|
1518
|
continue; |
|
1519
|
} |
|
1520
|
|
|
1521
|
// busy — only cancel keys work |
|
1522
|
if app.is_busy() { |
|
1523
|
if key.kind == KeyEventKind::Press { |
|
1524
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); |
|
1525
|
if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) { |
|
1526
|
if app.is_streaming() { |
|
1527
|
app.finalize_stream(); |
|
1528
|
app.messages.push(ChatMessage::system("(cancelled)")); |
|
1529
|
} |
|
1530
|
if app.thinking { |
|
1531
|
// dropping rx kills the background task |
|
1532
|
app.stop_thinking(); |
|
1533
|
app.messages.push(ChatMessage::system("(cancelled)")); |
|
1534
|
} |
|
1535
|
if app.switching_model { |
|
1536
|
// flag before drop so Disconnected handler stays quiet |
|
1537
|
app.model_load_cancelled = true; |
|
1538
|
app.switching_model = false; |
|
1539
|
app.switching_model_id = None; |
|
1540
|
app.download_progress = None; |
|
1541
|
app.model_load_rx = None; |
|
1542
|
app.messages |
|
1543
|
.push(ChatMessage::system("(download cancelled — model switch aborted)")); |
|
1544
|
} |
|
1545
|
} |
|
1546
|
} |
|
1547
|
continue; |
|
1548
|
} |
|
1549
|
|
|
1550
|
if let Some(text) = handle_key(&mut app, key) { |
|
1551
|
if let Some(cmd) = parse_slash(&text) { |
|
1552
|
exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await; |
|
1553
|
continue; |
|
1554
|
} |
|
1555
|
|
|
1556
|
// ── spawn inference ────────────────────────────── |
|
1557
|
app.messages.push(ChatMessage::user(&text)); |
|
1558
|
app.start_thinking(); |
|
1559
|
|
|
1560
|
let (tx, rx) = mpsc::channel::<InferenceUpdate>(64); |
|
1561
|
app.inference_rx = Some(rx); |
|
1562
|
|
|
1563
|
let engine_handle = Arc::clone(&engine); |
|
1564
|
let user_text = text.clone(); |
|
1565
|
let tools_enabled = app.tool_calling; |
|
1566
|
tokio::spawn(async move { |
|
1567
|
run_inference_task(engine_handle, user_text, tx, tools_enabled).await; |
|
1568
|
}); |
|
1569
|
} |
|
1570
|
} |
|
1571
|
} |
|
1572
|
} |
|
1573
|
} |
|
1574
|
|
|
1575
|
Ok(()) |
|
1576
|
} |
|
1577
|
|
|
1578
|
// ── Download progress helpers (TUI) ────────────────────────────────────── |
|
1579
|
|
|
1580
|
/// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks) |
|
1581
|
fn dir_size_recursive(path: &std::path::Path) -> u64 { |
|
1582
|
let mut total: u64 = 0; |
|
1583
|
let Ok(entries) = std::fs::read_dir(path) else { |
|
1584
|
return 0; |
|
1585
|
}; |
|
1586
|
for entry in entries.flatten() { |
|
1587
|
let entry_path = entry.path(); |
|
1588
|
if entry_path.is_dir() { |
|
1589
|
total += dir_size_recursive(&entry_path); |
|
1590
|
} else if let Ok(meta) = entry_path.metadata() { |
|
1591
|
total += meta.len(); |
|
1592
|
} |
|
1593
|
} |
|
1594
|
total |
|
1595
|
} |
|
1596
|
|
|
1597
|
fn format_size_human(bytes: u64) -> String { |
|
1598
|
const GB: u64 = 1_073_741_824; |
|
1599
|
const MB: u64 = 1_048_576; |
|
1600
|
const KB: u64 = 1_024; |
|
1601
|
if bytes >= GB { |
|
1602
|
format!("{:.2} GB", bytes as f64 / GB as f64) |
|
1603
|
} else if bytes >= MB { |
|
1604
|
format!("{:.1} MB", bytes as f64 / MB as f64) |
|
1605
|
} else if bytes >= KB { |
|
1606
|
format!("{:.0} KB", bytes as f64 / KB as f64) |
|
1607
|
} else { |
|
1608
|
format!("{bytes} B") |
|
1609
|
} |
|
1610
|
} |
|
1611
|
} // end #[cfg(unix)] mod tui |
|
1612
|
|
|
1613
|
// re-export so callers write `chat::run_with(...)` on all platforms |
|
1614
|
#[cfg(unix)] |
|
1615
|
pub use tui::run_with; |
|
1616
|
|
|
1617
|
// ── Tests (platform-agnostic) ───────────────────────────────────────────────── |
|
1618
|
|
|
1619
|
#[cfg(test)] |
|
1620
|
mod tests { |
|
1621
|
use super::strip_think_blocks; |
|
1622
|
|
|
1623
|
#[test] |
|
1624
|
fn strip_think_blocks_separates_thinking_and_visible_reply() { |
|
1625
|
let raw = "<think>I should inspect the code first.</think>Here is the fix."; |
|
1626
|
let (thinking, visible) = strip_think_blocks(raw); |
|
1627
|
|
|
1628
|
assert_eq!(thinking, "I should inspect the code first."); |
|
1629
|
assert_eq!(visible, "Here is the fix."); |
|
1630
|
} |
|
1631
|
|
|
1632
|
#[test] |
|
1633
|
fn strip_think_blocks_handles_unclosed_think_block() { |
|
1634
|
let raw = "<think>I am still reasoning about the bug"; |
|
1635
|
let (thinking, visible) = strip_think_blocks(raw); |
|
1636
|
|
|
1637
|
assert_eq!(thinking, "I am still reasoning about the bug"); |
|
1638
|
assert_eq!(visible, ""); |
|
1639
|
} |
|
1640
|
|
|
1641
|
#[test] |
|
1642
|
fn strip_think_blocks_leaves_plain_text_untouched() { |
|
1643
|
let raw = "No hidden reasoning here."; |
|
1644
|
let (thinking, visible) = strip_think_blocks(raw); |
|
1645
|
|
|
1646
|
assert_eq!(thinking, ""); |
|
1647
|
assert_eq!(visible, "No hidden reasoning here."); |
|
1648
|
} |
|
1649
|
} |