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 // Once the weights have fully landed on disk, swap the spinner for a
876 // checkmark so it's clear the download finished and we're now loading
877 // the model into memory (which can still take a while).
878 let download_complete = matches!(
879 app.download_progress,
880 Some((downloaded, expected)) if expected > 0 && downloaded >= expected
881 );
882
883 if download_complete {
884 let size_str = app
885 .download_progress
886 .map(|(_, expected)| format!(" ({})", format_size_human(expected)))
887 .unwrap_or_default();
888 lines.push(Line::from(vec![
889 Span::styled(" ✓ ", Style::default().fg(Color::Green)),
890 Span::styled(
891 format!("model downloaded{size_str} — loading into memory…"),
892 Style::default().fg(Color::DarkGray),
893 ),
894 ]));
895 } else {
896 let progress_str = if let Some((downloaded, expected)) = app.download_progress {
897 if expected > 0 {
898 let pct = (downloaded as f64 / expected as f64 * 100.0).min(100.0) as u8;
899 let dl_str = format_size_human(downloaded.min(expected));
900 let ex_str = format_size_human(expected);
901 format!(" — {dl_str} / {ex_str} ({pct}%)")
902 } else if downloaded > 0 {
903 format!(" — {} downloaded", format_size_human(downloaded))
904 } else {
905 String::new()
906 }
907 } else {
908 String::new()
909 };
910 lines.push(Line::from(Span::styled(
911 format!(" {} switching model{progress_str}…", app.switching_frame()),
912 Style::default().fg(Color::DarkGray),
913 )));
914 }
915 }
916
917 let total_lines = lines.len() as u16;
918 let scroll = if total_lines > inner_height {
919 app.scroll_offset.min(total_lines - inner_height)
920 } else {
921 0
922 };
923
924 frame.render_widget(
925 Paragraph::new(lines)
926 .scroll((scroll, 0))
927 .wrap(Wrap { trim: false }),
928 inner,
929 );
930 }
931
932 fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) {
933 match msg.role {
934 Role::Banner => {
935 let palette = [
936 Color::Red,
937 Color::Yellow,
938 Color::Green,
939 Color::Cyan,
940 Color::Blue,
941 Color::Magenta,
942 ];
943 let mut spans = Vec::new();
944 for (i, ch) in msg.text.chars().enumerate() {
945 let color = palette[i % palette.len()];
946 spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
947 }
948 lines.push(Line::from(spans));
949 }
950 Role::System => {
951 for text_line in msg.text.split('\n') {
952 let trimmed = text_line.trim();
953 let (prefix, body) = if trimmed.is_empty() {
954 ("", "")
955 } else {
956 (" · ", trimmed)
957 };
958
959 lines.push(Line::from(vec![
960 Span::styled(
961 prefix.to_string(),
962 Style::default()
963 .fg(Color::Rgb(90, 90, 98))
964 .add_modifier(Modifier::DIM),
965 ),
966 Span::styled(
967 body.to_string(),
968 Style::default()
969 .fg(Color::Rgb(132, 132, 145))
970 .add_modifier(Modifier::ITALIC | Modifier::DIM),
971 ),
972 ]));
973 }
974 }
975 Role::User => {
976 let prefix = Span::styled(
977 "you > ".to_string(),
978 Style::default()
979 .fg(Color::Green)
980 .add_modifier(Modifier::BOLD),
981 );
982 let mut first = true;
983 for text_line in msg.text.split('\n') {
984 if first {
985 lines.push(Line::from(vec![
986 prefix.clone(),
987 Span::raw(text_line.to_string()),
988 ]));
989 first = false;
990 } else {
991 lines.push(Line::from(Span::raw(format!(" {text_line}"))));
992 }
993 }
994 }
995 Role::Assistant => {
996 if let Some(ref think) = msg.think_block {
997 lines.push(Line::from(Span::styled(
998 " ┌ thinking ".to_string(),
999 Style::default().fg(Color::DarkGray),
1000 )));
1001 for think_line in think.split('\n') {
1002 lines.push(Line::from(Span::styled(
1003 format!(" │ {think_line}"),
1004 Style::default().fg(Color::DarkGray),
1005 )));
1006 }
1007 lines.push(Line::from(Span::styled(
1008 " └─────────".to_string(),
1009 Style::default().fg(Color::DarkGray),
1010 )));
1011 }
1012
1013 let prefix = Span::styled(
1014 "siGit > ".to_string(),
1015 Style::default()
1016 .fg(Color::Cyan)
1017 .add_modifier(Modifier::BOLD),
1018 );
1019 let body_style = Style::default();
1020 let bold_style = Style::default().add_modifier(Modifier::BOLD);
1021 let mut first = true;
1022 for text_line in msg.text.split('\n') {
1023 if first {
1024 let mut spans = vec![prefix.clone()];
1025 spans.extend(rich_text_spans(text_line, body_style, bold_style));
1026 lines.push(Line::from(spans));
1027 first = false;
1028 } else {
1029 let mut spans = vec![Span::raw(" ".to_string())];
1030 spans.extend(rich_text_spans(text_line, body_style, bold_style));
1031 lines.push(Line::from(spans));
1032 }
1033 }
1034 }
1035 }
1036 }
1037
1038 fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1039 let block = Block::default()
1040 .borders(Borders::ALL)
1041 .border_style(Style::default().fg(Color::DarkGray))
1042 .title(" message ");
1043
1044 let inner = block.inner(area);
1045 frame.render_widget(block, area);
1046
1047 let display = app.input.clone();
1048 frame.render_widget(
1049 Paragraph::new(display.clone()).wrap(Wrap { trim: false }),
1050 inner,
1051 );
1052
1053 let col = (app.cursor as u16) % inner.width;
1054 let row = (app.cursor as u16) / inner.width;
1055 frame.set_cursor_position(Position {
1056 x: inner.x + col,
1057 y: inner.y + row,
1058 });
1059 }
1060
1061 fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1062 let mut spans = vec![
1063 Span::styled(
1064 " Enter ",
1065 Style::default().fg(Color::Black).bg(Color::Green),
1066 ),
1067 Span::styled(" send ", Style::default().fg(Color::DarkGray)),
1068 Span::styled(
1069 " /help ",
1070 Style::default().fg(Color::Black).bg(Color::DarkGray),
1071 ),
1072 Span::styled(" commands ", Style::default().fg(Color::DarkGray)),
1073 Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
1074 Span::styled(" quit", Style::default().fg(Color::DarkGray)),
1075 ];
1076
1077 if app.thinking || app.switching_model || app.is_streaming() {
1078 spans.push(Span::styled(
1079 " (busy — Ctrl+C to cancel)",
1080 Style::default().fg(Color::Yellow),
1081 ));
1082 }
1083
1084 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1085 }
1086
1087 fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
1088 if key.kind != KeyEventKind::Press {
1089 return None;
1090 }
1091
1092 if app.show_model_picker {
1093 match key.code {
1094 KeyCode::Esc => {
1095 app.close_model_picker();
1096 return None;
1097 }
1098 KeyCode::Up => {
1099 app.move_model_picker_up();
1100 return None;
1101 }
1102 KeyCode::Down => {
1103 app.move_model_picker_down();
1104 return None;
1105 }
1106 KeyCode::Enter => {
1107 return Some(format!("/models {}", app.model_picker_index + 1));
1108 }
1109 _ => return None,
1110 }
1111 }
1112
1113 match key.code {
1114 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1115 app.quit = true;
1116 None
1117 }
1118 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1119 app.quit = true;
1120 None
1121 }
1122 KeyCode::Enter => {
1123 if app.input.trim().is_empty() {
1124 return None;
1125 }
1126 let text = app.input.drain(..).collect::<String>();
1127 app.cursor = 0;
1128 Some(text)
1129 }
1130 KeyCode::Backspace => {
1131 if app.cursor > 0 {
1132 app.cursor -= 1;
1133 app.input.remove(app.cursor);
1134 }
1135 None
1136 }
1137 KeyCode::Delete => {
1138 if app.cursor < app.input.len() {
1139 app.input.remove(app.cursor);
1140 }
1141 None
1142 }
1143 KeyCode::Left => {
1144 app.cursor = app.cursor.saturating_sub(1);
1145 None
1146 }
1147 KeyCode::Right => {
1148 if app.cursor < app.input.len() {
1149 app.cursor += 1;
1150 }
1151 None
1152 }
1153 KeyCode::Home => {
1154 app.cursor = 0;
1155 None
1156 }
1157 KeyCode::End => {
1158 app.cursor = app.input.len();
1159 None
1160 }
1161 KeyCode::Char(ch) => {
1162 app.input.insert(app.cursor, ch);
1163 app.cursor += 1;
1164 None
1165 }
1166 _ => None,
1167 }
1168 }
1169
1170 // ── Slash command execution ───────────────────────────────────────────────
1171
1172 async fn exec_slash<B: ratatui::backend::Backend>(
1173 app: &mut App,
1174 cmd: SlashCommand,
1175 engine: Arc<ChatEngine>,
1176 terminal: &mut ratatui::Terminal<B>,
1177 ) {
1178 match cmd {
1179 SlashCommand::Help => {
1180 app.messages.push(ChatMessage::system(
1181 "/help — show this message\n\
1182 /models — open the model picker\n\
1183 /models N — switch to model N\n\
1184 /login E P — sign in to siGit Code Cloud\n\
1185 /logout — sign out\n\
1186 /whoami — show the signed-in account\n\
1187 /clear — wipe conversation history\n\
1188 /status — show engine status\n\
1189 /exit — quit chat",
1190 ));
1191 }
1192 SlashCommand::Clear => {
1193 let cleared = engine.clear_history().await;
1194 app.messages.clear();
1195 app.scroll_offset = 0;
1196 app.messages.push(ChatMessage::system(format!(
1197 "Cleared {cleared} turn(s). History is empty.",
1198 )));
1199 }
1200 SlashCommand::Status => {
1201 let info = engine.as_ref().info().await;
1202 let model = info.model_name.as_deref().unwrap_or("(none)");
1203 let mem = info.approx_memory.as_deref().unwrap_or("unknown");
1204 app.messages.push(ChatMessage::system(format!(
1205 "status: {:?} model: {} memory: {} history: {} turns",
1206 info.status, model, mem, info.history_length,
1207 )));
1208 }
1209 SlashCommand::Models(selection) => match selection {
1210 None => {
1211 app.open_model_picker(&engine);
1212 }
1213 Some(n) => {
1214 let idx = n.saturating_sub(1);
1215 match app.model_picker_items.get(idx).cloned() {
1216 None => {
1217 app.messages.push(ChatMessage::system(format!(
1218 "error: no model #{n} — type /models to see the list."
1219 )));
1220 }
1221 Some(model) => {
1222 // ── siGit Code Cloud tier: no local load; sign-in gated ──
1223 if let Some(tier) = model.cloud_tier.clone() {
1224 app.close_model_picker();
1225 match crate::provider::cloud_tier_provider(&tier) {
1226 Some(provider) => {
1227 let system_prompt =
1228 crate::system_prompt_for_model(true).to_string();
1229 app.backend = Arc::new(OpenAiBackend::new(
1230 provider.base_url,
1231 provider.api_key,
1232 provider.model,
1233 Some(system_prompt),
1234 ));
1235 app.current_model_name = provider.display_name.clone();
1236 app.tool_calling = true;
1237 app.messages.push(ChatMessage::system(format!(
1238 "Switched to {}.",
1239 provider.display_name
1240 )));
1241 }
1242 None => {
1243 app.messages.push(ChatMessage::system(
1244 "siGit Code Cloud needs an account. Use \
1245 `/login <email> <password>`, or create one at sigit.si.",
1246 ));
1247 }
1248 }
1249 return;
1250 }
1251
1252 if model.cache_health == ModelCacheHealth::Incomplete {
1253 app.close_model_picker();
1254 app.messages.push(ChatMessage::system(format!(
1255 "error: {} has an incomplete local cache and cannot be selected yet.",
1256 model.display_name
1257 )));
1258 return;
1259 }
1260
1261 // Route inference on-device; the loader thread below
1262 // fills the engine the LocalBackend reads from.
1263 app.backend = Arc::new(LocalBackend::new(Arc::clone(&engine)));
1264
1265 let loading_msg = if model.cache_health
1266 == ModelCacheHealth::NotDownloaded
1267 {
1268 format!(
1269 "Downloading and loading {} ({})… this may take a few minutes.",
1270 model.display_name, model.description
1271 )
1272 } else {
1273 format!("Loading {}…", model.display_name)
1274 };
1275
1276 app.close_model_picker();
1277 app.messages.push(ChatMessage::system(loading_msg));
1278 terminal.draw(|frame| render(frame, app)).ok();
1279
1280 let (tx, rx) = mpsc::channel(1);
1281 app.model_load_rx = Some(rx);
1282 app.switching_model = true;
1283 app.switching_model_id = Some(model.config.model_id.clone());
1284 // Only show download progress for models not yet cached.
1285 app.download_progress =
1286 if model.cache_health == ModelCacheHealth::NotDownloaded {
1287 Some((0, 0))
1288 } else {
1289 None
1290 };
1291
1292 let sampling = SamplingConfig {
1293 max_tokens: Some(model.max_tokens),
1294 ..SamplingConfig::default()
1295 };
1296
1297 // own thread + runtime so block_in_place doesn't starve the TUI loop
1298 let system_prompt = crate::system_prompt_for_model(model.tool_calling);
1299 let engine_handle = Arc::clone(&engine);
1300 let tool_calling = model.tool_calling;
1301 std::thread::spawn(move || {
1302 let rt = tokio::runtime::Runtime::new()
1303 .expect("failed to create model-loader runtime");
1304 let update = rt.block_on(async move {
1305 match engine_handle
1306 .load_gguf_model(
1307 model.config.clone(),
1308 Some(system_prompt.to_string()),
1309 Some(sampling),
1310 )
1311 .await
1312 {
1313 Ok(_) => {
1314 ModelLoadUpdate::Loaded(model.display_name.clone())
1315 }
1316 Err(err) => ModelLoadUpdate::Error(err.to_string()),
1317 }
1318 });
1319 // capacity-1 channel, receiver alive while switching
1320 let _ = tx.blocking_send(update);
1321 });
1322 // applied on ModelLoadUpdate::Loaded
1323 app.pending_tool_calling = Some(tool_calling);
1324 }
1325 }
1326 }
1327 },
1328 SlashCommand::Login(arg) => {
1329 let message = match arg.as_deref().and_then(crate::account::parse_login_args) {
1330 Some((email, password)) => {
1331 match crate::account::authenticate(&email, &password).await {
1332 Ok(email) => format!(
1333 "Signed in as {email}. siGit Code Cloud applies to your next session."
1334 ),
1335 Err(error) => format!("Login failed: {error}"),
1336 }
1337 }
1338 None => "usage: /login <email> <password>".to_string(),
1339 };
1340 app.messages.push(ChatMessage::system(message));
1341 }
1342 SlashCommand::Logout => {
1343 let message = crate::account::end_session().await;
1344 app.messages.push(ChatMessage::system(message));
1345 }
1346 SlashCommand::Whoami => {
1347 let message = crate::account::status_line().await;
1348 app.messages.push(ChatMessage::system(message));
1349 }
1350 SlashCommand::Exit => {
1351 app.quit = true;
1352 }
1353 SlashCommand::Unknown(cmd) => {
1354 app.messages
1355 .push(ChatMessage::system(format!("unknown command: {cmd}")));
1356 }
1357 }
1358 }
1359
1360 // ── Background inference task ─────────────────────────────────────────────
1361
1362 /// cap tool rounds so a confused model can't loop forever
1363 const MAX_TOOL_ROUNDS: usize = 10;
1364
1365 fn build_tool_specs() -> Vec<ToolSpec> {
1366 crate::tools::all_tools()
1367 .into_iter()
1368 .map(|t| ToolSpec {
1369 name: t.name.to_string(),
1370 description: t.description.to_string(),
1371 parameters_schema: t.parameters_schema.to_string(),
1372 })
1373 .collect()
1374 }
1375
1376 /// run the tool-calling loop off the main thread, posting updates via `tx`.
1377 /// dropping `tx` signals completion to the event loop.
1378 async fn run_inference_task(
1379 backend: Arc<dyn InferenceBackend>,
1380 text: String,
1381 tx: mpsc::Sender<InferenceUpdate>,
1382 tools_enabled: bool,
1383 ) {
1384 let tools = if tools_enabled {
1385 build_tool_specs()
1386 } else {
1387 vec![]
1388 };
1389
1390 let mut result = match backend.send_message_with_tools(&text, &tools).await {
1391 Ok(r) => r,
1392 Err(err) => {
1393 let _ = tx.send(InferenceUpdate::Error(err)).await;
1394 return;
1395 }
1396 };
1397
1398 let mut round = 0;
1399
1400 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
1401 round += 1;
1402 log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
1403
1404 let mut tool_results = Vec::new();
1405
1406 for tc in &result.tool_calls {
1407 log::info!(
1408 " → {}({})",
1409 tc.name,
1410 tc.arguments.chars().take(120).collect::<String>()
1411 );
1412
1413 let _ = tx.send(InferenceUpdate::ToolUse(tc.name.clone())).await;
1414
1415 let output = crate::tools::execute_tool(&tc.name, &tc.arguments).await;
1416 log::info!(" ← {} chars", output.len());
1417
1418 tool_results.push(ToolResult {
1419 tool_call_id: tc.id.clone(),
1420 content: output,
1421 });
1422 }
1423
1424 // on the last round, pass no tools so the model must produce text
1425 let next_tools = if round < MAX_TOOL_ROUNDS {
1426 Some(tools.as_slice())
1427 } else {
1428 None
1429 };
1430
1431 match backend.send_tool_results(tool_results, next_tools).await {
1432 Ok(r) => result = r,
1433 Err(err) => {
1434 let _ = tx.send(InferenceUpdate::Error(err)).await;
1435 return;
1436 }
1437 }
1438 }
1439
1440 if result.tool_calls.is_empty() {
1441 if result.text.is_empty() {
1442 log::warn!(
1443 "model returned empty reply — may have exhausted max_tokens on thinking"
1444 );
1445 let _ = tx
1446 .send(InferenceUpdate::Error(
1447 "(empty response — the model may have used all tokens on internal reasoning. \
1448 Try a shorter or simpler prompt.)"
1449 .to_string(),
1450 ))
1451 .await;
1452 } else {
1453 let _ = tx.send(InferenceUpdate::Response(result.text)).await;
1454 }
1455 }
1456
1457 log::info!("inference complete — {} tool round(s)", round);
1458 // tx drops here — event loop gets None from rx.recv()
1459 }
1460
1461 // ── Main loop ─────────────────────────────────────────────────────────────
1462
1463 /// entry point — blocks until the user quits.
1464 /// caller owns terminal init/restore. `load_rx` delivers the model-load result
1465 /// from a dedicated OS thread; we poll it non-blocking each tick.
1466 pub async fn run_with<B: ratatui::backend::Backend>(
1467 terminal: &mut ratatui::Terminal<B>,
1468 engine: Arc<ChatEngine>,
1469 backend: Arc<dyn InferenceBackend>,
1470 load_rx: std_mpsc::Receiver<Result<(), String>>,
1471 load_model_name: String,
1472 ) -> Result<()> {
1473 event_loop(terminal, engine, backend, load_rx, load_model_name).await
1474 }
1475
1476 async fn event_loop<B: ratatui::backend::Backend>(
1477 terminal: &mut ratatui::Terminal<B>,
1478 engine: Arc<ChatEngine>,
1479 backend: Arc<dyn InferenceBackend>,
1480 load_rx: std_mpsc::Receiver<Result<(), String>>,
1481 load_model_name: String,
1482 ) -> Result<()> {
1483 let mut app = App::new(load_model_name, backend);
1484 let mut event_stream = EventStream::new();
1485
1486 // 10 fps is plenty for spinners
1487 let mut ticker = interval(Duration::from_millis(100));
1488
1489 loop {
1490 // ── Poll the loader channel (non-blocking) ────────────────────────
1491 if app.is_loading {
1492 match load_rx.try_recv() {
1493 Ok(Ok(())) => app.finish_loading(),
1494 Ok(Err(e)) => app.set_load_error(e),
1495 Err(std_mpsc::TryRecvError::Empty) => {}
1496 Err(std_mpsc::TryRecvError::Disconnected) => {
1497 app.set_load_error("Model loader thread crashed.".to_string());
1498 }
1499 }
1500 }
1501
1502 // redraw every iteration
1503 terminal.draw(|frame| render(frame, &mut app))?;
1504
1505 if let Some(rx) = app.model_load_rx.as_mut() {
1506 match rx.try_recv() {
1507 Ok(ModelLoadUpdate::Loaded(model_name)) => {
1508 engine.clear_history().await;
1509 if let Some(tc) = app.pending_tool_calling.take() {
1510 app.tool_calling = tc;
1511 }
1512 app.switching_model = false;
1513 app.switching_model_id = None;
1514 app.download_progress = None;
1515 app.model_load_cancelled = false;
1516 app.model_load_rx = None;
1517 app.current_model_name = model_name.clone();
1518
1519 let save_result = app
1520 .model_picker_items
1521 .iter()
1522 .find(|item| item.display_name == model_name)
1523 .map(|item| crate::setup::SelectedModel {
1524 model_id: item.config.model_id.clone(),
1525 gguf_file: item
1526 .config
1527 .files
1528 .first()
1529 .cloned()
1530 .unwrap_or_else(String::new),
1531 })
1532 .filter(|selected| !selected.gguf_file.is_empty())
1533 .map(|selected| crate::setup::save_selected_model(&selected))
1534 .unwrap_or_else(|| {
1535 Err(format!(
1536 "could not determine a stable identifier for {}",
1537 model_name
1538 ))
1539 });
1540
1541 if let Err(error) = save_result {
1542 app.messages.push(ChatMessage::system(format!(
1543 "warning: switched to {} but could not save the selection: {}",
1544 model_name, error
1545 )));
1546 } else {
1547 app.messages
1548 .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
1549 }
1550 }
1551 Ok(ModelLoadUpdate::Error(error)) => {
1552 app.switching_model = false;
1553 app.switching_model_id = None;
1554 app.download_progress = None;
1555 app.model_load_cancelled = false;
1556 app.model_load_rx = None;
1557 app.messages
1558 .push(ChatMessage::system(format!("error loading model: {error}")));
1559 }
1560 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
1561 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
1562 let was_cancelled = app.model_load_cancelled;
1563 app.switching_model = false;
1564 app.switching_model_id = None;
1565 app.download_progress = None;
1566 app.model_load_cancelled = false;
1567 app.model_load_rx = None;
1568 if !was_cancelled {
1569 app.messages.push(ChatMessage::system(
1570 "error loading model: loader task disconnected".to_string(),
1571 ));
1572 }
1573 }
1574 }
1575 }
1576
1577 if app.quit {
1578 break;
1579 }
1580
1581 // multiplex terminal events, streaming tokens, inference updates,
1582 // and the thinking-spinner timer.
1583 tokio::select! {
1584 biased;
1585
1586 // ── Spinner tick (loading phase only) ─────────────────────────
1587 _ = ticker.tick(), if app.is_loading => {
1588 app.tick();
1589 }
1590
1591 // ── Streaming LLM tokens ──────────────────────────────────────
1592 chunk = async {
1593 match app.stream_rx.as_mut() {
1594 Some(rx) => rx.recv().await,
1595 None => pending().await,
1596 }
1597 } => {
1598 match chunk {
1599 Some(chunk) => {
1600 if !chunk.delta.is_empty() {
1601 app.push_stream_delta(&chunk.delta);
1602 }
1603 if chunk.done {
1604 app.finalize_stream();
1605 }
1606 }
1607 // sender dropped without done=true
1608 None => {
1609 app.finalize_stream();
1610 }
1611 }
1612 }
1613
1614 // ── inference updates from background task ───────────────────
1615 update = async {
1616 match app.inference_rx.as_mut() {
1617 Some(rx) => rx.recv().await,
1618 None => pending().await,
1619 }
1620 } => {
1621 match update {
1622 Some(InferenceUpdate::ToolUse(name)) => {
1623 app.messages.push(ChatMessage::system(format!("🔧 {name}")));
1624 }
1625 Some(InferenceUpdate::Response(text)) => {
1626 app.stop_thinking();
1627 app.messages.push(ChatMessage::assistant(text));
1628 }
1629 Some(InferenceUpdate::Error(msg)) => {
1630 app.stop_thinking();
1631 app.messages.push(ChatMessage::system(format!("error: {msg}")));
1632 }
1633 None => {
1634 // task finished, possibly with no text to show
1635 app.stop_thinking();
1636 }
1637 }
1638 }
1639
1640 // ── thinking / switching spinner tick (100ms) ────────────────
1641 _ = async {
1642 if app.thinking || app.switching_model {
1643 tokio::time::sleep(Duration::from_millis(100)).await
1644 } else {
1645 pending().await
1646 }
1647 } => {
1648 app.tick_thinking();
1649 // keep the progress display fresh
1650 if app.switching_model {
1651 app.poll_download_progress();
1652 }
1653 }
1654
1655 // ── Terminal events ───────────────────────────────────────────
1656 maybe_event = event_stream.next() => {
1657 let Some(Ok(event)) = maybe_event else {
1658 break;
1659 };
1660
1661 if let Event::Key(key) = event {
1662 // loading phase — only quit keys work
1663 if app.is_loading {
1664 if key.kind == KeyEventKind::Press {
1665 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1666 if ctrl
1667 && (key.code == KeyCode::Char('c')
1668 || key.code == KeyCode::Char('d'))
1669 {
1670 app.quit = true;
1671 }
1672 }
1673 continue;
1674 }
1675
1676 // busy — only cancel keys work
1677 if app.is_busy() {
1678 if key.kind == KeyEventKind::Press {
1679 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1680 if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
1681 if app.is_streaming() {
1682 app.finalize_stream();
1683 app.messages.push(ChatMessage::system("(cancelled)"));
1684 }
1685 if app.thinking {
1686 // dropping rx kills the background task
1687 app.stop_thinking();
1688 app.messages.push(ChatMessage::system("(cancelled)"));
1689 }
1690 if app.switching_model {
1691 // flag before drop so Disconnected handler stays quiet
1692 app.model_load_cancelled = true;
1693 app.switching_model = false;
1694 app.switching_model_id = None;
1695 app.download_progress = None;
1696 app.model_load_rx = None;
1697 app.messages
1698 .push(ChatMessage::system("(download cancelled — model switch aborted)"));
1699 }
1700 }
1701 }
1702 continue;
1703 }
1704
1705 if let Some(text) = handle_key(&mut app, key) {
1706 if let Some(cmd) = parse_slash(&text) {
1707 exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await;
1708 continue;
1709 }
1710
1711 // ── spawn inference ──────────────────────────────
1712 app.messages.push(ChatMessage::user(&text));
1713 app.start_thinking();
1714
1715 let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
1716 app.inference_rx = Some(rx);
1717
1718 let backend_handle = Arc::clone(&app.backend);
1719 let user_text = text.clone();
1720 let tools_enabled = app.tool_calling;
1721 tokio::spawn(async move {
1722 run_inference_task(backend_handle, user_text, tx, tools_enabled).await;
1723 });
1724 }
1725 }
1726 }
1727 }
1728 }
1729
1730 Ok(())
1731 }
1732
1733 // ── Download progress helpers (TUI) ──────────────────────────────────────
1734
1735 /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks)
1736 fn dir_size_recursive(path: &std::path::Path) -> u64 {
1737 let mut total: u64 = 0;
1738 let Ok(entries) = std::fs::read_dir(path) else {
1739 return 0;
1740 };
1741 for entry in entries.flatten() {
1742 let entry_path = entry.path();
1743 if entry_path.is_dir() {
1744 total += dir_size_recursive(&entry_path);
1745 } else if let Ok(meta) = entry_path.metadata() {
1746 total += meta.len();
1747 }
1748 }
1749 total
1750 }
1751
1752 fn format_size_human(bytes: u64) -> String {
1753 const GB: u64 = 1_073_741_824;
1754 const MB: u64 = 1_048_576;
1755 const KB: u64 = 1_024;
1756 if bytes >= GB {
1757 format!("{:.2} GB", bytes as f64 / GB as f64)
1758 } else if bytes >= MB {
1759 format!("{:.1} MB", bytes as f64 / MB as f64)
1760 } else if bytes >= KB {
1761 format!("{:.0} KB", bytes as f64 / KB as f64)
1762 } else {
1763 format!("{bytes} B")
1764 }
1765 }
1766 } // end #[cfg(unix)] mod tui
1767
1768 // re-export so callers write `chat::run_with(...)` on all platforms
1769 #[cfg(unix)]
1770 pub use tui::run_with;
1771
1772 // ── Tests (platform-agnostic) ─────────────────────────────────────────────────
1773
1774 #[cfg(test)]
1775 mod tests {
1776 use super::{parse_rich_text_segments, strip_think_blocks};
1777
1778 #[test]
1779 fn strip_think_blocks_separates_thinking_and_visible_reply() {
1780 let raw = "<think>I should inspect the code first.</think>Here is the fix.";
1781 let (thinking, visible) = strip_think_blocks(raw);
1782
1783 assert_eq!(thinking, "I should inspect the code first.");
1784 assert_eq!(visible, "Here is the fix.");
1785 }
1786
1787 #[test]
1788 fn strip_think_blocks_handles_unclosed_think_block() {
1789 let raw = "<think>I am still reasoning about the bug";
1790 let (thinking, visible) = strip_think_blocks(raw);
1791
1792 assert_eq!(thinking, "I am still reasoning about the bug");
1793 assert_eq!(visible, "");
1794 }
1795
1796 #[test]
1797 fn strip_think_blocks_leaves_plain_text_untouched() {
1798 let raw = "No hidden reasoning here.";
1799 let (thinking, visible) = strip_think_blocks(raw);
1800
1801 assert_eq!(thinking, "");
1802 assert_eq!(visible, "No hidden reasoning here.");
1803 }
1804
1805 #[test]
1806 fn parse_rich_text_segments_marks_bold_runs() {
1807 let segments = parse_rich_text_segments(
1808 "The current weather is **72°F** with **Partly Cloudy** conditions.",
1809 );
1810
1811 assert_eq!(
1812 segments,
1813 vec![
1814 ("The current weather is ".to_string(), false),
1815 ("72°F".to_string(), true),
1816 (" with ".to_string(), false),
1817 ("Partly Cloudy".to_string(), true),
1818 (" conditions.".to_string(), false),
1819 ]
1820 );
1821 }
1822
1823 #[test]
1824 fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
1825 let segments = parse_rich_text_segments("Prefix **bold");
1826
1827 assert_eq!(
1828 segments,
1829 vec![("Prefix ".to_string(), false), ("bold".to_string(), true),]
1830 );
1831 }
1832 }