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 // Delegated research (`task`) is offered only when a subagent backend
1604 // can actually be built — same conditional pattern as `skill` above.
1605 if crate::tools::subagent_available() {
1606 specs.push(crate::tools::task_tool_spec());
1607 }
1608
1609 // Tools discovered from configured MCP servers (incl. the official one).
1610 specs.extend(crate::mcp::tool_specs());
1611
1612 specs
1613 }
1614
1615 /// Close out a cancelled round in backend history: the results of tools
1616 /// that already ran this round, plus cancellation notes for `unreached`
1617 /// calls. Leaving a round's tool calls unanswered breaks strict
1618 /// OpenAI-compatible endpoints on the session's next request.
1619 async fn abandon_round(
1620 backend: &dyn InferenceBackend,
1621 mut tool_results: Vec<ToolResult>,
1622 unreached: &[crate::backend::ToolCall],
1623 ) {
1624 for pending in unreached {
1625 tool_results.push(ToolResult {
1626 tool_call_id: pending.id.clone(),
1627 content: format!(
1628 "`{}` was not executed: the user cancelled the turn.",
1629 pending.name
1630 ),
1631 });
1632 }
1633 backend.record_cancelled_tool_results(tool_results).await;
1634 }
1635
1636 /// run the tool-calling loop off the main thread, posting updates via `tx`.
1637 /// dropping `tx` signals completion to the event loop.
1638 async fn run_inference_task(
1639 backend: Arc<dyn InferenceBackend>,
1640 text: String,
1641 tx: mpsc::Sender<InferenceUpdate>,
1642 tools_enabled: bool,
1643 ) {
1644 let tools = if tools_enabled {
1645 build_tool_specs()
1646 } else {
1647 vec![]
1648 };
1649
1650 // Bridge the backend's token sink (plain strings) onto the UI update
1651 // channel as `Delta` messages. The forwarder lives for the whole turn.
1652 let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
1653 let forward_tx = tx.clone();
1654 let forwarder = tokio::spawn(async move {
1655 while let Some(piece) = delta_rx.recv().await {
1656 if forward_tx
1657 .send(InferenceUpdate::Delta(piece))
1658 .await
1659 .is_err()
1660 {
1661 break;
1662 }
1663 }
1664 });
1665
1666 // The first round offers tools, so on-device inference can't stream it
1667 // (it must buffer to detect tool calls). With tools disabled there are
1668 // none to offer, so it streams directly.
1669 let first_sink = if tools.is_empty() {
1670 Some(&delta_tx)
1671 } else {
1672 None
1673 };
1674 let mut streamed = first_sink.is_some();
1675
1676 let mut result = match backend
1677 .send_message_with_tools(&text, &tools, first_sink)
1678 .await
1679 {
1680 Ok(r) => r,
1681 Err(err) => {
1682 let _ = tx.send(InferenceUpdate::Error(err)).await;
1683 return;
1684 }
1685 };
1686
1687 let mut round = 0;
1688
1689 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
1690 // any tool call means the first round didn't produce a final answer
1691 streamed = false;
1692 round += 1;
1693 log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
1694
1695 let mut tool_results = Vec::new();
1696
1697 for (call_index, tc) in result.tool_calls.iter().enumerate() {
1698 // The UI drops the receiver on Ctrl+C or quit. Stop the turn
1699 // at the next boundary instead of burning model rounds (and
1700 // possibly running granted tools) in the background.
1701 if tx.is_closed() {
1702 log::info!("turn cancelled by the user — stopping the tool loop");
1703 abandon_round(&*backend, tool_results, &result.tool_calls[call_index..]).await;
1704 return;
1705 }
1706
1707 log::info!(
1708 " → {}({})",
1709 tc.name,
1710 tc.arguments.chars().take(120).collect::<String>()
1711 );
1712
1713 let _ = tx.send(InferenceUpdate::ToolUse(tc.name.clone())).await;
1714
1715 // Permission gate: read-only tools pass straight through; a
1716 // mutating tool consults policy and may pause on the user's
1717 // y/a/n answer (delivered over a oneshot from the event loop).
1718 use crate::permissions::{self, Decision, TUI_SESSION};
1719 let output = match permissions::decision_for(TUI_SESSION, &tc.name) {
1720 Decision::Allow => crate::tools::execute_tool(&tc.name, &tc.arguments).await,
1721 Decision::Deny(reason) => {
1722 log::info!(" ✗ {} denied by policy", tc.name);
1723 reason
1724 }
1725 Decision::Ask => {
1726 let (reply_tx, reply_rx) = oneshot::channel();
1727 let _ = tx
1728 .send(InferenceUpdate::ApprovalRequest {
1729 tool: tc.name.clone(),
1730 args: permissions::approval_preview(&tc.arguments),
1731 reply: reply_tx,
1732 })
1733 .await;
1734 match reply_rx.await {
1735 Ok(ApprovalChoice::Once) => {
1736 crate::tools::execute_tool(&tc.name, &tc.arguments).await
1737 }
1738 Ok(ApprovalChoice::Session) => {
1739 permissions::grant_for_session(TUI_SESSION, &tc.name);
1740 crate::tools::execute_tool(&tc.name, &tc.arguments).await
1741 }
1742 Ok(ApprovalChoice::Deny) => {
1743 log::info!(" ✗ {} denied by user", tc.name);
1744 permissions::user_denial(&tc.name)
1745 }
1746 // The UI dropped the reply channel (Ctrl+C or
1747 // quit): the whole turn is over, not just this
1748 // call. Close out the round and stop instead of
1749 // continuing rounds in the background.
1750 Err(_) => {
1751 log::info!(
1752 "turn cancelled at the approval prompt — stopping the tool loop"
1753 );
1754 abandon_round(
1755 &*backend,
1756 tool_results,
1757 &result.tool_calls[call_index..],
1758 )
1759 .await;
1760 return;
1761 }
1762 }
1763 }
1764 };
1765 log::info!(" ← {} chars", output.len());
1766
1767 tool_results.push(ToolResult {
1768 tool_call_id: tc.id.clone(),
1769 content: output,
1770 });
1771 }
1772
1773 // Cancelled while the round's tools ran: record what executed and
1774 // stop before paying for another model round nobody will see.
1775 if tx.is_closed() {
1776 log::info!("turn cancelled by the user — skipping the next model round");
1777 abandon_round(&*backend, tool_results, &[]).await;
1778 return;
1779 }
1780
1781 // on the last round, pass no tools so the model must produce text —
1782 // that's also the round we can stream on-device.
1783 let next_tools = if round < MAX_TOOL_ROUNDS {
1784 Some(tools.as_slice())
1785 } else {
1786 None
1787 };
1788 let sink = if next_tools.is_none() {
1789 streamed = true;
1790 Some(&delta_tx)
1791 } else {
1792 None
1793 };
1794
1795 match backend
1796 .send_tool_results(tool_results, next_tools, sink)
1797 .await
1798 {
1799 Ok(r) => result = r,
1800 Err(err) => {
1801 let _ = tx.send(InferenceUpdate::Error(err)).await;
1802 return;
1803 }
1804 }
1805 }
1806
1807 // Drop the sink so the forwarder finishes draining any buffered tokens
1808 // before we commit the reply.
1809 drop(delta_tx);
1810 let _ = forwarder.await;
1811
1812 if result.tool_calls.is_empty() {
1813 if result.text.is_empty() {
1814 log::warn!(
1815 "model returned empty reply — may have exhausted max_tokens on thinking"
1816 );
1817 let _ = tx
1818 .send(InferenceUpdate::Error(
1819 "(empty response — the model may have used all tokens on internal reasoning. \
1820 Try a shorter or simpler prompt.)"
1821 .to_string(),
1822 ))
1823 .await;
1824 } else if streamed {
1825 // tokens already went out as deltas; just commit the buffer
1826 let _ = tx.send(InferenceUpdate::StreamEnd).await;
1827 } else {
1828 let _ = tx.send(InferenceUpdate::Response(result.text)).await;
1829 }
1830 }
1831
1832 log::info!("inference complete — {} tool round(s)", round);
1833 // tx drops here — event loop gets None from rx.recv()
1834 }
1835
1836 // ── Main loop ─────────────────────────────────────────────────────────────
1837
1838 /// entry point — blocks until the user quits.
1839 /// caller owns terminal init/restore. `load_rx` delivers the model-load result
1840 /// from a dedicated OS thread; we poll it non-blocking each tick.
1841 pub async fn run_with<B: ratatui::backend::Backend>(
1842 terminal: &mut ratatui::Terminal<B>,
1843 engine: Arc<ChatEngine>,
1844 backend: Arc<dyn InferenceBackend>,
1845 load_rx: std_mpsc::Receiver<Result<(), String>>,
1846 load_model_name: String,
1847 ) -> Result<()> {
1848 event_loop(terminal, engine, backend, load_rx, load_model_name).await
1849 }
1850
1851 async fn event_loop<B: ratatui::backend::Backend>(
1852 terminal: &mut ratatui::Terminal<B>,
1853 engine: Arc<ChatEngine>,
1854 backend: Arc<dyn InferenceBackend>,
1855 load_rx: std_mpsc::Receiver<Result<(), String>>,
1856 load_model_name: String,
1857 ) -> Result<()> {
1858 let mut app = App::new(load_model_name, backend);
1859 let mut event_stream = EventStream::new();
1860
1861 // 10 fps is plenty for spinners
1862 let mut ticker = interval(Duration::from_millis(100));
1863
1864 loop {
1865 // ── Poll the loader channel (non-blocking) ────────────────────────
1866 if app.is_loading {
1867 match load_rx.try_recv() {
1868 Ok(Ok(())) => app.finish_loading(),
1869 Ok(Err(e)) => app.set_load_error(e),
1870 Err(std_mpsc::TryRecvError::Empty) => {}
1871 Err(std_mpsc::TryRecvError::Disconnected) => {
1872 app.set_load_error("Model loader thread crashed.".to_string());
1873 }
1874 }
1875 }
1876
1877 // redraw every iteration
1878 terminal.draw(|frame| render(frame, &mut app))?;
1879
1880 if let Some(rx) = app.model_load_rx.as_mut() {
1881 match rx.try_recv() {
1882 Ok(ModelLoadUpdate::Loaded(model_name)) => {
1883 engine.clear_history().await;
1884 if let Some(tc) = app.pending_tool_calling.take() {
1885 app.tool_calling = tc;
1886 }
1887 app.switching_model = false;
1888 app.switching_model_id = None;
1889 app.download_progress = None;
1890 app.model_load_cancelled = false;
1891 app.model_load_rx = None;
1892 app.current_model_name = model_name.clone();
1893
1894 let save_result = app
1895 .model_picker_items
1896 .iter()
1897 .find(|item| item.display_name == model_name)
1898 .map(|item| crate::setup::SelectedModel {
1899 model_id: item.config.model_id.clone(),
1900 gguf_file: item
1901 .config
1902 .files
1903 .first()
1904 .cloned()
1905 .unwrap_or_else(String::new),
1906 })
1907 .filter(|selected| !selected.gguf_file.is_empty())
1908 .map(|selected| crate::setup::save_selected_model(&selected))
1909 .unwrap_or_else(|| {
1910 Err(format!(
1911 "could not determine a stable identifier for {}",
1912 model_name
1913 ))
1914 });
1915
1916 if let Err(error) = save_result {
1917 app.messages.push(ChatMessage::system(format!(
1918 "warning: switched to {} but could not save the selection: {}",
1919 model_name, error
1920 )));
1921 } else {
1922 app.messages
1923 .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
1924 }
1925 }
1926 Ok(ModelLoadUpdate::Error(error)) => {
1927 app.switching_model = false;
1928 app.switching_model_id = None;
1929 app.download_progress = None;
1930 app.model_load_cancelled = false;
1931 app.model_load_rx = None;
1932 app.messages
1933 .push(ChatMessage::system(format!("error loading model: {error}")));
1934 }
1935 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
1936 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
1937 let was_cancelled = app.model_load_cancelled;
1938 app.switching_model = false;
1939 app.switching_model_id = None;
1940 app.download_progress = None;
1941 app.model_load_cancelled = false;
1942 app.model_load_rx = None;
1943 if !was_cancelled {
1944 app.messages.push(ChatMessage::system(
1945 "error loading model: loader task disconnected".to_string(),
1946 ));
1947 }
1948 }
1949 }
1950 }
1951
1952 if app.quit {
1953 break;
1954 }
1955
1956 // multiplex terminal events, streaming tokens, inference updates,
1957 // and the thinking-spinner timer.
1958 tokio::select! {
1959 biased;
1960
1961 // ── Spinner tick (loading phase only) ─────────────────────────
1962 _ = ticker.tick(), if app.is_loading => {
1963 app.tick();
1964 }
1965
1966 // ── inference updates from background task ───────────────────
1967 update = async {
1968 match app.inference_rx.as_mut() {
1969 Some(rx) => rx.recv().await,
1970 None => pending().await,
1971 }
1972 } => {
1973 match update {
1974 Some(InferenceUpdate::ToolUse(name)) => {
1975 app.messages.push(ChatMessage::system(format!("🔧 {name}")));
1976 }
1977 Some(InferenceUpdate::Delta(delta)) => {
1978 app.push_stream_delta(&delta);
1979 }
1980 Some(InferenceUpdate::StreamEnd) => {
1981 app.finalize_stream();
1982 }
1983 Some(InferenceUpdate::Response(text)) => {
1984 app.stop_thinking();
1985 app.messages.push(ChatMessage::assistant(text));
1986 }
1987 Some(InferenceUpdate::Error(msg)) => {
1988 app.finalize_stream();
1989 app.stop_thinking();
1990 app.messages.push(ChatMessage::system(format!("error: {msg}")));
1991 }
1992 Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
1993 let call = if args.is_empty() {
1994 tool.clone()
1995 } else {
1996 format!("{tool}({args})")
1997 };
1998 app.messages.push(ChatMessage::system(format!(
1999 "⚠ permission — allow {call}? [y]es · [a]lways this session · [n]o"
2000 )));
2001 app.pending_approval = Some((tool, reply));
2002 }
2003 None => {
2004 // task finished, possibly with no text to show
2005 app.finalize_stream();
2006 app.stop_thinking();
2007 }
2008 }
2009 }
2010
2011 // ── thinking / switching spinner tick (100ms) ────────────────
2012 _ = async {
2013 if app.thinking || app.switching_model {
2014 tokio::time::sleep(Duration::from_millis(100)).await
2015 } else {
2016 pending().await
2017 }
2018 } => {
2019 app.tick_thinking();
2020 // keep the progress display fresh
2021 if app.switching_model {
2022 app.poll_download_progress();
2023 }
2024 }
2025
2026 // ── Terminal events ───────────────────────────────────────────
2027 maybe_event = event_stream.next() => {
2028 let Some(Ok(event)) = maybe_event else {
2029 break;
2030 };
2031
2032 if let Event::Key(key) = event {
2033 // loading phase — only quit keys work
2034 if app.is_loading {
2035 if key.kind == KeyEventKind::Press {
2036 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
2037 if ctrl
2038 && (key.code == KeyCode::Char('c')
2039 || key.code == KeyCode::Char('d'))
2040 {
2041 app.quit = true;
2042 }
2043 }
2044 continue;
2045 }
2046
2047 // pending tool approval — y/a/n answer the prompt; the
2048 // inference task is paused on the reply channel. Checked
2049 // before the busy gate because the app *is* busy here.
2050 if app.pending_approval.is_some() {
2051 if key.kind == KeyEventKind::Press {
2052 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
2053 let choice = if ctrl
2054 && (key.code == KeyCode::Char('c')
2055 || key.code == KeyCode::Char('d'))
2056 {
2057 // cancel the whole turn: denying is implicit
2058 // in dropping the reply channel
2059 app.pending_approval = None;
2060 app.stop_thinking();
2061 app.messages.push(ChatMessage::system("(cancelled)"));
2062 continue;
2063 } else {
2064 match key.code {
2065 KeyCode::Char('y') | KeyCode::Char('Y') => {
2066 Some(ApprovalChoice::Once)
2067 }
2068 KeyCode::Char('a') | KeyCode::Char('A') => {
2069 Some(ApprovalChoice::Session)
2070 }
2071 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
2072 Some(ApprovalChoice::Deny)
2073 }
2074 _ => None,
2075 }
2076 };
2077 if let Some(choice) = choice
2078 && let Some((tool, reply)) = app.pending_approval.take()
2079 {
2080 let verdict = match &choice {
2081 ApprovalChoice::Once => "allowed once",
2082 ApprovalChoice::Session => "allowed for this session",
2083 ApprovalChoice::Deny => "denied",
2084 };
2085 app.messages.push(ChatMessage::system(format!(
2086 "{tool}: {verdict}"
2087 )));
2088 let _ = reply.send(choice);
2089 }
2090 }
2091 continue;
2092 }
2093
2094 // busy — only cancel keys work
2095 if app.is_busy() {
2096 if key.kind == KeyEventKind::Press {
2097 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
2098 if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
2099 if app.is_streaming() {
2100 app.finalize_stream();
2101 app.messages.push(ChatMessage::system("(cancelled)"));
2102 }
2103 if app.thinking {
2104 // dropping rx kills the background task
2105 app.stop_thinking();
2106 app.messages.push(ChatMessage::system("(cancelled)"));
2107 }
2108 if app.switching_model {
2109 // flag before drop so Disconnected handler stays quiet
2110 app.model_load_cancelled = true;
2111 app.switching_model = false;
2112 app.switching_model_id = None;
2113 app.download_progress = None;
2114 app.model_load_rx = None;
2115 app.messages
2116 .push(ChatMessage::system("(download cancelled — model switch aborted)"));
2117 }
2118 }
2119 }
2120 continue;
2121 }
2122
2123 if let Some(text) = handle_key(&mut app, key) {
2124 if let Some(cmd) = parse_slash(&text) {
2125 exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await;
2126 continue;
2127 }
2128
2129 // On-device inference needs a model in memory, and we
2130 // never load one implicitly: the user loads it with
2131 // /load (or /models). Refuse rather than erroring out
2132 // deep in the backend.
2133 if !app.backend.is_remote()
2134 && engine.info().await.status == onde::inference::EngineStatus::Unloaded
2135 {
2136 app.messages.push(ChatMessage::user(&text));
2137 app.messages.push(ChatMessage::system(
2138 "No on-device model is loaded. Run /load to load the selected \
2139 model, or /models to choose one.",
2140 ));
2141 continue;
2142 }
2143
2144 // ── spawn inference ──────────────────────────────
2145 app.messages.push(ChatMessage::user(&text));
2146 app.start_thinking();
2147
2148 let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
2149 app.inference_rx = Some(rx);
2150
2151 let backend_handle = Arc::clone(&app.backend);
2152 let user_text = text.clone();
2153 let tools_enabled = app.tool_calling;
2154 tokio::spawn(async move {
2155 run_inference_task(backend_handle, user_text, tx, tools_enabled).await;
2156 });
2157 }
2158 }
2159 }
2160 }
2161 }
2162
2163 Ok(())
2164 }
2165
2166 // ── Download progress helpers (TUI) ──────────────────────────────────────
2167
2168 /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks)
2169 fn dir_size_recursive(path: &std::path::Path) -> u64 {
2170 let mut total: u64 = 0;
2171 let Ok(entries) = std::fs::read_dir(path) else {
2172 return 0;
2173 };
2174 for entry in entries.flatten() {
2175 let entry_path = entry.path();
2176 if entry_path.is_dir() {
2177 total += dir_size_recursive(&entry_path);
2178 } else if let Ok(meta) = entry_path.metadata() {
2179 total += meta.len();
2180 }
2181 }
2182 total
2183 }
2184
2185 fn format_size_human(bytes: u64) -> String {
2186 const GB: u64 = 1_073_741_824;
2187 const MB: u64 = 1_048_576;
2188 const KB: u64 = 1_024;
2189 if bytes >= GB {
2190 format!("{:.2} GB", bytes as f64 / GB as f64)
2191 } else if bytes >= MB {
2192 format!("{:.1} MB", bytes as f64 / MB as f64)
2193 } else if bytes >= KB {
2194 format!("{:.0} KB", bytes as f64 / KB as f64)
2195 } else {
2196 format!("{bytes} B")
2197 }
2198 }
2199 } // end #[cfg(unix)] mod tui
2200
2201 // re-export so callers write `chat::run_with(...)` on all platforms
2202 #[cfg(unix)]
2203 pub use tui::run_with;
2204
2205 // ── Tests (platform-agnostic) ─────────────────────────────────────────────────
2206
2207 #[cfg(test)]
2208 mod tests {
2209 use super::{parse_rich_text_segments, strip_think_blocks};
2210
2211 #[test]
2212 fn strip_think_blocks_separates_thinking_and_visible_reply() {
2213 let raw = "<think>I should inspect the code first.</think>Here is the fix.";
2214 let (thinking, visible) = strip_think_blocks(raw);
2215
2216 assert_eq!(thinking, "I should inspect the code first.");
2217 assert_eq!(visible, "Here is the fix.");
2218 }
2219
2220 #[test]
2221 fn strip_think_blocks_handles_unclosed_think_block() {
2222 let raw = "<think>I am still reasoning about the bug";
2223 let (thinking, visible) = strip_think_blocks(raw);
2224
2225 assert_eq!(thinking, "I am still reasoning about the bug");
2226 assert_eq!(visible, "");
2227 }
2228
2229 #[test]
2230 fn strip_think_blocks_leaves_plain_text_untouched() {
2231 let raw = "No hidden reasoning here.";
2232 let (thinking, visible) = strip_think_blocks(raw);
2233
2234 assert_eq!(thinking, "");
2235 assert_eq!(visible, "No hidden reasoning here.");
2236 }
2237
2238 #[test]
2239 fn parse_rich_text_segments_marks_bold_runs() {
2240 let segments = parse_rich_text_segments(
2241 "The current weather is **72°F** with **Partly Cloudy** conditions.",
2242 );
2243
2244 assert_eq!(
2245 segments,
2246 vec![
2247 ("The current weather is ".to_string(), false),
2248 ("72°F".to_string(), true),
2249 (" with ".to_string(), false),
2250 ("Partly Cloudy".to_string(), true),
2251 (" conditions.".to_string(), false),
2252 ]
2253 );
2254 }
2255
2256 #[test]
2257 fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
2258 let segments = parse_rich_text_segments("Prefix **bold");
2259
2260 assert_eq!(
2261 segments,
2262 vec![("Prefix ".to_string(), false), ("bold".to_string(), true),]
2263 );
2264 }
2265 }