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