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