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