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