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 /// explicitly load the selected (or default) on-device model
742 Load,
743 /// `/login <email> <password>` — the raw argument, parsed when executed.
744 Login(Option<String>),
745 Logout,
746 Whoami,
747 Exit,
748 Unknown(String),
749 }
750
751 fn parse_slash(input: &str) -> Option<SlashCommand> {
752 let trimmed = input.trim();
753 if !trimmed.starts_with('/') {
754 return None;
755 }
756 let mut parts = trimmed.splitn(2, char::is_whitespace);
757 let cmd = parts.next().unwrap_or("");
758 let arg = parts.next().map(|s| s.trim());
759 Some(match cmd {
760 "/help" => SlashCommand::Help,
761 "/clear" => SlashCommand::Clear,
762 "/status" => SlashCommand::Status,
763 "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),
764 "/local" => SlashCommand::Local(parse_on_off(arg)),
765 "/skills" => SlashCommand::Skills,
766 "/load" => SlashCommand::Load,
767 "/login" => SlashCommand::Login(arg.map(str::to_string)),
768 "/logout" => SlashCommand::Logout,
769 "/whoami" => SlashCommand::Whoami,
770 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
771 other => SlashCommand::Unknown(other.to_string()),
772 })
773 }
774
775 /// `on`/`off` (and synonyms) → `Some(bool)`; missing or unrecognized → `None`
776 /// (meaning "toggle the current value").
777 fn parse_on_off(arg: Option<&str>) -> Option<bool> {
778 match arg.map(|s| s.trim().to_ascii_lowercase())?.as_str() {
779 "on" | "true" | "1" | "yes" => Some(true),
780 "off" | "false" | "0" | "no" => Some(false),
781 _ => None,
782 }
783 }
784
785 // ── Rendering ─────────────────────────────────────────────────────────────
786
787 fn render(frame: &mut Frame, app: &mut App) {
788 let area = frame.area();
789
790 if app.is_loading {
791 let zones = Layout::vertical([
792 Constraint::Length(1),
793 Constraint::Min(1),
794 Constraint::Length(1),
795 ])
796 .split(area);
797 render_loading_title(frame, app, zones[0]);
798 render_loading(frame, app, zones[1]);
799 render_loading_footer(frame, zones[2]);
800 return;
801 }
802
803 let zones = Layout::vertical([
804 Constraint::Length(1),
805 Constraint::Min(1),
806 Constraint::Length(3),
807 Constraint::Length(1),
808 ])
809 .split(area);
810
811 render_title(frame, app, zones[0]);
812 render_messages(frame, app, zones[1]);
813 render_input(frame, app, zones[2]);
814 render_footer(frame, app, zones[3]);
815
816 if app.show_model_picker {
817 render_model_picker(frame, app, area);
818 }
819 }
820
821 fn render_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
822 let model_label = format!(" siGit — {} ", app.current_model_name);
823 let tool_label = if app.tool_calling {
824 " [tools on] "
825 } else {
826 " [tools off] "
827 };
828 let line = Line::from(vec![
829 Span::styled(
830 model_label,
831 Style::default()
832 .fg(Color::Black)
833 .bg(Color::Green)
834 .add_modifier(Modifier::BOLD),
835 ),
836 Span::styled(
837 tool_label,
838 Style::default().fg(Color::Black).bg(Color::DarkGray),
839 ),
840 ]);
841 frame.render_widget(Paragraph::new(line), area);
842 }
843
844 fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
845 const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
846 let spin = SPINNER[(app.load_tick as usize) % SPINNER.len()];
847 let label = format!(" siGit {} loading {}… ", spin, app.load_model_name);
848 let line = Line::from(Span::styled(
849 label,
850 Style::default()
851 .fg(Color::Black)
852 .bg(Color::Green)
853 .add_modifier(Modifier::BOLD),
854 ));
855 frame.render_widget(Paragraph::new(line), area);
856 }
857
858 fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
859 let elapsed = app.load_start.elapsed().as_secs();
860 let elapsed_str = if elapsed < 60 {
861 format!("{}s", elapsed)
862 } else {
863 format!("{}m {}s", elapsed / 60, elapsed % 60)
864 };
865
866 let content = if let Some(ref err) = app.load_error {
867 format!(
868 "\n\n ✗ Failed to load model after {}.\n\n {}\n\n Press Ctrl+C to exit.",
869 elapsed_str, err
870 )
871 } else {
872 format!(
873 "\n\n Loading model, please wait… ({})\n\n The model is being initialised. This may take a moment on first run.",
874 elapsed_str
875 )
876 };
877
878 let style = if app.load_error.is_some() {
879 Style::default().fg(Color::Red)
880 } else {
881 Style::default().fg(Color::White)
882 };
883
884 frame.render_widget(
885 Paragraph::new(content)
886 .style(style)
887 .wrap(Wrap { trim: false }),
888 area,
889 );
890 }
891
892 fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) {
893 let line = Line::from(vec![
894 Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
895 Span::styled(" quit", Style::default().fg(Color::DarkGray)),
896 ]);
897 frame.render_widget(Paragraph::new(line), area);
898 }
899
900 fn render_messages(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
901 let inner_width = area.width.saturating_sub(2);
902 let inner_height = area.height.saturating_sub(2);
903
904 let block = Block::default()
905 .borders(Borders::ALL)
906 .border_style(Style::default().fg(Color::DarkGray));
907
908 let inner = block.inner(area);
909 frame.render_widget(block, area);
910
911 let mut lines: Vec<Line> = Vec::new();
912
913 for msg in &app.messages {
914 render_chat_message(&mut lines, msg, inner_width as usize);
915 }
916
917 let streamed_visible = app.visible_stream();
918 if !streamed_visible.is_empty() {
919 let fake = ChatMessage {
920 role: Role::Assistant,
921 text: streamed_visible,
922 think_block: None,
923 };
924 render_chat_message(&mut lines, &fake, inner_width as usize);
925 if app.blink_on
926 && let Some(last) = lines.last_mut()
927 {
928 last.spans
929 .push(Span::styled("▋", Style::default().fg(Color::Green)));
930 }
931 }
932
933 if app.thinking {
934 lines.push(Line::from(Span::styled(
935 format!(" {} thinking…", app.thinking_frame()),
936 Style::default().fg(Color::DarkGray),
937 )));
938 } else if app.switching_model {
939 // Once the weights have fully landed on disk, swap the spinner for a
940 // checkmark so it's clear the download finished and we're now loading
941 // the model into memory (which can still take a while).
942 let download_complete = matches!(
943 app.download_progress,
944 Some((downloaded, expected)) if expected > 0 && downloaded >= expected
945 );
946
947 if download_complete {
948 let size_str = app
949 .download_progress
950 .map(|(_, expected)| format!(" ({})", format_size_human(expected)))
951 .unwrap_or_default();
952 lines.push(Line::from(vec![
953 Span::styled(" ✓ ", Style::default().fg(Color::Green)),
954 Span::styled(
955 format!("model downloaded{size_str} — loading into memory…"),
956 Style::default().fg(Color::DarkGray),
957 ),
958 ]));
959 } else {
960 let progress_str = if let Some((downloaded, expected)) = app.download_progress {
961 if expected > 0 {
962 let pct = (downloaded as f64 / expected as f64 * 100.0).min(100.0) as u8;
963 let dl_str = format_size_human(downloaded.min(expected));
964 let ex_str = format_size_human(expected);
965 format!(" — {dl_str} / {ex_str} ({pct}%)")
966 } else if downloaded > 0 {
967 format!(" — {} downloaded", format_size_human(downloaded))
968 } else {
969 String::new()
970 }
971 } else {
972 String::new()
973 };
974 lines.push(Line::from(Span::styled(
975 format!(" {} switching model{progress_str}…", app.switching_frame()),
976 Style::default().fg(Color::DarkGray),
977 )));
978 }
979 }
980
981 // Always pin to the bottom so the latest message stays visible. There is
982 // no scrollback, so we just need the exact number of wrapped rows the
983 // paragraph occupies at this width — `line_count` runs the same
984 // WordWrapper as rendering, so it never diverges from what's drawn (an
985 // estimate would, e.g. by forgetting the `<think>` box lines, and scroll
986 // too little — the bug this fixes).
987 let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
988 let total_lines = paragraph.line_count(inner_width) as u16;
989 let scroll = total_lines.saturating_sub(inner_height);
990
991 frame.render_widget(paragraph.scroll((scroll, 0)), inner);
992 }
993
994 fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) {
995 match msg.role {
996 Role::Banner => {
997 let palette = [
998 Color::Red,
999 Color::Yellow,
1000 Color::Green,
1001 Color::Cyan,
1002 Color::Blue,
1003 Color::Magenta,
1004 ];
1005 let mut spans = Vec::new();
1006 for (i, ch) in msg.text.chars().enumerate() {
1007 let color = palette[i % palette.len()];
1008 spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
1009 }
1010 lines.push(Line::from(spans));
1011 }
1012 Role::System => {
1013 for text_line in msg.text.split('\n') {
1014 let trimmed = text_line.trim();
1015 let (prefix, body) = if trimmed.is_empty() {
1016 ("", "")
1017 } else {
1018 (" · ", trimmed)
1019 };
1020
1021 lines.push(Line::from(vec![
1022 Span::styled(
1023 prefix.to_string(),
1024 Style::default()
1025 .fg(Color::Rgb(90, 90, 98))
1026 .add_modifier(Modifier::DIM),
1027 ),
1028 Span::styled(
1029 body.to_string(),
1030 Style::default()
1031 .fg(Color::Rgb(132, 132, 145))
1032 .add_modifier(Modifier::ITALIC | Modifier::DIM),
1033 ),
1034 ]));
1035 }
1036 }
1037 Role::User => {
1038 let prefix = Span::styled(
1039 "you > ".to_string(),
1040 Style::default()
1041 .fg(Color::Green)
1042 .add_modifier(Modifier::BOLD),
1043 );
1044 let mut first = true;
1045 for text_line in msg.text.split('\n') {
1046 if first {
1047 lines.push(Line::from(vec![
1048 prefix.clone(),
1049 Span::raw(text_line.to_string()),
1050 ]));
1051 first = false;
1052 } else {
1053 lines.push(Line::from(Span::raw(format!(" {text_line}"))));
1054 }
1055 }
1056 }
1057 Role::Assistant => {
1058 if let Some(ref think) = msg.think_block {
1059 lines.push(Line::from(Span::styled(
1060 " ┌ thinking ".to_string(),
1061 Style::default().fg(Color::DarkGray),
1062 )));
1063 for think_line in think.split('\n') {
1064 lines.push(Line::from(Span::styled(
1065 format!(" │ {think_line}"),
1066 Style::default().fg(Color::DarkGray),
1067 )));
1068 }
1069 lines.push(Line::from(Span::styled(
1070 " └─────────".to_string(),
1071 Style::default().fg(Color::DarkGray),
1072 )));
1073 }
1074
1075 let prefix = Span::styled(
1076 "siGit > ".to_string(),
1077 Style::default()
1078 .fg(Color::Cyan)
1079 .add_modifier(Modifier::BOLD),
1080 );
1081 let body_style = Style::default();
1082 let bold_style = Style::default().add_modifier(Modifier::BOLD);
1083 let mut first = true;
1084 for text_line in msg.text.split('\n') {
1085 if first {
1086 let mut spans = vec![prefix.clone()];
1087 spans.extend(rich_text_spans(text_line, body_style, bold_style));
1088 lines.push(Line::from(spans));
1089 first = false;
1090 } else {
1091 let mut spans = vec![Span::raw(" ".to_string())];
1092 spans.extend(rich_text_spans(text_line, body_style, bold_style));
1093 lines.push(Line::from(spans));
1094 }
1095 }
1096 }
1097 }
1098 }
1099
1100 fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1101 let block = Block::default()
1102 .borders(Borders::ALL)
1103 .border_style(Style::default().fg(Color::DarkGray))
1104 .title(" message ");
1105
1106 let inner = block.inner(area);
1107 frame.render_widget(block, area);
1108
1109 let display = app.input.clone();
1110 frame.render_widget(
1111 Paragraph::new(display.clone()).wrap(Wrap { trim: false }),
1112 inner,
1113 );
1114
1115 let col = (app.cursor as u16) % inner.width;
1116 let row = (app.cursor as u16) / inner.width;
1117 frame.set_cursor_position(Position {
1118 x: inner.x + col,
1119 y: inner.y + row,
1120 });
1121 }
1122
1123 fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1124 let mut spans = vec![
1125 Span::styled(
1126 " Enter ",
1127 Style::default().fg(Color::Black).bg(Color::Green),
1128 ),
1129 Span::styled(" send ", Style::default().fg(Color::DarkGray)),
1130 Span::styled(
1131 " /help ",
1132 Style::default().fg(Color::Black).bg(Color::DarkGray),
1133 ),
1134 Span::styled(" commands ", Style::default().fg(Color::DarkGray)),
1135 Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
1136 Span::styled(" quit", Style::default().fg(Color::DarkGray)),
1137 ];
1138
1139 if app.thinking || app.switching_model || app.is_streaming() {
1140 spans.push(Span::styled(
1141 " (busy — Ctrl+C to cancel)",
1142 Style::default().fg(Color::Yellow),
1143 ));
1144 }
1145
1146 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1147 }
1148
1149 fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
1150 if key.kind != KeyEventKind::Press {
1151 return None;
1152 }
1153
1154 if app.show_model_picker {
1155 match key.code {
1156 KeyCode::Esc => {
1157 app.close_model_picker();
1158 return None;
1159 }
1160 KeyCode::Up => {
1161 app.move_model_picker_up();
1162 return None;
1163 }
1164 KeyCode::Down => {
1165 app.move_model_picker_down();
1166 return None;
1167 }
1168 KeyCode::Enter => {
1169 return Some(format!("/models {}", app.model_picker_index + 1));
1170 }
1171 _ => return None,
1172 }
1173 }
1174
1175 match key.code {
1176 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1177 app.quit = true;
1178 None
1179 }
1180 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1181 app.quit = true;
1182 None
1183 }
1184 KeyCode::Enter => {
1185 if app.input.trim().is_empty() {
1186 return None;
1187 }
1188 let text = app.input.drain(..).collect::<String>();
1189 app.cursor = 0;
1190 Some(text)
1191 }
1192 KeyCode::Backspace => {
1193 if app.cursor > 0 {
1194 app.cursor -= 1;
1195 app.input.remove(app.cursor);
1196 }
1197 None
1198 }
1199 KeyCode::Delete => {
1200 if app.cursor < app.input.len() {
1201 app.input.remove(app.cursor);
1202 }
1203 None
1204 }
1205 KeyCode::Left => {
1206 app.cursor = app.cursor.saturating_sub(1);
1207 None
1208 }
1209 KeyCode::Right => {
1210 if app.cursor < app.input.len() {
1211 app.cursor += 1;
1212 }
1213 None
1214 }
1215 KeyCode::Home => {
1216 app.cursor = 0;
1217 None
1218 }
1219 KeyCode::End => {
1220 app.cursor = app.input.len();
1221 None
1222 }
1223 KeyCode::Char(ch) => {
1224 app.input.insert(app.cursor, ch);
1225 app.cursor += 1;
1226 None
1227 }
1228 _ => None,
1229 }
1230 }
1231
1232 // ── Explicit on-device model loading ──────────────────────────────────────
1233
1234 /// The local model `/load` should bring up: the persisted selection if it
1235 /// still resolves to a known model, otherwise the first on-device (non-cloud)
1236 /// entry in the picker.
1237 fn default_local_model_item(app: &App) -> Option<ModelPickerItem> {
1238 if let Some(selected) = crate::setup::load_selected_model()
1239 && let Some(item) = app.model_picker_items.iter().find(|item| {
1240 item.config.model_id == selected.model_id
1241 && item
1242 .config
1243 .files
1244 .iter()
1245 .any(|file| file == &selected.gguf_file)
1246 })
1247 {
1248 return Some(item.clone());
1249 }
1250 app.model_picker_items
1251 .iter()
1252 .find(|item| item.cloud_tier.is_none())
1253 .cloned()
1254 }
1255
1256 /// Load `model` on-device on a dedicated loader thread, routing inference to a
1257 /// fresh `LocalBackend` and driving the switch-progress UI. The caller is
1258 /// responsible for any cloud-tier handling; this path is on-device only.
1259 fn start_local_model_load<B: ratatui::backend::Backend>(
1260 app: &mut App,
1261 model: ModelPickerItem,
1262 engine: Arc<ChatEngine>,
1263 terminal: &mut ratatui::Terminal<B>,
1264 ) {
1265 if model.cache_health == ModelCacheHealth::Incomplete {
1266 app.messages.push(ChatMessage::system(format!(
1267 "error: {} has an incomplete local cache and cannot be selected yet.",
1268 model.display_name
1269 )));
1270 return;
1271 }
1272
1273 // Loading an on-device model puts us in local inference mode.
1274 let _ = crate::settings::set_local_inference(true);
1275
1276 // Route inference on-device; the loader thread below fills the engine the
1277 // LocalBackend reads from.
1278 app.backend = Arc::new(LocalBackend::new(Arc::clone(&engine)));
1279
1280 let loading_msg = if model.cache_health == ModelCacheHealth::NotDownloaded {
1281 format!(
1282 "Downloading and loading {} ({})… this may take a few minutes.",
1283 model.display_name, model.description
1284 )
1285 } else {
1286 format!("Loading {}…", model.display_name)
1287 };
1288
1289 app.messages.push(ChatMessage::system(loading_msg));
1290 terminal.draw(|frame| render(frame, app)).ok();
1291
1292 let (tx, rx) = mpsc::channel(1);
1293 app.model_load_rx = Some(rx);
1294 app.switching_model = true;
1295 app.switching_model_id = Some(model.config.model_id.clone());
1296 // Only show download progress for models not yet cached.
1297 app.download_progress = if model.cache_health == ModelCacheHealth::NotDownloaded {
1298 Some((0, 0))
1299 } else {
1300 None
1301 };
1302
1303 let sampling = SamplingConfig {
1304 max_tokens: Some(model.max_tokens),
1305 ..SamplingConfig::default()
1306 };
1307
1308 // own thread + runtime so block_in_place doesn't starve the TUI loop.
1309 // Fold in project instruction files (AGENTS.md / CLAUDE.md) for the launch
1310 // directory so the on-device model gets the same always-on context the
1311 // cloud and ACP paths get.
1312 let system_prompt = {
1313 let base = crate::system_prompt_for_model(model.tool_calling).to_string();
1314 match std::env::current_dir()
1315 .ok()
1316 .and_then(|cwd| crate::instructions::load_project_instructions(&cwd))
1317 {
1318 Some(extra) => format!("{base}\n\n{extra}"),
1319 None => base,
1320 }
1321 };
1322 let engine_handle = Arc::clone(&engine);
1323 let tool_calling = model.tool_calling;
1324 std::thread::spawn(move || {
1325 let rt = tokio::runtime::Runtime::new().expect("failed to create model-loader runtime");
1326 let update = rt.block_on(async move {
1327 match engine_handle
1328 .load_gguf_model(
1329 model.config.clone(),
1330 Some(system_prompt.to_string()),
1331 Some(sampling),
1332 )
1333 .await
1334 {
1335 Ok(_) => ModelLoadUpdate::Loaded(model.display_name.clone()),
1336 Err(err) => ModelLoadUpdate::Error(err.to_string()),
1337 }
1338 });
1339 // capacity-1 channel, receiver alive while switching
1340 let _ = tx.blocking_send(update);
1341 });
1342 // applied on ModelLoadUpdate::Loaded
1343 app.pending_tool_calling = Some(tool_calling);
1344 }
1345
1346 // ── Slash command execution ───────────────────────────────────────────────
1347
1348 async fn exec_slash<B: ratatui::backend::Backend>(
1349 app: &mut App,
1350 cmd: SlashCommand,
1351 engine: Arc<ChatEngine>,
1352 terminal: &mut ratatui::Terminal<B>,
1353 ) {
1354 match cmd {
1355 SlashCommand::Help => {
1356 app.messages.push(ChatMessage::system(
1357 "/help — show this message\n\
1358 /models — open the model picker\n\
1359 /models N — switch to model N\n\
1360 /local [on|off]— toggle on-device inference mode\n\
1361 /skills — list available Agent Skills\n\
1362 /load — load the selected on-device model\n\
1363 /login E P — sign in to siGit Code Cloud\n\
1364 /logout — sign out\n\
1365 /whoami — show the signed-in account\n\
1366 /clear — wipe conversation history\n\
1367 /status — show engine status\n\
1368 /exit — quit chat",
1369 ));
1370 }
1371 SlashCommand::Clear => {
1372 let cleared = engine.clear_history().await;
1373 app.messages.clear();
1374 app.messages.push(ChatMessage::system(format!(
1375 "Cleared {cleared} turn(s). History is empty.",
1376 )));
1377 }
1378 SlashCommand::Status => {
1379 let info = engine.as_ref().info().await;
1380 let model = info.model_name.as_deref().unwrap_or("(none)");
1381 let mem = info.approx_memory.as_deref().unwrap_or("unknown");
1382 app.messages.push(ChatMessage::system(format!(
1383 "status: {:?} model: {} memory: {} history: {} turns",
1384 info.status, model, mem, info.history_length,
1385 )));
1386 }
1387 SlashCommand::Skills => {
1388 app.messages
1389 .push(ChatMessage::system(crate::skills::format_skills_list()));
1390 }
1391 SlashCommand::Models(selection) => match selection {
1392 None => {
1393 app.open_model_picker(&engine);
1394 }
1395 Some(n) => {
1396 let idx = n.saturating_sub(1);
1397 match app.model_picker_items.get(idx).cloned() {
1398 None => {
1399 app.messages.push(ChatMessage::system(format!(
1400 "error: no model #{n} — type /models to see the list."
1401 )));
1402 }
1403 Some(model) => {
1404 // ── siGit Code Cloud tier: no local load; sign-in gated ──
1405 if let Some(tier) = model.cloud_tier.clone() {
1406 app.close_model_picker();
1407 match crate::provider::cloud_tier_provider(&tier) {
1408 Some(provider) => {
1409 let system_prompt =
1410 crate::system_prompt_for_model(true).to_string();
1411 app.backend = Arc::new(OpenAiBackend::new(
1412 provider.base_url,
1413 provider.api_key,
1414 provider.model,
1415 Some(system_prompt),
1416 ));
1417 app.current_model_name = provider.display_name.clone();
1418 app.tool_calling = true;
1419 // Selecting a cloud tier puts us in cloud mode.
1420 let _ = crate::settings::set_local_inference(false);
1421 app.messages.push(ChatMessage::system(format!(
1422 "Switched to {}.",
1423 provider.display_name
1424 )));
1425 }
1426 None => {
1427 app.messages.push(ChatMessage::system(
1428 "siGit Code Cloud needs an account. Use \
1429 `/login <email> <password>`, or create one at sigit.si.",
1430 ));
1431 }
1432 }
1433 return;
1434 }
1435
1436 app.close_model_picker();
1437 start_local_model_load(app, model, Arc::clone(&engine), terminal);
1438 }
1439 }
1440 }
1441 },
1442 SlashCommand::Local(value) => {
1443 let enabled = value.unwrap_or(!crate::settings::local_inference_enabled());
1444 match crate::settings::set_local_inference(enabled) {
1445 Ok(()) => {
1446 let state = if enabled { "on" } else { "off" };
1447 let hint = if enabled {
1448 "On-device models are highlighted. Type /models to pick one."
1449 } else {
1450 "siGit Code Cloud tiers are highlighted. Type /models to pick one."
1451 };
1452 app.messages.push(ChatMessage::system(format!(
1453 "Local inference is {state}. {hint}"
1454 )));
1455 // Refresh the picker so emphasis/order reflects the new mode.
1456 if app.show_model_picker {
1457 app.open_model_picker(&engine);
1458 }
1459 }
1460 Err(error) => {
1461 app.messages.push(ChatMessage::system(format!(
1462 "error: could not save local inference setting: {error}"
1463 )));
1464 }
1465 }
1466 }
1467 SlashCommand::Load => match default_local_model_item(app) {
1468 None => {
1469 app.messages.push(ChatMessage::system(
1470 "No local model available to load. Use /models to see the list.",
1471 ));
1472 }
1473 Some(model) => {
1474 start_local_model_load(app, model, Arc::clone(&engine), terminal);
1475 }
1476 },
1477 SlashCommand::Login(arg) => {
1478 let message = match arg.as_deref().and_then(crate::account::parse_login_args) {
1479 Some((email, password)) => {
1480 match crate::account::authenticate(&email, &password).await {
1481 Ok(email) => format!(
1482 "Signed in as {email}. siGit Code Cloud applies to your next session."
1483 ),
1484 Err(error) => format!("Login failed: {error}"),
1485 }
1486 }
1487 None => "usage: /login <email> <password>".to_string(),
1488 };
1489 app.messages.push(ChatMessage::system(message));
1490 }
1491 SlashCommand::Logout => {
1492 let message = crate::account::end_session().await;
1493 app.messages.push(ChatMessage::system(message));
1494 }
1495 SlashCommand::Whoami => {
1496 let message = crate::account::status_line().await;
1497 app.messages.push(ChatMessage::system(message));
1498 }
1499 SlashCommand::Exit => {
1500 app.quit = true;
1501 }
1502 SlashCommand::Unknown(cmd) => {
1503 app.messages
1504 .push(ChatMessage::system(format!("unknown command: {cmd}")));
1505 }
1506 }
1507 }
1508
1509 // ── Background inference task ─────────────────────────────────────────────
1510
1511 /// cap tool rounds so a confused model can't loop forever
1512 const MAX_TOOL_ROUNDS: usize = 10;
1513
1514 fn build_tool_specs() -> Vec<ToolSpec> {
1515 let mut specs: Vec<ToolSpec> = crate::tools::all_tools()
1516 .into_iter()
1517 .map(|t| ToolSpec {
1518 name: t.name.to_string(),
1519 description: t.description.to_string(),
1520 parameters_schema: t.parameters_schema.to_string(),
1521 })
1522 .collect();
1523
1524 // Advertise the Agent Skills `skill` tool only when skills exist on disk
1525 // (https://agentskills.io). The tool description carries the discovery
1526 // list (name + description) for progressive disclosure.
1527 let discovered = crate::skills::discover_skills();
1528 if !discovered.is_empty() {
1529 specs.push(ToolSpec {
1530 name: crate::skills::SKILL_TOOL_NAME.to_string(),
1531 description: crate::skills::skill_tool_description(&discovered),
1532 parameters_schema: crate::skills::skill_tool_schema().to_string(),
1533 });
1534 }
1535
1536 specs
1537 }
1538
1539 /// run the tool-calling loop off the main thread, posting updates via `tx`.
1540 /// dropping `tx` signals completion to the event loop.
1541 async fn run_inference_task(
1542 backend: Arc<dyn InferenceBackend>,
1543 text: String,
1544 tx: mpsc::Sender<InferenceUpdate>,
1545 tools_enabled: bool,
1546 ) {
1547 let tools = if tools_enabled {
1548 build_tool_specs()
1549 } else {
1550 vec![]
1551 };
1552
1553 // Bridge the backend's token sink (plain strings) onto the UI update
1554 // channel as `Delta` messages. The forwarder lives for the whole turn.
1555 let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
1556 let forward_tx = tx.clone();
1557 let forwarder = tokio::spawn(async move {
1558 while let Some(piece) = delta_rx.recv().await {
1559 if forward_tx
1560 .send(InferenceUpdate::Delta(piece))
1561 .await
1562 .is_err()
1563 {
1564 break;
1565 }
1566 }
1567 });
1568
1569 // The first round offers tools, so on-device inference can't stream it
1570 // (it must buffer to detect tool calls). With tools disabled there are
1571 // none to offer, so it streams directly.
1572 let first_sink = if tools.is_empty() {
1573 Some(&delta_tx)
1574 } else {
1575 None
1576 };
1577 let mut streamed = first_sink.is_some();
1578
1579 let mut result = match backend
1580 .send_message_with_tools(&text, &tools, first_sink)
1581 .await
1582 {
1583 Ok(r) => r,
1584 Err(err) => {
1585 let _ = tx.send(InferenceUpdate::Error(err)).await;
1586 return;
1587 }
1588 };
1589
1590 let mut round = 0;
1591
1592 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
1593 // any tool call means the first round didn't produce a final answer
1594 streamed = false;
1595 round += 1;
1596 log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
1597
1598 let mut tool_results = Vec::new();
1599
1600 for tc in &result.tool_calls {
1601 log::info!(
1602 " → {}({})",
1603 tc.name,
1604 tc.arguments.chars().take(120).collect::<String>()
1605 );
1606
1607 let _ = tx.send(InferenceUpdate::ToolUse(tc.name.clone())).await;
1608
1609 let output = crate::tools::execute_tool(&tc.name, &tc.arguments).await;
1610 log::info!(" ← {} chars", output.len());
1611
1612 tool_results.push(ToolResult {
1613 tool_call_id: tc.id.clone(),
1614 content: output,
1615 });
1616 }
1617
1618 // on the last round, pass no tools so the model must produce text —
1619 // that's also the round we can stream on-device.
1620 let next_tools = if round < MAX_TOOL_ROUNDS {
1621 Some(tools.as_slice())
1622 } else {
1623 None
1624 };
1625 let sink = if next_tools.is_none() {
1626 streamed = true;
1627 Some(&delta_tx)
1628 } else {
1629 None
1630 };
1631
1632 match backend
1633 .send_tool_results(tool_results, next_tools, sink)
1634 .await
1635 {
1636 Ok(r) => result = r,
1637 Err(err) => {
1638 let _ = tx.send(InferenceUpdate::Error(err)).await;
1639 return;
1640 }
1641 }
1642 }
1643
1644 // Drop the sink so the forwarder finishes draining any buffered tokens
1645 // before we commit the reply.
1646 drop(delta_tx);
1647 let _ = forwarder.await;
1648
1649 if result.tool_calls.is_empty() {
1650 if result.text.is_empty() {
1651 log::warn!(
1652 "model returned empty reply — may have exhausted max_tokens on thinking"
1653 );
1654 let _ = tx
1655 .send(InferenceUpdate::Error(
1656 "(empty response — the model may have used all tokens on internal reasoning. \
1657 Try a shorter or simpler prompt.)"
1658 .to_string(),
1659 ))
1660 .await;
1661 } else if streamed {
1662 // tokens already went out as deltas; just commit the buffer
1663 let _ = tx.send(InferenceUpdate::StreamEnd).await;
1664 } else {
1665 let _ = tx.send(InferenceUpdate::Response(result.text)).await;
1666 }
1667 }
1668
1669 log::info!("inference complete — {} tool round(s)", round);
1670 // tx drops here — event loop gets None from rx.recv()
1671 }
1672
1673 // ── Main loop ─────────────────────────────────────────────────────────────
1674
1675 /// entry point — blocks until the user quits.
1676 /// caller owns terminal init/restore. `load_rx` delivers the model-load result
1677 /// from a dedicated OS thread; we poll it non-blocking each tick.
1678 pub async fn run_with<B: ratatui::backend::Backend>(
1679 terminal: &mut ratatui::Terminal<B>,
1680 engine: Arc<ChatEngine>,
1681 backend: Arc<dyn InferenceBackend>,
1682 load_rx: std_mpsc::Receiver<Result<(), String>>,
1683 load_model_name: String,
1684 ) -> Result<()> {
1685 event_loop(terminal, engine, backend, load_rx, load_model_name).await
1686 }
1687
1688 async fn event_loop<B: ratatui::backend::Backend>(
1689 terminal: &mut ratatui::Terminal<B>,
1690 engine: Arc<ChatEngine>,
1691 backend: Arc<dyn InferenceBackend>,
1692 load_rx: std_mpsc::Receiver<Result<(), String>>,
1693 load_model_name: String,
1694 ) -> Result<()> {
1695 let mut app = App::new(load_model_name, backend);
1696 let mut event_stream = EventStream::new();
1697
1698 // 10 fps is plenty for spinners
1699 let mut ticker = interval(Duration::from_millis(100));
1700
1701 loop {
1702 // ── Poll the loader channel (non-blocking) ────────────────────────
1703 if app.is_loading {
1704 match load_rx.try_recv() {
1705 Ok(Ok(())) => app.finish_loading(),
1706 Ok(Err(e)) => app.set_load_error(e),
1707 Err(std_mpsc::TryRecvError::Empty) => {}
1708 Err(std_mpsc::TryRecvError::Disconnected) => {
1709 app.set_load_error("Model loader thread crashed.".to_string());
1710 }
1711 }
1712 }
1713
1714 // redraw every iteration
1715 terminal.draw(|frame| render(frame, &mut app))?;
1716
1717 if let Some(rx) = app.model_load_rx.as_mut() {
1718 match rx.try_recv() {
1719 Ok(ModelLoadUpdate::Loaded(model_name)) => {
1720 engine.clear_history().await;
1721 if let Some(tc) = app.pending_tool_calling.take() {
1722 app.tool_calling = tc;
1723 }
1724 app.switching_model = false;
1725 app.switching_model_id = None;
1726 app.download_progress = None;
1727 app.model_load_cancelled = false;
1728 app.model_load_rx = None;
1729 app.current_model_name = model_name.clone();
1730
1731 let save_result = app
1732 .model_picker_items
1733 .iter()
1734 .find(|item| item.display_name == model_name)
1735 .map(|item| crate::setup::SelectedModel {
1736 model_id: item.config.model_id.clone(),
1737 gguf_file: item
1738 .config
1739 .files
1740 .first()
1741 .cloned()
1742 .unwrap_or_else(String::new),
1743 })
1744 .filter(|selected| !selected.gguf_file.is_empty())
1745 .map(|selected| crate::setup::save_selected_model(&selected))
1746 .unwrap_or_else(|| {
1747 Err(format!(
1748 "could not determine a stable identifier for {}",
1749 model_name
1750 ))
1751 });
1752
1753 if let Err(error) = save_result {
1754 app.messages.push(ChatMessage::system(format!(
1755 "warning: switched to {} but could not save the selection: {}",
1756 model_name, error
1757 )));
1758 } else {
1759 app.messages
1760 .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
1761 }
1762 }
1763 Ok(ModelLoadUpdate::Error(error)) => {
1764 app.switching_model = false;
1765 app.switching_model_id = None;
1766 app.download_progress = None;
1767 app.model_load_cancelled = false;
1768 app.model_load_rx = None;
1769 app.messages
1770 .push(ChatMessage::system(format!("error loading model: {error}")));
1771 }
1772 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
1773 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
1774 let was_cancelled = app.model_load_cancelled;
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 if !was_cancelled {
1781 app.messages.push(ChatMessage::system(
1782 "error loading model: loader task disconnected".to_string(),
1783 ));
1784 }
1785 }
1786 }
1787 }
1788
1789 if app.quit {
1790 break;
1791 }
1792
1793 // multiplex terminal events, streaming tokens, inference updates,
1794 // and the thinking-spinner timer.
1795 tokio::select! {
1796 biased;
1797
1798 // ── Spinner tick (loading phase only) ─────────────────────────
1799 _ = ticker.tick(), if app.is_loading => {
1800 app.tick();
1801 }
1802
1803 // ── inference updates from background task ───────────────────
1804 update = async {
1805 match app.inference_rx.as_mut() {
1806 Some(rx) => rx.recv().await,
1807 None => pending().await,
1808 }
1809 } => {
1810 match update {
1811 Some(InferenceUpdate::ToolUse(name)) => {
1812 app.messages.push(ChatMessage::system(format!("🔧 {name}")));
1813 }
1814 Some(InferenceUpdate::Delta(delta)) => {
1815 app.push_stream_delta(&delta);
1816 }
1817 Some(InferenceUpdate::StreamEnd) => {
1818 app.finalize_stream();
1819 }
1820 Some(InferenceUpdate::Response(text)) => {
1821 app.stop_thinking();
1822 app.messages.push(ChatMessage::assistant(text));
1823 }
1824 Some(InferenceUpdate::Error(msg)) => {
1825 app.finalize_stream();
1826 app.stop_thinking();
1827 app.messages.push(ChatMessage::system(format!("error: {msg}")));
1828 }
1829 None => {
1830 // task finished, possibly with no text to show
1831 app.finalize_stream();
1832 app.stop_thinking();
1833 }
1834 }
1835 }
1836
1837 // ── thinking / switching spinner tick (100ms) ────────────────
1838 _ = async {
1839 if app.thinking || app.switching_model {
1840 tokio::time::sleep(Duration::from_millis(100)).await
1841 } else {
1842 pending().await
1843 }
1844 } => {
1845 app.tick_thinking();
1846 // keep the progress display fresh
1847 if app.switching_model {
1848 app.poll_download_progress();
1849 }
1850 }
1851
1852 // ── Terminal events ───────────────────────────────────────────
1853 maybe_event = event_stream.next() => {
1854 let Some(Ok(event)) = maybe_event else {
1855 break;
1856 };
1857
1858 if let Event::Key(key) = event {
1859 // loading phase — only quit keys work
1860 if app.is_loading {
1861 if key.kind == KeyEventKind::Press {
1862 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1863 if ctrl
1864 && (key.code == KeyCode::Char('c')
1865 || key.code == KeyCode::Char('d'))
1866 {
1867 app.quit = true;
1868 }
1869 }
1870 continue;
1871 }
1872
1873 // busy — only cancel keys work
1874 if app.is_busy() {
1875 if key.kind == KeyEventKind::Press {
1876 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1877 if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
1878 if app.is_streaming() {
1879 app.finalize_stream();
1880 app.messages.push(ChatMessage::system("(cancelled)"));
1881 }
1882 if app.thinking {
1883 // dropping rx kills the background task
1884 app.stop_thinking();
1885 app.messages.push(ChatMessage::system("(cancelled)"));
1886 }
1887 if app.switching_model {
1888 // flag before drop so Disconnected handler stays quiet
1889 app.model_load_cancelled = true;
1890 app.switching_model = false;
1891 app.switching_model_id = None;
1892 app.download_progress = None;
1893 app.model_load_rx = None;
1894 app.messages
1895 .push(ChatMessage::system("(download cancelled — model switch aborted)"));
1896 }
1897 }
1898 }
1899 continue;
1900 }
1901
1902 if let Some(text) = handle_key(&mut app, key) {
1903 if let Some(cmd) = parse_slash(&text) {
1904 exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await;
1905 continue;
1906 }
1907
1908 // On-device inference needs a model in memory, and we
1909 // never load one implicitly: the user loads it with
1910 // /load (or /models). Refuse rather than erroring out
1911 // deep in the backend.
1912 if !app.backend.is_remote()
1913 && engine.info().await.status == onde::inference::EngineStatus::Unloaded
1914 {
1915 app.messages.push(ChatMessage::user(&text));
1916 app.messages.push(ChatMessage::system(
1917 "No on-device model is loaded. Run /load to load the selected \
1918 model, or /models to choose one.",
1919 ));
1920 continue;
1921 }
1922
1923 // ── spawn inference ──────────────────────────────
1924 app.messages.push(ChatMessage::user(&text));
1925 app.start_thinking();
1926
1927 let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
1928 app.inference_rx = Some(rx);
1929
1930 let backend_handle = Arc::clone(&app.backend);
1931 let user_text = text.clone();
1932 let tools_enabled = app.tool_calling;
1933 tokio::spawn(async move {
1934 run_inference_task(backend_handle, user_text, tx, tools_enabled).await;
1935 });
1936 }
1937 }
1938 }
1939 }
1940 }
1941
1942 Ok(())
1943 }
1944
1945 // ── Download progress helpers (TUI) ──────────────────────────────────────
1946
1947 /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks)
1948 fn dir_size_recursive(path: &std::path::Path) -> u64 {
1949 let mut total: u64 = 0;
1950 let Ok(entries) = std::fs::read_dir(path) else {
1951 return 0;
1952 };
1953 for entry in entries.flatten() {
1954 let entry_path = entry.path();
1955 if entry_path.is_dir() {
1956 total += dir_size_recursive(&entry_path);
1957 } else if let Ok(meta) = entry_path.metadata() {
1958 total += meta.len();
1959 }
1960 }
1961 total
1962 }
1963
1964 fn format_size_human(bytes: u64) -> String {
1965 const GB: u64 = 1_073_741_824;
1966 const MB: u64 = 1_048_576;
1967 const KB: u64 = 1_024;
1968 if bytes >= GB {
1969 format!("{:.2} GB", bytes as f64 / GB as f64)
1970 } else if bytes >= MB {
1971 format!("{:.1} MB", bytes as f64 / MB as f64)
1972 } else if bytes >= KB {
1973 format!("{:.0} KB", bytes as f64 / KB as f64)
1974 } else {
1975 format!("{bytes} B")
1976 }
1977 }
1978 } // end #[cfg(unix)] mod tui
1979
1980 // re-export so callers write `chat::run_with(...)` on all platforms
1981 #[cfg(unix)]
1982 pub use tui::run_with;
1983
1984 // ── Tests (platform-agnostic) ─────────────────────────────────────────────────
1985
1986 #[cfg(test)]
1987 mod tests {
1988 use super::{parse_rich_text_segments, strip_think_blocks};
1989
1990 #[test]
1991 fn strip_think_blocks_separates_thinking_and_visible_reply() {
1992 let raw = "<think>I should inspect the code first.</think>Here is the fix.";
1993 let (thinking, visible) = strip_think_blocks(raw);
1994
1995 assert_eq!(thinking, "I should inspect the code first.");
1996 assert_eq!(visible, "Here is the fix.");
1997 }
1998
1999 #[test]
2000 fn strip_think_blocks_handles_unclosed_think_block() {
2001 let raw = "<think>I am still reasoning about the bug";
2002 let (thinking, visible) = strip_think_blocks(raw);
2003
2004 assert_eq!(thinking, "I am still reasoning about the bug");
2005 assert_eq!(visible, "");
2006 }
2007
2008 #[test]
2009 fn strip_think_blocks_leaves_plain_text_untouched() {
2010 let raw = "No hidden reasoning here.";
2011 let (thinking, visible) = strip_think_blocks(raw);
2012
2013 assert_eq!(thinking, "");
2014 assert_eq!(visible, "No hidden reasoning here.");
2015 }
2016
2017 #[test]
2018 fn parse_rich_text_segments_marks_bold_runs() {
2019 let segments = parse_rich_text_segments(
2020 "The current weather is **72°F** with **Partly Cloudy** conditions.",
2021 );
2022
2023 assert_eq!(
2024 segments,
2025 vec![
2026 ("The current weather is ".to_string(), false),
2027 ("72°F".to_string(), true),
2028 (" with ".to_string(), false),
2029 ("Partly Cloudy".to_string(), true),
2030 (" conditions.".to_string(), false),
2031 ]
2032 );
2033 }
2034
2035 #[test]
2036 fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
2037 let segments = parse_rich_text_segments("Prefix **bold");
2038
2039 assert_eq!(
2040 segments,
2041 vec![("Prefix ".to_string(), false), ("bold".to_string(), true),]
2042 );
2043 }
2044 }