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