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