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