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