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 // ── Tabs ──────────────────────────────────────────────────────────────────────
66 //
67 // The top-level tab bar (GitHub Copilot CLI-style). Defined outside `mod tui`
68 // so the pure cycling/formatting logic is testable on every target; only the
69 // Unix-only TUI consumes it at runtime, hence the non-Unix dead-code gates
70 // (same pattern as `permissions::TUI_SESSION`).
71
72 /// The three top-level TUI tabs, cycled with the Tab key.
73 #[cfg_attr(not(unix), allow(dead_code))]
74 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
75 pub(crate) enum Tab {
76 /// The chat itself (default).
77 Session,
78 /// Saved sessions from the session store.
79 History,
80 /// siGit Code Cloud status and settings.
81 Cloud,
82 }
83
84 #[cfg_attr(not(unix), allow(dead_code))]
85 impl Tab {
86 pub(crate) const TITLES: [&'static str; 3] = ["Session", "History", "Cloud"];
87
88 /// Session → History → Cloud → Session.
89 pub(crate) fn next(self) -> Self {
90 match self {
91 Tab::Session => Tab::History,
92 Tab::History => Tab::Cloud,
93 Tab::Cloud => Tab::Session,
94 }
95 }
96
97 /// Position in [`Tab::TITLES`], for the ratatui `Tabs` widget.
98 pub(crate) fn index(self) -> usize {
99 match self {
100 Tab::Session => 0,
101 Tab::History => 1,
102 Tab::Cloud => 2,
103 }
104 }
105 }
106
107 /// Coarse "how long ago" label for the History tab (no date dependency).
108 #[cfg_attr(not(unix), allow(dead_code))]
109 pub(crate) fn format_age(age: std::time::Duration) -> String {
110 let secs = age.as_secs();
111 if secs < 60 {
112 format!("{secs}s ago")
113 } else if secs < 3_600 {
114 format!("{}m ago", secs / 60)
115 } else if secs < 86_400 {
116 format!("{}h ago", secs / 3_600)
117 } else {
118 format!("{}d ago", secs / 86_400)
119 }
120 }
121
122 /// One History-tab row: id, age, message count. `age` is `None` when the
123 /// file's mtime could not be read (or lies in the future).
124 #[cfg_attr(not(unix), allow(dead_code))]
125 pub(crate) fn history_row(
126 id: &str,
127 age: Option<std::time::Duration>,
128 message_count: usize,
129 ) -> String {
130 let when = age
131 .map(format_age)
132 .unwrap_or_else(|| "age unknown".to_string());
133 format!("{id} · {when} · {message_count} message(s)")
134 }
135
136 // ── Unix-only TUI ─────────────────────────────────────────────────────────────
137 //
138 // macOS + Linux only. Windows uses ACP mode instead.
139
140 #[cfg(unix)]
141 mod tui {
142 use std::future::pending;
143 use std::sync::Arc;
144 use std::sync::mpsc as std_mpsc;
145
146 use anyhow::Result;
147 use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
148 use futures::StreamExt;
149 use onde::inference::{ChatEngine, SamplingConfig};
150
151 use super::Tab;
152 use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend, ToolResult, ToolSpec};
153 use crate::models::{
154 InferenceKind, ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items,
155 };
156 use crate::session_store::SessionEntry;
157 use ratatui::{
158 Frame,
159 layout::{Constraint, Layout, Position},
160 style::{Color, Modifier, Style},
161 text::{Line, Span},
162 widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap},
163 };
164 use tokio::sync::{mpsc, oneshot};
165 use tokio::time::{Duration, Instant, interval};
166
167 // ── Message types ─────────────────────────────────────────────────────────
168
169 #[derive(Clone, Copy, PartialEq, Eq)]
170 enum Role {
171 User,
172 Assistant,
173 System,
174 /// rainbow-colored banner art
175 Banner,
176 }
177
178 struct ChatMessage {
179 role: Role,
180 text: String,
181 /// Qwen 3 reasoning extracted from `<think>` tags, if any.
182 think_block: Option<String>,
183 }
184
185 impl ChatMessage {
186 fn user(text: impl Into<String>) -> Self {
187 Self {
188 role: Role::User,
189 text: text.into(),
190 think_block: None,
191 }
192 }
193
194 fn assistant(text: impl Into<String>) -> Self {
195 let raw = text.into();
196 let (think, visible) = super::strip_think_blocks(&raw);
197 Self {
198 role: Role::Assistant,
199 text: visible,
200 think_block: if think.is_empty() { None } else { Some(think) },
201 }
202 }
203
204 fn system(text: impl Into<String>) -> Self {
205 Self {
206 role: Role::System,
207 text: text.into(),
208 think_block: None,
209 }
210 }
211
212 fn banner(text: impl Into<String>) -> Self {
213 Self {
214 role: Role::Banner,
215 text: text.into(),
216 think_block: None,
217 }
218 }
219 }
220
221 // ── Inference updates from background task ────────────────────────────────
222
223 enum InferenceUpdate {
224 /// show tool name in chat while it runs
225 ToolUse(String),
226 /// a streamed token fragment of the assistant's reply
227 Delta(String),
228 /// the streamed reply is complete; commit the accumulated buffer
229 StreamEnd,
230 /// a complete (non-streamed) assistant reply
231 Response(String),
232 Error(String),
233 /// the inference task wants to run a mutating tool and is paused on
234 /// `reply`; the user answers with y (once) / a (session) / n (deny)
235 ApprovalRequest {
236 tool: String,
237 /// arguments preview so the user can see what they are approving
238 args: String,
239 reply: oneshot::Sender<ApprovalChoice>,
240 },
241 }
242
243 /// The user's answer to a tool-approval prompt. Dropping the reply channel
244 /// (quit, cancel) counts as a denial on the inference side.
245 enum ApprovalChoice {
246 /// run this one call
247 Once,
248 /// run it and stop asking for this tool for the rest of the session
249 Session,
250 /// skip the call; the model gets an explanatory tool result
251 Deny,
252 }
253
254 enum ModelLoadUpdate {
255 Loaded(String),
256 Error(String),
257 }
258
259 // ── App state ─────────────────────────────────────────────────────────────
260
261 struct App {
262 messages: Vec<ChatMessage>,
263 input: String,
264 cursor: usize,
265 /// true while assistant tokens are streaming into `stream_buf`
266 streaming: bool,
267 stream_buf: String,
268 inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
269 model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>,
270 /// a tool call waiting on the user's y/a/n answer; the inference task is
271 /// paused on the other end of the channel
272 pending_approval: Option<(String, oneshot::Sender<ApprovalChoice>)>,
273 thinking: bool,
274 thinking_tick: u8,
275 quit: bool,
276 /// toggled periodically so the streaming cursor blinks
277 blink_on: bool,
278 blink_counter: u8,
279 switching_model: bool,
280 /// stashed until ModelLoadUpdate::Loaded applies it to `app.tool_calling`
281 pending_tool_calling: Option<bool>,
282 /// suppresses the spurious "disconnected" error when we drop model_load_rx on cancel
283 model_load_cancelled: bool,
284
285 // ── Loading-phase state ───────────────────────────────────────────────
286 is_loading: bool,
287 load_tick: u32,
288 /// keeps the loading view visible so the user can read the error
289 load_error: Option<String>,
290 load_start: Instant,
291 load_model_name: String,
292
293 // ── Model picker state ────────────────────────────────────────────────
294 show_model_picker: bool,
295 model_picker_index: usize,
296 model_picker_items: Vec<ModelPickerItem>,
297 current_model_name: String,
298 tool_calling: bool,
299
300 // ── Model-switch download progress ────────────────────────────────────
301 switching_model_id: Option<String>,
302 /// (downloaded, expected) bytes — polled every tick during a model switch
303 download_progress: Option<(u64, u64)>,
304
305 // ── Active inference backend ──────────────────────────────────────────
306 /// The backend serving inference. Swapped in place when the user picks a
307 /// different model or cloud tier via `/models`.
308 backend: Arc<dyn InferenceBackend>,
309
310 // ── Tab bar state ─────────────────────────────────────────────────────
311 /// Which top-level tab is showing. Inference keeps running while the
312 /// user is on History/Cloud; updates land in `messages` regardless.
313 active_tab: Tab,
314
315 // History tab: saved sessions from the session store.
316 history_sessions: Vec<SessionEntry>,
317 history_index: usize,
318 /// Session id awaiting the confirming second `d`; any other key clears it.
319 history_pending_delete: Option<String>,
320 /// One-shot notice shown under the session list (e.g. a failed restore).
321 history_notice: Option<String>,
322
323 // Cloud tab: status text is fetched async when the tab opens and cached.
324 /// `None` while a fetch is in flight (renders as "fetching…").
325 cloud_lines: Option<Vec<String>>,
326 cloud_rx: Option<oneshot::Receiver<Vec<String>>>,
327 }
328
329 const BANNER_ART: &str = "\
330 77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777
331 77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777
332 1111111125555555555555555555555511113222311159 5002 088 3081771691111111111111
333 1111111111111111111111111111131136841 1482853332007 05 9043332891 400811111111111
334 1111111111111111111111111111111201 109 304 40 00 79 100041111111111
335 333333255555555555555555555552392 102 503 90 7000000005 903 0000023333333333
336 333333245454545454545454545433381 7600000 302 61 780 109 20009533333333333
337 3333333333333333333333333333333402 7001 08 761 202 902 90003333333333333
338 2222255555555555555555555555250899901 49 304 403 08 108 300042222222222222
339 2222222222222222222222222222269 106 03 901 06 505 402 000052222222222222
340 2222255555555555555555555555299 708 1002 80 00 90852222222222222
341 55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555
342 88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888";
343
344 const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
345
346 fn rich_text_spans(text: &str, base_style: Style, bold_style: Style) -> Vec<Span<'static>> {
347 let mut spans = Vec::new();
348
349 for (segment, is_bold) in super::parse_rich_text_segments(text) {
350 let style = if is_bold { bold_style } else { base_style };
351 spans.push(Span::styled(segment, style));
352 }
353
354 if spans.is_empty() {
355 spans.push(Span::styled(String::new(), base_style));
356 }
357
358 spans
359 }
360
361 impl App {
362 fn new(load_model_name: String, backend: Arc<dyn InferenceBackend>) -> Self {
363 let is_remote = backend.is_remote();
364 let items = build_model_picker_items();
365 let tool_calling = items
366 .iter()
367 .find(|m| m.display_name == load_model_name)
368 .map(|m| m.tool_calling)
369 .unwrap_or(true);
370 // For a remote provider the passed-in name is authoritative; the
371 // persisted local selection must not override it (or the title would
372 // show an on-device model while requests go to the cloud).
373 let current_model_name = if is_remote {
374 load_model_name.clone()
375 } else {
376 crate::setup::load_selected_model_name().unwrap_or_else(|| load_model_name.clone())
377 };
378 Self {
379 messages: Vec::new(),
380 input: String::new(),
381 cursor: 0,
382 streaming: false,
383 stream_buf: String::new(),
384 inference_rx: None,
385 model_load_rx: None,
386 pending_approval: None,
387 thinking: false,
388 thinking_tick: 0,
389 quit: false,
390 blink_on: true,
391 blink_counter: 0,
392 switching_model: false,
393 pending_tool_calling: None,
394 model_load_cancelled: false,
395 switching_model_id: None,
396 download_progress: None,
397 is_loading: true,
398 load_tick: 0,
399 load_error: None,
400 load_start: Instant::now(),
401 load_model_name: load_model_name.clone(),
402 show_model_picker: false,
403 model_picker_index: 0,
404 model_picker_items: items,
405 current_model_name,
406 tool_calling,
407 backend,
408 active_tab: Tab::Session,
409 history_sessions: Vec::new(),
410 history_index: 0,
411 history_pending_delete: None,
412 history_notice: None,
413 cloud_lines: None,
414 cloud_rx: None,
415 }
416 }
417
418 /// Reload the History tab's session list, keeping the selection in
419 /// bounds and dropping any pending delete confirmation.
420 fn refresh_history(&mut self) {
421 self.history_sessions = crate::session_store::list();
422 self.history_index = self
423 .history_index
424 .min(self.history_sessions.len().saturating_sub(1));
425 self.history_pending_delete = None;
426 }
427
428 /// The History entry the cursor is on, if any.
429 fn selected_session(&self) -> Option<&SessionEntry> {
430 self.history_sessions.get(self.history_index)
431 }
432
433 fn is_busy(&self) -> bool {
434 self.is_streaming() || self.thinking || self.switching_model
435 }
436
437 fn switching_frame(&self) -> &'static str {
438 let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
439 THINKING_FRAMES[idx]
440 }
441
442 fn is_streaming(&self) -> bool {
443 self.streaming
444 }
445
446 fn finalize_stream(&mut self) {
447 self.streaming = false;
448 if !self.stream_buf.is_empty() {
449 let text = std::mem::take(&mut self.stream_buf);
450 self.messages.push(ChatMessage::assistant(text));
451 }
452 self.blink_on = false;
453 }
454
455 fn push_stream_delta(&mut self, delta: &str) {
456 self.streaming = true;
457 self.stream_buf.push_str(delta);
458 // Hide reasoning the way the rest of the app does: keep the "thinking"
459 // spinner until visible (non-<think>) text appears, then show the
460 // live reply. Don't call stop_thinking() — that drops the channel.
461 let (_think, visible) = super::strip_think_blocks(&self.stream_buf);
462 self.thinking = visible.trim().is_empty();
463 self.blink_counter = self.blink_counter.wrapping_add(1);
464 self.blink_on = self.blink_counter % 4 < 2;
465 }
466
467 /// The portion of the streaming buffer to show live, with reasoning hidden.
468 fn visible_stream(&self) -> String {
469 let (_think, visible) = super::strip_think_blocks(&self.stream_buf);
470 visible
471 }
472
473 fn start_thinking(&mut self) {
474 self.thinking = true;
475 self.thinking_tick = 0;
476 }
477
478 fn stop_thinking(&mut self) {
479 self.thinking = false;
480 self.inference_rx = None;
481 // Dropping a pending reply channel reads as a denial on the
482 // inference side, so a cancelled turn can't leave a tool waiting.
483 self.pending_approval = None;
484 }
485
486 fn tick_thinking(&mut self) {
487 self.thinking_tick = self.thinking_tick.wrapping_add(1);
488 }
489
490 fn thinking_frame(&self) -> &'static str {
491 let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
492 THINKING_FRAMES[idx]
493 }
494
495 fn tick(&mut self) {
496 self.load_tick = self.load_tick.wrapping_add(1);
497 }
498
499 /// check how much of the model has landed on disk so far
500 fn poll_download_progress(&mut self) {
501 let Some(ref model_id) = self.switching_model_id else {
502 return;
503 };
504 let cache_path = onde::hf_cache::model_cache_path(model_id);
505 let downloaded = cache_path
506 .as_ref()
507 .filter(|p| p.exists())
508 .map(|p| dir_size_recursive(p))
509 .unwrap_or(0);
510 let expected = onde::inference::models::SUPPORTED_MODEL_INFO
511 .iter()
512 .find(|m| m.id == model_id.as_str())
513 .map(|m| m.expected_size_bytes)
514 .unwrap_or(0);
515 self.download_progress = Some((downloaded, expected));
516 }
517
518 /// switch to chat phase and show the welcome banner
519 fn finish_loading(&mut self) {
520 self.is_loading = false;
521 for line in BANNER_ART.lines() {
522 self.messages.push(ChatMessage::banner(line));
523 }
524 self.messages.push(ChatMessage::system(""));
525 self.messages.push(ChatMessage::system(
526 "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit",
527 ));
528 if self.backend.is_remote() {
529 self.messages.push(ChatMessage::system(format!(
530 "Current model: {}",
531 self.current_model_name
532 )));
533 } else {
534 // On-device models are never loaded implicitly; prompt the user to
535 // load one explicitly before their first message.
536 self.messages.push(ChatMessage::system(format!(
537 "No on-device model loaded. Run /load to load {}, or /models to choose one.",
538 self.current_model_name
539 )));
540 }
541 self.messages
542 .push(ChatMessage::system("Type /help for commands."));
543 }
544
545 /// store the error but stay in loading view so the user can read it
546 fn set_load_error(&mut self, error: String) {
547 self.load_error = Some(error);
548 // is_loading stays true so render_loading() keeps rendering.
549 }
550
551 fn open_model_picker(&mut self, engine: &ChatEngine) {
552 let current = crate::setup::load_selected_model();
553 let current_name = crate::setup::load_selected_model_name().unwrap_or_else(|| {
554 futures::executor::block_on(engine.info())
555 .model_name
556 .unwrap_or_else(|| self.current_model_name.clone())
557 });
558
559 self.model_picker_items = build_model_picker_items();
560 self.model_picker_index = current
561 .as_ref()
562 .and_then(|selected| {
563 self.model_picker_items.iter().position(|item| {
564 item.config.model_id == selected.model_id
565 && item
566 .config
567 .files
568 .iter()
569 .any(|file| file == &selected.gguf_file)
570 })
571 })
572 .or_else(|| {
573 self.model_picker_items
574 .iter()
575 .position(|item| item.display_name == current_name)
576 })
577 .unwrap_or(0);
578 self.show_model_picker = true;
579 }
580
581 fn close_model_picker(&mut self) {
582 self.show_model_picker = false;
583 }
584
585 fn move_model_picker_up(&mut self) {
586 if self.model_picker_items.is_empty() {
587 return;
588 }
589 if self.model_picker_index == 0 {
590 self.model_picker_index = self.model_picker_items.len().saturating_sub(1);
591 } else {
592 self.model_picker_index -= 1;
593 }
594 }
595
596 fn move_model_picker_down(&mut self) {
597 if self.model_picker_items.is_empty() {
598 return;
599 }
600 self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len();
601 }
602 }
603
604 // ── Model picker ─────────────────────────────────────────────────────────
605 //
606 // picker data types live in crate::models so Windows (ACP-only) can use them too
607
608 fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
609 let popup = centered_rect(82, 72, area);
610
611 // clear the background so text doesn't bleed through
612 frame.render_widget(Clear, popup);
613
614 let block = Block::default()
615 .title(" Select a model… ")
616 .borders(Borders::ALL)
617 .border_style(Style::default().fg(Color::DarkGray))
618 .style(Style::default().bg(Color::Black));
619
620 let inner = block.inner(popup);
621 frame.render_widget(block, popup);
622
623 let active_kind = crate::models::active_inference_kind();
624 let mut lines = Vec::new();
625
626 // State banner: which mode is active, and how to flip it.
627 let (state_word, state_style) = match active_kind {
628 InferenceKind::Local => (
629 "ON (on-device)",
630 Style::default().fg(Color::Green).bg(Color::Black),
631 ),
632 InferenceKind::Cloud => (
633 "OFF (siGit Code Cloud)",
634 Style::default().fg(Color::Magenta).bg(Color::Black),
635 ),
636 };
637 lines.push(Line::from(vec![
638 Span::styled(
639 "Local inference: ",
640 Style::default()
641 .fg(Color::White)
642 .bg(Color::Black)
643 .add_modifier(Modifier::BOLD),
644 ),
645 Span::styled(state_word, state_style.add_modifier(Modifier::BOLD)),
646 Span::styled(
647 " toggle with /local on|off",
648 Style::default().fg(Color::DarkGray).bg(Color::Black),
649 ),
650 ]));
651 lines.push(Line::from("").style(Style::default().bg(Color::Black)));
652
653 let mut last_section: Option<ModelSource> = None;
654 let mut last_kind: Option<InferenceKind> = None;
655
656 for (index, item) in app.model_picker_items.iter().enumerate() {
657 let item_kind = item.source.kind();
658 let item_active = item_kind == active_kind;
659
660 // Top-level group header (Local / Cloud) whenever the nature changes.
661 if last_kind != Some(item_kind) {
662 if last_kind.is_some() {
663 lines.push(Line::from("").style(Style::default().bg(Color::Black)));
664 }
665 let group_label = match item_kind {
666 InferenceKind::Local => "LOCAL — on-device inference",
667 InferenceKind::Cloud => "CLOUD — siGit Code Cloud",
668 };
669 let group_style = if item_active {
670 Style::default()
671 .fg(Color::White)
672 .bg(Color::Black)
673 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
674 } else {
675 Style::default().fg(Color::DarkGray).bg(Color::Black)
676 };
677 lines.push(
678 Line::from(vec![Span::styled(group_label, group_style)])
679 .style(Style::default().bg(Color::Black)),
680 );
681 last_kind = Some(item_kind);
682 last_section = None;
683 }
684
685 if last_section != Some(item.source) {
686 if last_section.is_some() {
687 lines.push(Line::from("").style(Style::default().bg(Color::Black)));
688 }
689
690 let (section_mark, section_name, section_style) = match item.source {
691 ModelSource::Onde => (
692 "◉",
693 "Onde Inference",
694 Style::default()
695 .fg(Color::Green)
696 .bg(Color::Black)
697 .add_modifier(Modifier::BOLD),
698 ),
699 ModelSource::HuggingFace => (
700 "○",
701 "Hugging Face cache",
702 Style::default()
703 .fg(Color::Cyan)
704 .bg(Color::Black)
705 .add_modifier(Modifier::BOLD),
706 ),
707 ModelSource::Available => (
708 "↓",
709 "Available for download",
710 Style::default()
711 .fg(Color::Blue)
712 .bg(Color::Black)
713 .add_modifier(Modifier::BOLD),
714 ),
715 ModelSource::Fallback => (
716 "◎",
717 "Fallback",
718 Style::default()
719 .fg(Color::Yellow)
720 .bg(Color::Black)
721 .add_modifier(Modifier::BOLD),
722 ),
723 ModelSource::Cloud => (
724 "☁",
725 "siGit Code Cloud",
726 Style::default()
727 .fg(Color::Magenta)
728 .bg(Color::Black)
729 .add_modifier(Modifier::BOLD),
730 ),
731 };
732
733 // Dim the section header when it belongs to the inactive group.
734 let section_style = if item_active {
735 section_style
736 } else {
737 Style::default().fg(Color::DarkGray).bg(Color::Black)
738 };
739
740 lines.push(
741 Line::from(vec![
742 Span::styled(format!(" {section_mark} "), section_style),
743 Span::styled(section_name, section_style),
744 ])
745 .style(Style::default().bg(Color::Black)),
746 );
747 last_section = Some(item.source);
748 }
749
750 let selected = index == app.model_picker_index;
751 let current = item.display_name == app.current_model_name;
752 let marker = if selected { "› " } else { " " };
753 let tool_badge = if item.tool_calling {
754 " ✓ tool calling"
755 } else {
756 ""
757 };
758 let health_badge = match item.cache_health {
759 ModelCacheHealth::Complete => "",
760 ModelCacheHealth::Incomplete => " ! incomplete cache",
761 ModelCacheHealth::NotDownloaded => " ↓ download",
762 };
763 let current_badge = if current { " ← current" } else { "" };
764 let disabled_badge = match item.cache_health {
765 ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "",
766 ModelCacheHealth::Incomplete => " (unselectable)",
767 };
768 let brand_mark = match item.source {
769 ModelSource::Onde => "◉",
770 ModelSource::HuggingFace => "○",
771 ModelSource::Available => "↓",
772 ModelSource::Fallback => "◎",
773 ModelSource::Cloud => "☁",
774 };
775 let source = format!(" [{} {}]", brand_mark, item.source_label);
776
777 let base_style = if selected {
778 Style::default().fg(Color::Black).bg(Color::Green)
779 } else if item_active {
780 Style::default().fg(Color::White).bg(Color::Black)
781 } else {
782 // Inactive group: still visible (we surface the offering) but dimmed.
783 Style::default().fg(Color::DarkGray).bg(Color::Black)
784 };
785
786 let source_style = if selected {
787 Style::default().fg(Color::Black).bg(Color::Green)
788 } else if !item_active {
789 Style::default().fg(Color::DarkGray).bg(Color::Black)
790 } else {
791 match item.source {
792 ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black),
793 ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black),
794 ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black),
795 ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black),
796 ModelSource::Cloud => Style::default().fg(Color::Magenta).bg(Color::Black),
797 }
798 };
799
800 let health_style = if selected {
801 Style::default().fg(Color::Red).bg(Color::Green)
802 } else {
803 Style::default().fg(Color::Red).bg(Color::Black)
804 };
805
806 lines.push(Line::from(vec![
807 Span::styled(
808 format!("{marker}{} {}", item.display_name, item.description),
809 base_style,
810 ),
811 Span::styled(
812 tool_badge.to_string(),
813 if selected {
814 Style::default().fg(Color::Black).bg(Color::Green)
815 } else {
816 Style::default().fg(Color::Green).bg(Color::Black)
817 },
818 ),
819 Span::styled(health_badge.to_string(), health_style),
820 Span::styled(
821 disabled_badge.to_string(),
822 if selected {
823 Style::default().fg(Color::Black).bg(Color::Green)
824 } else {
825 Style::default().fg(Color::DarkGray).bg(Color::Black)
826 },
827 ),
828 Span::styled(
829 current_badge.to_string(),
830 if selected {
831 Style::default().fg(Color::Black).bg(Color::Green)
832 } else {
833 Style::default().fg(Color::Cyan).bg(Color::Black)
834 },
835 ),
836 Span::styled(source, source_style),
837 ]));
838 }
839
840 frame.render_widget(
841 Paragraph::new(lines)
842 .wrap(Wrap { trim: false })
843 .style(Style::default().bg(Color::Black)),
844 inner,
845 );
846 }
847
848 fn centered_rect(
849 percent_x: u16,
850 percent_y: u16,
851 area: ratatui::layout::Rect,
852 ) -> ratatui::layout::Rect {
853 let vertical = Layout::vertical([
854 Constraint::Percentage((100 - percent_y) / 2),
855 Constraint::Percentage(percent_y),
856 Constraint::Percentage((100 - percent_y) / 2),
857 ])
858 .split(area);
859
860 Layout::horizontal([
861 Constraint::Percentage((100 - percent_x) / 2),
862 Constraint::Percentage(percent_x),
863 Constraint::Percentage((100 - percent_x) / 2),
864 ])
865 .split(vertical[1])[1]
866 }
867
868 // ── Tab bar (Session / History / Cloud) ───────────────────────────────────
869
870 /// Switch to `tab`, refreshing the data it shows. Entering History rescans
871 /// the sessions dir; entering Cloud kicks off the async status fetch.
872 fn switch_tab(app: &mut App, tab: Tab, engine: &Arc<ChatEngine>) {
873 app.active_tab = tab;
874 match tab {
875 Tab::Session => {}
876 Tab::History => {
877 app.refresh_history();
878 app.history_notice = None;
879 }
880 Tab::Cloud => refresh_cloud(app, engine),
881 }
882 }
883
884 /// Fetch the Cloud tab's status text on a background task and cache it.
885 /// Account status and engine info are async (the account check may hit the
886 /// network), so the tab shows "fetching…" until the oneshot resolves in
887 /// the event loop.
888 fn refresh_cloud(app: &mut App, engine: &Arc<ChatEngine>) {
889 let (tx, rx) = oneshot::channel();
890 app.cloud_rx = Some(rx);
891 app.cloud_lines = None;
892
893 let engine = Arc::clone(engine);
894 let is_remote = app.backend.is_remote();
895 let model_name = app.current_model_name.clone();
896 tokio::spawn(async move {
897 // `status_line` already folds failures into its message, so a dead
898 // network degrades to an error string rather than a stuck tab.
899 let account = crate::account::status_line().await;
900 let info = engine.info().await;
901
902 let mut lines = Vec::new();
903 lines.push(format!("Account: {account}"));
904 lines.push(format!(
905 "Inference: {}",
906 if is_remote {
907 "remote (siGit Code Cloud / hosted endpoint)"
908 } else {
909 "on-device"
910 }
911 ));
912 lines.push(format!("Model: {model_name}"));
913 lines.push(format!(
914 "Engine: status: {:?} model: {} memory: {} history: {} turns",
915 info.status,
916 info.model_name.as_deref().unwrap_or("(none)"),
917 info.approx_memory.as_deref().unwrap_or("unknown"),
918 info.history_length,
919 ));
920 lines.push(format!(
921 "Local inference: {}",
922 if crate::settings::local_inference_enabled() {
923 "on"
924 } else {
925 "off"
926 }
927 ));
928 let config_dir = std::env::var("SIGIT_CONFIG_DIR").unwrap_or_else(|_| {
929 let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
930 format!("{home}/.config/sigit")
931 });
932 lines.push(format!("Config dir: {config_dir}"));
933 lines.push(String::new());
934 for perm_line in crate::permissions::describe(crate::permissions::TUI_SESSION).lines() {
935 lines.push(perm_line.to_string());
936 }
937
938 let _ = tx.send(lines);
939 });
940 }
941
942 /// Keys on the History and Cloud tabs (the Session tab keeps `handle_key`).
943 /// Tab/Esc navigation is handled earlier in the event loop; this gets the
944 /// rest.
945 async fn handle_tab_key(app: &mut App, key: KeyEvent, engine: &Arc<ChatEngine>) {
946 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
947 if ctrl && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')) {
948 app.quit = true;
949 return;
950 }
951
952 match app.active_tab {
953 Tab::Session => {}
954 Tab::History => match key.code {
955 KeyCode::Up => {
956 app.history_pending_delete = None;
957 app.history_index = app.history_index.saturating_sub(1);
958 }
959 KeyCode::Down => {
960 app.history_pending_delete = None;
961 if app.history_index + 1 < app.history_sessions.len() {
962 app.history_index += 1;
963 }
964 }
965 KeyCode::Char('r') => {
966 app.refresh_history();
967 app.history_notice = None;
968 }
969 KeyCode::Char('d') => {
970 let Some(id) = app.selected_session().map(|e| e.id.clone()) else {
971 return;
972 };
973 if app.history_pending_delete.as_deref() == Some(id.as_str()) {
974 crate::session_store::delete(&id);
975 app.refresh_history();
976 app.history_notice = Some(format!("Deleted session '{id}'."));
977 } else {
978 app.history_pending_delete = Some(id);
979 }
980 }
981 KeyCode::Enter => {
982 app.history_pending_delete = None;
983 let Some(id) = app.selected_session().map(|e| e.id.clone()) else {
984 return;
985 };
986 match crate::session_store::load(&id) {
987 Some(history) if !history.is_empty() => {
988 let restored = history.len();
989 app.backend.restore_history(history).await;
990 app.messages.push(ChatMessage::system(format!(
991 "Restored {restored} message(s) from session '{id}'. \
992 The model remembers the conversation; the scrollback \
993 above does not replay it."
994 )));
995 app.active_tab = Tab::Session;
996 }
997 _ => {
998 app.history_notice = Some(format!(
999 "Could not restore '{id}': the session is empty or unreadable."
1000 ));
1001 }
1002 }
1003 }
1004 // Any other key cancels a pending delete confirmation.
1005 _ => app.history_pending_delete = None,
1006 },
1007 Tab::Cloud => match key.code {
1008 KeyCode::Char('l') => {
1009 let enabled = !crate::settings::local_inference_enabled();
1010 match crate::settings::set_local_inference(enabled) {
1011 Ok(()) => refresh_cloud(app, engine),
1012 Err(error) => {
1013 app.cloud_lines
1014 .get_or_insert_with(Vec::new)
1015 .push(format!("error: could not save the setting: {error}"));
1016 }
1017 }
1018 }
1019 KeyCode::Char('r') => refresh_cloud(app, engine),
1020 _ => {}
1021 },
1022 }
1023 }
1024
1025 fn render_tab_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1026 let tabs = Tabs::new(Tab::TITLES.map(Line::from).to_vec())
1027 .select(app.active_tab.index())
1028 .style(Style::default().fg(Color::DarkGray))
1029 .highlight_style(
1030 Style::default()
1031 .fg(Color::Black)
1032 .bg(Color::Green)
1033 .add_modifier(Modifier::BOLD),
1034 );
1035 frame.render_widget(tabs, area);
1036 }
1037
1038 fn render_history_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1039 let block = Block::default()
1040 .borders(Borders::ALL)
1041 .border_style(Style::default().fg(Color::DarkGray))
1042 .title(" saved sessions ");
1043 let inner = block.inner(area);
1044 frame.render_widget(block, area);
1045
1046 let mut lines: Vec<Line> = Vec::new();
1047
1048 if app.history_sessions.is_empty() {
1049 lines.push(Line::from(Span::styled(
1050 " No saved sessions yet. Sessions are saved after each turn.",
1051 Style::default().fg(Color::DarkGray),
1052 )));
1053 } else {
1054 let now = std::time::SystemTime::now();
1055 for (index, entry) in app.history_sessions.iter().enumerate() {
1056 let selected = index == app.history_index;
1057 let marker = if selected { "› " } else { " " };
1058 let age = now.duration_since(entry.modified).ok();
1059 let row = super::history_row(&entry.id, age, entry.message_count);
1060 let style = if selected {
1061 Style::default().fg(Color::Black).bg(Color::Green)
1062 } else {
1063 Style::default().fg(Color::White)
1064 };
1065 lines.push(Line::from(Span::styled(format!("{marker}{row}"), style)));
1066 }
1067 }
1068
1069 if let Some(ref id) = app.history_pending_delete {
1070 lines.push(Line::from(""));
1071 lines.push(Line::from(Span::styled(
1072 format!(" Delete '{id}'? Press d again to confirm — any other key cancels."),
1073 Style::default().fg(Color::Yellow),
1074 )));
1075 } else if let Some(ref notice) = app.history_notice {
1076 lines.push(Line::from(""));
1077 lines.push(Line::from(Span::styled(
1078 format!(" {notice}"),
1079 Style::default().fg(Color::Yellow),
1080 )));
1081 }
1082
1083 // Keep the selection visible when the list outgrows the pane.
1084 let inner_height = inner.height as usize;
1085 let scroll = app
1086 .history_index
1087 .saturating_sub(inner_height.saturating_sub(1)) as u16;
1088 frame.render_widget(
1089 Paragraph::new(lines)
1090 .wrap(Wrap { trim: false })
1091 .scroll((scroll, 0)),
1092 inner,
1093 );
1094 }
1095
1096 fn render_cloud_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1097 let block = Block::default()
1098 .borders(Borders::ALL)
1099 .border_style(Style::default().fg(Color::DarkGray))
1100 .title(" siGit Code Cloud ");
1101 let inner = block.inner(area);
1102 frame.render_widget(block, area);
1103
1104 let mut lines: Vec<Line> = Vec::new();
1105 match app.cloud_lines {
1106 None => lines.push(Line::from(Span::styled(
1107 " fetching status…",
1108 Style::default().fg(Color::DarkGray),
1109 ))),
1110 Some(ref cloud_lines) => {
1111 for text in cloud_lines {
1112 lines.push(Line::from(Span::styled(
1113 format!(" {text}"),
1114 Style::default().fg(Color::White),
1115 )));
1116 }
1117 }
1118 }
1119 lines.push(Line::from(""));
1120 lines.push(Line::from(Span::styled(
1121 " Note: toggling Local Inference takes effect for the next model \
1122 selection (/models); the running backend is not swapped.",
1123 Style::default().fg(Color::DarkGray),
1124 )));
1125
1126 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
1127 }
1128
1129 // ── Slash commands ────────────────────────────────────────────────────────
1130
1131 enum SlashCommand {
1132 Help,
1133 Clear,
1134 Status,
1135 /// picker UI, or jump straight to model N
1136 Models(Option<usize>),
1137 /// toggle on-device inference mode. `Some(true/false)` sets it, `None` flips it.
1138 Local(Option<bool>),
1139 /// List discovered Agent Skills.
1140 Skills,
1141 /// List configured MCP servers and their tools.
1142 Mcp,
1143 /// explicitly load the selected (or default) on-device model
1144 Load,
1145 /// `/login <email> <password>` — the raw argument, parsed when executed.
1146 Login(Option<String>),
1147 Logout,
1148 Whoami,
1149 /// Toggle plan mode (research only; mutating tools are denied with a
1150 /// prompt to present a plan). `Some(true/false)` sets it, `None` flips it.
1151 Plan(Option<bool>),
1152 /// Show the effective permission policy for this session.
1153 Permissions,
1154 /// Summarize-and-shrink the conversation history on demand.
1155 Compact,
1156 /// Restore the saved TUI session from disk.
1157 Resume,
1158 Exit,
1159 Unknown(String),
1160 }
1161
1162 fn parse_slash(input: &str) -> Option<SlashCommand> {
1163 let trimmed = input.trim();
1164 if !trimmed.starts_with('/') {
1165 return None;
1166 }
1167 let mut parts = trimmed.splitn(2, char::is_whitespace);
1168 let cmd = parts.next().unwrap_or("");
1169 let arg = parts.next().map(|s| s.trim());
1170 Some(match cmd {
1171 "/help" => SlashCommand::Help,
1172 "/clear" => SlashCommand::Clear,
1173 "/status" => SlashCommand::Status,
1174 "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),
1175 "/local" => SlashCommand::Local(parse_on_off(arg)),
1176 "/skills" => SlashCommand::Skills,
1177 "/mcp" => SlashCommand::Mcp,
1178 "/load" => SlashCommand::Load,
1179 "/login" => SlashCommand::Login(arg.map(str::to_string)),
1180 "/logout" => SlashCommand::Logout,
1181 "/whoami" => SlashCommand::Whoami,
1182 "/plan" => SlashCommand::Plan(parse_on_off(arg)),
1183 "/permissions" => SlashCommand::Permissions,
1184 "/compact" => SlashCommand::Compact,
1185 "/resume" => SlashCommand::Resume,
1186 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
1187 other => SlashCommand::Unknown(other.to_string()),
1188 })
1189 }
1190
1191 /// `on`/`off` (and synonyms) → `Some(bool)`; missing or unrecognized → `None`
1192 /// (meaning "toggle the current value").
1193 fn parse_on_off(arg: Option<&str>) -> Option<bool> {
1194 match arg.map(|s| s.trim().to_ascii_lowercase())?.as_str() {
1195 "on" | "true" | "1" | "yes" => Some(true),
1196 "off" | "false" | "0" | "no" => Some(false),
1197 _ => None,
1198 }
1199 }
1200
1201 // ── Rendering ─────────────────────────────────────────────────────────────
1202
1203 fn render(frame: &mut Frame, app: &mut App) {
1204 let area = frame.area();
1205
1206 if app.is_loading {
1207 let zones = Layout::vertical([
1208 Constraint::Length(1),
1209 Constraint::Min(1),
1210 Constraint::Length(1),
1211 ])
1212 .split(area);
1213 render_loading_title(frame, app, zones[0]);
1214 render_loading(frame, app, zones[1]);
1215 render_loading_footer(frame, zones[2]);
1216 return;
1217 }
1218
1219 match app.active_tab {
1220 Tab::Session => {
1221 let zones = Layout::vertical([
1222 Constraint::Length(1),
1223 Constraint::Length(1),
1224 Constraint::Min(1),
1225 Constraint::Length(3),
1226 Constraint::Length(1),
1227 ])
1228 .split(area);
1229
1230 render_tab_bar(frame, app, zones[0]);
1231 render_title(frame, app, zones[1]);
1232 render_messages(frame, app, zones[2]);
1233 render_input(frame, app, zones[3]);
1234 render_footer(frame, app, zones[4]);
1235 }
1236 // No input pane on the non-chat tabs: the Tab key always cycles.
1237 Tab::History | Tab::Cloud => {
1238 let zones = Layout::vertical([
1239 Constraint::Length(1),
1240 Constraint::Length(1),
1241 Constraint::Min(1),
1242 Constraint::Length(1),
1243 ])
1244 .split(area);
1245
1246 render_tab_bar(frame, app, zones[0]);
1247 render_title(frame, app, zones[1]);
1248 if app.active_tab == Tab::History {
1249 render_history_tab(frame, app, zones[2]);
1250 } else {
1251 render_cloud_tab(frame, app, zones[2]);
1252 }
1253 render_footer(frame, app, zones[3]);
1254 }
1255 }
1256
1257 if app.show_model_picker {
1258 render_model_picker(frame, app, area);
1259 }
1260 }
1261
1262 fn render_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1263 let model_label = format!(" siGit — {} ", app.current_model_name);
1264 let tool_label = if app.tool_calling {
1265 " [tools on] "
1266 } else {
1267 " [tools off] "
1268 };
1269 let line = Line::from(vec![
1270 Span::styled(
1271 model_label,
1272 Style::default()
1273 .fg(Color::Black)
1274 .bg(Color::Green)
1275 .add_modifier(Modifier::BOLD),
1276 ),
1277 Span::styled(
1278 tool_label,
1279 Style::default().fg(Color::Black).bg(Color::DarkGray),
1280 ),
1281 ]);
1282 frame.render_widget(Paragraph::new(line), area);
1283 }
1284
1285 fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1286 const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
1287 let spin = SPINNER[(app.load_tick as usize) % SPINNER.len()];
1288 let label = format!(" siGit {} loading {}… ", spin, app.load_model_name);
1289 let line = Line::from(Span::styled(
1290 label,
1291 Style::default()
1292 .fg(Color::Black)
1293 .bg(Color::Green)
1294 .add_modifier(Modifier::BOLD),
1295 ));
1296 frame.render_widget(Paragraph::new(line), area);
1297 }
1298
1299 fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1300 let elapsed = app.load_start.elapsed().as_secs();
1301 let elapsed_str = if elapsed < 60 {
1302 format!("{}s", elapsed)
1303 } else {
1304 format!("{}m {}s", elapsed / 60, elapsed % 60)
1305 };
1306
1307 let content = if let Some(ref err) = app.load_error {
1308 format!(
1309 "\n\n ✗ Failed to load model after {}.\n\n {}\n\n Press Ctrl+C to exit.",
1310 elapsed_str, err
1311 )
1312 } else {
1313 format!(
1314 "\n\n Loading model, please wait… ({})\n\n The model is being initialised. This may take a moment on first run.",
1315 elapsed_str
1316 )
1317 };
1318
1319 let style = if app.load_error.is_some() {
1320 Style::default().fg(Color::Red)
1321 } else {
1322 Style::default().fg(Color::White)
1323 };
1324
1325 frame.render_widget(
1326 Paragraph::new(content)
1327 .style(style)
1328 .wrap(Wrap { trim: false }),
1329 area,
1330 );
1331 }
1332
1333 fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) {
1334 let line = Line::from(vec![
1335 Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
1336 Span::styled(" quit", Style::default().fg(Color::DarkGray)),
1337 ]);
1338 frame.render_widget(Paragraph::new(line), area);
1339 }
1340
1341 fn render_messages(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1342 let inner_width = area.width.saturating_sub(2);
1343 let inner_height = area.height.saturating_sub(2);
1344
1345 let block = Block::default()
1346 .borders(Borders::ALL)
1347 .border_style(Style::default().fg(Color::DarkGray));
1348
1349 let inner = block.inner(area);
1350 frame.render_widget(block, area);
1351
1352 let mut lines: Vec<Line> = Vec::new();
1353
1354 for msg in &app.messages {
1355 render_chat_message(&mut lines, msg, inner_width as usize);
1356 }
1357
1358 let streamed_visible = app.visible_stream();
1359 if !streamed_visible.is_empty() {
1360 let fake = ChatMessage {
1361 role: Role::Assistant,
1362 text: streamed_visible,
1363 think_block: None,
1364 };
1365 render_chat_message(&mut lines, &fake, inner_width as usize);
1366 if app.blink_on
1367 && let Some(last) = lines.last_mut()
1368 {
1369 last.spans
1370 .push(Span::styled("▋", Style::default().fg(Color::Green)));
1371 }
1372 }
1373
1374 if app.thinking {
1375 lines.push(Line::from(Span::styled(
1376 format!(" {} thinking…", app.thinking_frame()),
1377 Style::default().fg(Color::DarkGray),
1378 )));
1379 } else if app.switching_model {
1380 // Once the weights have fully landed on disk, swap the spinner for a
1381 // checkmark so it's clear the download finished and we're now loading
1382 // the model into memory (which can still take a while).
1383 let download_complete = matches!(
1384 app.download_progress,
1385 Some((downloaded, expected)) if expected > 0 && downloaded >= expected
1386 );
1387
1388 if download_complete {
1389 let size_str = app
1390 .download_progress
1391 .map(|(_, expected)| format!(" ({})", format_size_human(expected)))
1392 .unwrap_or_default();
1393 lines.push(Line::from(vec![
1394 Span::styled(" ✓ ", Style::default().fg(Color::Green)),
1395 Span::styled(
1396 format!("model downloaded{size_str} — loading into memory…"),
1397 Style::default().fg(Color::DarkGray),
1398 ),
1399 ]));
1400 } else {
1401 let progress_str = if let Some((downloaded, expected)) = app.download_progress {
1402 if expected > 0 {
1403 let pct = (downloaded as f64 / expected as f64 * 100.0).min(100.0) as u8;
1404 let dl_str = format_size_human(downloaded.min(expected));
1405 let ex_str = format_size_human(expected);
1406 format!(" — {dl_str} / {ex_str} ({pct}%)")
1407 } else if downloaded > 0 {
1408 format!(" — {} downloaded", format_size_human(downloaded))
1409 } else {
1410 String::new()
1411 }
1412 } else {
1413 String::new()
1414 };
1415 lines.push(Line::from(Span::styled(
1416 format!(" {} switching model{progress_str}…", app.switching_frame()),
1417 Style::default().fg(Color::DarkGray),
1418 )));
1419 }
1420 }
1421
1422 // Always pin to the bottom so the latest message stays visible. There is
1423 // no scrollback, so we just need the exact number of wrapped rows the
1424 // paragraph occupies at this width — `line_count` runs the same
1425 // WordWrapper as rendering, so it never diverges from what's drawn (an
1426 // estimate would, e.g. by forgetting the `<think>` box lines, and scroll
1427 // too little — the bug this fixes).
1428 let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
1429 let total_lines = paragraph.line_count(inner_width) as u16;
1430 let scroll = total_lines.saturating_sub(inner_height);
1431
1432 frame.render_widget(paragraph.scroll((scroll, 0)), inner);
1433 }
1434
1435 fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) {
1436 match msg.role {
1437 Role::Banner => {
1438 let palette = [
1439 Color::Red,
1440 Color::Yellow,
1441 Color::Green,
1442 Color::Cyan,
1443 Color::Blue,
1444 Color::Magenta,
1445 ];
1446 let mut spans = Vec::new();
1447 for (i, ch) in msg.text.chars().enumerate() {
1448 let color = palette[i % palette.len()];
1449 spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
1450 }
1451 lines.push(Line::from(spans));
1452 }
1453 Role::System => {
1454 for text_line in msg.text.split('\n') {
1455 let trimmed = text_line.trim();
1456 let (prefix, body) = if trimmed.is_empty() {
1457 ("", "")
1458 } else {
1459 (" · ", trimmed)
1460 };
1461
1462 lines.push(Line::from(vec![
1463 Span::styled(
1464 prefix.to_string(),
1465 Style::default()
1466 .fg(Color::Rgb(90, 90, 98))
1467 .add_modifier(Modifier::DIM),
1468 ),
1469 Span::styled(
1470 body.to_string(),
1471 Style::default()
1472 .fg(Color::Rgb(132, 132, 145))
1473 .add_modifier(Modifier::ITALIC | Modifier::DIM),
1474 ),
1475 ]));
1476 }
1477 }
1478 Role::User => {
1479 let prefix = Span::styled(
1480 "you > ".to_string(),
1481 Style::default()
1482 .fg(Color::Green)
1483 .add_modifier(Modifier::BOLD),
1484 );
1485 let mut first = true;
1486 for text_line in msg.text.split('\n') {
1487 if first {
1488 lines.push(Line::from(vec![
1489 prefix.clone(),
1490 Span::raw(text_line.to_string()),
1491 ]));
1492 first = false;
1493 } else {
1494 lines.push(Line::from(Span::raw(format!(" {text_line}"))));
1495 }
1496 }
1497 }
1498 Role::Assistant => {
1499 if let Some(ref think) = msg.think_block {
1500 lines.push(Line::from(Span::styled(
1501 " ┌ thinking ".to_string(),
1502 Style::default().fg(Color::DarkGray),
1503 )));
1504 for think_line in think.split('\n') {
1505 lines.push(Line::from(Span::styled(
1506 format!(" │ {think_line}"),
1507 Style::default().fg(Color::DarkGray),
1508 )));
1509 }
1510 lines.push(Line::from(Span::styled(
1511 " └─────────".to_string(),
1512 Style::default().fg(Color::DarkGray),
1513 )));
1514 }
1515
1516 let prefix = Span::styled(
1517 "siGit > ".to_string(),
1518 Style::default()
1519 .fg(Color::Cyan)
1520 .add_modifier(Modifier::BOLD),
1521 );
1522 let body_style = Style::default();
1523 let bold_style = Style::default().add_modifier(Modifier::BOLD);
1524 let mut first = true;
1525 for text_line in msg.text.split('\n') {
1526 if first {
1527 let mut spans = vec![prefix.clone()];
1528 spans.extend(rich_text_spans(text_line, body_style, bold_style));
1529 lines.push(Line::from(spans));
1530 first = false;
1531 } else {
1532 let mut spans = vec![Span::raw(" ".to_string())];
1533 spans.extend(rich_text_spans(text_line, body_style, bold_style));
1534 lines.push(Line::from(spans));
1535 }
1536 }
1537 }
1538 }
1539 }
1540
1541 fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1542 let block = Block::default()
1543 .borders(Borders::ALL)
1544 .border_style(Style::default().fg(Color::DarkGray))
1545 .title(" message ");
1546
1547 let inner = block.inner(area);
1548 frame.render_widget(block, area);
1549
1550 let display = app.input.clone();
1551 frame.render_widget(
1552 Paragraph::new(display.clone()).wrap(Wrap { trim: false }),
1553 inner,
1554 );
1555
1556 let col = (app.cursor as u16) % inner.width;
1557 let row = (app.cursor as u16) / inner.width;
1558 frame.set_cursor_position(Position {
1559 x: inner.x + col,
1560 y: inner.y + row,
1561 });
1562 }
1563
1564 fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1565 let key_style = Style::default().fg(Color::Black).bg(Color::Green);
1566 let label_style = Style::default().fg(Color::DarkGray);
1567
1568 if app.active_tab != Tab::Session {
1569 let hints: &[(&str, &str)] = match app.active_tab {
1570 Tab::History => &[
1571 (" ↑/↓ ", " select "),
1572 (" Enter ", " resume "),
1573 (" d ", " delete "),
1574 (" r ", " refresh "),
1575 (" Tab ", " next tab "),
1576 (" Esc ", " session"),
1577 ],
1578 _ => &[
1579 (" l ", " toggle local inference "),
1580 (" r ", " refresh "),
1581 (" Tab ", " next tab "),
1582 (" Esc ", " session"),
1583 ],
1584 };
1585 let mut spans = Vec::new();
1586 for (key, label) in hints {
1587 spans.push(Span::styled(key.to_string(), key_style));
1588 spans.push(Span::styled(label.to_string(), label_style));
1589 }
1590 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1591 return;
1592 }
1593
1594 let mut spans = vec![
1595 Span::styled(" Enter ", key_style),
1596 Span::styled(" send ", label_style),
1597 Span::styled(" Tab ", key_style),
1598 Span::styled(" tabs ", label_style),
1599 Span::styled(
1600 " /help ",
1601 Style::default().fg(Color::Black).bg(Color::DarkGray),
1602 ),
1603 Span::styled(" commands ", Style::default().fg(Color::DarkGray)),
1604 Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
1605 Span::styled(" quit", Style::default().fg(Color::DarkGray)),
1606 ];
1607
1608 if let Some((tool, _)) = &app.pending_approval {
1609 spans.push(Span::styled(
1610 format!(" allow {tool}? [y]es · [a]lways · [n]o"),
1611 Style::default().fg(Color::Yellow),
1612 ));
1613 } else if app.thinking || app.switching_model || app.is_streaming() {
1614 spans.push(Span::styled(
1615 " (busy — Ctrl+C to cancel)",
1616 Style::default().fg(Color::Yellow),
1617 ));
1618 }
1619
1620 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1621 }
1622
1623 fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
1624 if key.kind != KeyEventKind::Press {
1625 return None;
1626 }
1627
1628 if app.show_model_picker {
1629 match key.code {
1630 KeyCode::Esc => {
1631 app.close_model_picker();
1632 return None;
1633 }
1634 KeyCode::Up => {
1635 app.move_model_picker_up();
1636 return None;
1637 }
1638 KeyCode::Down => {
1639 app.move_model_picker_down();
1640 return None;
1641 }
1642 KeyCode::Enter => {
1643 return Some(format!("/models {}", app.model_picker_index + 1));
1644 }
1645 _ => return None,
1646 }
1647 }
1648
1649 match key.code {
1650 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1651 app.quit = true;
1652 None
1653 }
1654 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1655 app.quit = true;
1656 None
1657 }
1658 KeyCode::Enter => {
1659 if app.input.trim().is_empty() {
1660 return None;
1661 }
1662 let text = app.input.drain(..).collect::<String>();
1663 app.cursor = 0;
1664 Some(text)
1665 }
1666 KeyCode::Backspace => {
1667 if app.cursor > 0 {
1668 app.cursor -= 1;
1669 app.input.remove(app.cursor);
1670 }
1671 None
1672 }
1673 KeyCode::Delete => {
1674 if app.cursor < app.input.len() {
1675 app.input.remove(app.cursor);
1676 }
1677 None
1678 }
1679 KeyCode::Left => {
1680 app.cursor = app.cursor.saturating_sub(1);
1681 None
1682 }
1683 KeyCode::Right => {
1684 if app.cursor < app.input.len() {
1685 app.cursor += 1;
1686 }
1687 None
1688 }
1689 KeyCode::Home => {
1690 app.cursor = 0;
1691 None
1692 }
1693 KeyCode::End => {
1694 app.cursor = app.input.len();
1695 None
1696 }
1697 KeyCode::Char(ch) => {
1698 app.input.insert(app.cursor, ch);
1699 app.cursor += 1;
1700 None
1701 }
1702 _ => None,
1703 }
1704 }
1705
1706 // ── Explicit on-device model loading ──────────────────────────────────────
1707
1708 /// The local model `/load` should bring up: the persisted selection if it
1709 /// still resolves to a known model, otherwise the first on-device (non-cloud)
1710 /// entry in the picker.
1711 fn default_local_model_item(app: &App) -> Option<ModelPickerItem> {
1712 if let Some(selected) = crate::setup::load_selected_model()
1713 && let Some(item) = app.model_picker_items.iter().find(|item| {
1714 item.config.model_id == selected.model_id
1715 && item
1716 .config
1717 .files
1718 .iter()
1719 .any(|file| file == &selected.gguf_file)
1720 })
1721 {
1722 return Some(item.clone());
1723 }
1724 app.model_picker_items
1725 .iter()
1726 .find(|item| item.cloud_tier.is_none())
1727 .cloned()
1728 }
1729
1730 /// Load `model` on-device on a dedicated loader thread, routing inference to a
1731 /// fresh `LocalBackend` and driving the switch-progress UI. The caller is
1732 /// responsible for any cloud-tier handling; this path is on-device only.
1733 fn start_local_model_load<B: ratatui::backend::Backend>(
1734 app: &mut App,
1735 model: ModelPickerItem,
1736 engine: Arc<ChatEngine>,
1737 terminal: &mut ratatui::Terminal<B>,
1738 ) {
1739 if model.cache_health == ModelCacheHealth::Incomplete {
1740 app.messages.push(ChatMessage::system(format!(
1741 "error: {} has an incomplete local cache and cannot be selected yet.",
1742 model.display_name
1743 )));
1744 return;
1745 }
1746
1747 // Loading an on-device model puts us in local inference mode.
1748 let _ = crate::settings::set_local_inference(true);
1749
1750 // Route inference on-device; the loader thread below fills the engine the
1751 // LocalBackend reads from.
1752 app.backend = Arc::new(LocalBackend::new(Arc::clone(&engine)));
1753
1754 let loading_msg = if model.cache_health == ModelCacheHealth::NotDownloaded {
1755 format!(
1756 "Downloading and loading {} ({})… this may take a few minutes.",
1757 model.display_name, model.description
1758 )
1759 } else {
1760 format!("Loading {}…", model.display_name)
1761 };
1762
1763 app.messages.push(ChatMessage::system(loading_msg));
1764 terminal.draw(|frame| render(frame, app)).ok();
1765
1766 let (tx, rx) = mpsc::channel(1);
1767 app.model_load_rx = Some(rx);
1768 app.switching_model = true;
1769 app.switching_model_id = Some(model.config.model_id.clone());
1770 // Only show download progress for models not yet cached.
1771 app.download_progress = if model.cache_health == ModelCacheHealth::NotDownloaded {
1772 Some((0, 0))
1773 } else {
1774 None
1775 };
1776
1777 let sampling = SamplingConfig {
1778 max_tokens: Some(model.max_tokens),
1779 ..SamplingConfig::default()
1780 };
1781
1782 // own thread + runtime so block_in_place doesn't starve the TUI loop.
1783 // Fold in project instruction files (AGENTS.md / CLAUDE.md) for the launch
1784 // directory so the on-device model gets the same always-on context the
1785 // cloud and ACP paths get.
1786 let system_prompt = {
1787 let base = crate::system_prompt_for_model(model.tool_calling).to_string();
1788 match std::env::current_dir()
1789 .ok()
1790 .and_then(|cwd| crate::instructions::load_project_instructions(&cwd))
1791 {
1792 Some(extra) => format!("{base}\n\n{extra}"),
1793 None => base,
1794 }
1795 };
1796 let engine_handle = Arc::clone(&engine);
1797 let tool_calling = model.tool_calling;
1798 std::thread::spawn(move || {
1799 let rt = tokio::runtime::Runtime::new().expect("failed to create model-loader runtime");
1800 let update = rt.block_on(async move {
1801 match engine_handle
1802 .load_gguf_model(
1803 model.config.clone(),
1804 Some(system_prompt.to_string()),
1805 Some(sampling),
1806 )
1807 .await
1808 {
1809 Ok(_) => ModelLoadUpdate::Loaded(model.display_name.clone()),
1810 Err(err) => ModelLoadUpdate::Error(err.to_string()),
1811 }
1812 });
1813 // capacity-1 channel, receiver alive while switching
1814 let _ = tx.blocking_send(update);
1815 });
1816 // applied on ModelLoadUpdate::Loaded
1817 app.pending_tool_calling = Some(tool_calling);
1818 }
1819
1820 // ── Slash command execution ───────────────────────────────────────────────
1821
1822 async fn exec_slash<B: ratatui::backend::Backend>(
1823 app: &mut App,
1824 cmd: SlashCommand,
1825 engine: Arc<ChatEngine>,
1826 terminal: &mut ratatui::Terminal<B>,
1827 ) {
1828 match cmd {
1829 SlashCommand::Help => {
1830 app.messages.push(ChatMessage::system(
1831 "/help — show this message\n\
1832 /models — open the model picker\n\
1833 /models N — switch to model N\n\
1834 /local [on|off]— toggle on-device inference mode\n\
1835 /skills — list available Agent Skills\n\
1836 /mcp — list MCP servers and their tools\n\
1837 /load — load the selected on-device model\n\
1838 /login E P — sign in to siGit Code Cloud\n\
1839 /logout — sign out\n\
1840 /whoami — show the signed-in account\n\
1841 /plan [on|off] — plan mode: research only, no edits or commands\n\
1842 /permissions — show the tool permission policy\n\
1843 /compact — summarize and shrink conversation history\n\
1844 /resume — restore the saved session from disk\n\
1845 /clear — wipe conversation history\n\
1846 /status — show engine status\n\
1847 /exit — quit chat",
1848 ));
1849 }
1850 SlashCommand::Clear => {
1851 let cleared = engine.clear_history().await;
1852 app.messages.clear();
1853 crate::permissions::reset_session(crate::permissions::TUI_SESSION);
1854 // The saved session must not resurrect what the user just wiped.
1855 crate::session_store::delete(TUI_STORE_SESSION);
1856 app.messages.push(ChatMessage::system(format!(
1857 "Cleared {cleared} turn(s). History is empty.",
1858 )));
1859 }
1860 SlashCommand::Compact => {
1861 let before = crate::backend::estimate_tokens(&app.backend.history_snapshot().await);
1862 match app
1863 .backend
1864 .compact_history(crate::backend::COMPACT_KEEP_LAST)
1865 .await
1866 {
1867 Ok(()) => {
1868 let snapshot = app.backend.history_snapshot().await;
1869 let after = crate::backend::estimate_tokens(&snapshot);
1870 // Keep the saved session in step with the compacted state.
1871 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot)
1872 {
1873 log::warn!("session save after /compact failed: {error}");
1874 }
1875 app.messages.push(ChatMessage::system(format!(
1876 "Compacted history: ~{before} → ~{after} tokens (estimated)."
1877 )));
1878 }
1879 Err(error) => {
1880 app.messages
1881 .push(ChatMessage::system(format!("Compaction failed: {error}")));
1882 }
1883 }
1884 }
1885 SlashCommand::Resume => match crate::session_store::load(TUI_STORE_SESSION) {
1886 Some(history) if !history.is_empty() => {
1887 let restored = history.len();
1888 app.backend.restore_history(history).await;
1889 app.messages.push(ChatMessage::system(format!(
1890 "Restored {restored} message(s) from the saved session. \
1891 The model remembers the conversation; the scrollback above does not \
1892 replay it."
1893 )));
1894 }
1895 _ => {
1896 app.messages.push(ChatMessage::system(
1897 "No saved session to resume. Sessions are saved after each turn.",
1898 ));
1899 }
1900 },
1901 SlashCommand::Plan(value) => {
1902 use crate::permissions::{self, TUI_SESSION};
1903 let enabled = value.unwrap_or_else(|| !permissions::plan_mode(TUI_SESSION));
1904 permissions::set_plan_mode(TUI_SESSION, enabled);
1905 app.messages.push(ChatMessage::system(if enabled {
1906 "Plan mode ON — research with read-only tools only; edits and commands \
1907 are blocked until /plan off."
1908 } else {
1909 "Plan mode OFF — tools may execute again (subject to the permission \
1910 policy)."
1911 }));
1912 }
1913 SlashCommand::Permissions => {
1914 app.messages
1915 .push(ChatMessage::system(crate::permissions::describe(
1916 crate::permissions::TUI_SESSION,
1917 )));
1918 }
1919 SlashCommand::Status => {
1920 let info = engine.as_ref().info().await;
1921 let model = info.model_name.as_deref().unwrap_or("(none)");
1922 let mem = info.approx_memory.as_deref().unwrap_or("unknown");
1923 app.messages.push(ChatMessage::system(format!(
1924 "status: {:?} model: {} memory: {} history: {} turns",
1925 info.status, model, mem, info.history_length,
1926 )));
1927 }
1928 SlashCommand::Skills => {
1929 app.messages
1930 .push(ChatMessage::system(crate::skills::format_skills_list()));
1931 }
1932 SlashCommand::Mcp => {
1933 app.messages
1934 .push(ChatMessage::system(crate::mcp::status_summary()));
1935 }
1936 SlashCommand::Models(selection) => match selection {
1937 None => {
1938 app.open_model_picker(&engine);
1939 }
1940 Some(n) => {
1941 let idx = n.saturating_sub(1);
1942 match app.model_picker_items.get(idx).cloned() {
1943 None => {
1944 app.messages.push(ChatMessage::system(format!(
1945 "error: no model #{n} — type /models to see the list."
1946 )));
1947 }
1948 Some(model) => {
1949 // ── siGit Code Cloud tier: no local load; sign-in gated ──
1950 if let Some(tier) = model.cloud_tier.clone() {
1951 app.close_model_picker();
1952 match crate::provider::cloud_tier_provider(&tier) {
1953 Some(provider) => {
1954 let system_prompt =
1955 crate::system_prompt_for_model(true).to_string();
1956 app.backend = Arc::new(OpenAiBackend::new(
1957 provider.base_url,
1958 provider.api_key,
1959 provider.model,
1960 Some(system_prompt),
1961 ));
1962 app.current_model_name = provider.display_name.clone();
1963 app.tool_calling = true;
1964 // Selecting a cloud tier puts us in cloud mode.
1965 let _ = crate::settings::set_local_inference(false);
1966 app.messages.push(ChatMessage::system(format!(
1967 "Switched to {}.",
1968 provider.display_name
1969 )));
1970 }
1971 None => {
1972 app.messages.push(ChatMessage::system(
1973 "siGit Code Cloud needs an account. Use \
1974 `/login <email> <password>`, or create one at sigit.si.",
1975 ));
1976 }
1977 }
1978 return;
1979 }
1980
1981 app.close_model_picker();
1982 start_local_model_load(app, model, Arc::clone(&engine), terminal);
1983 }
1984 }
1985 }
1986 },
1987 SlashCommand::Local(value) => {
1988 let enabled = value.unwrap_or(!crate::settings::local_inference_enabled());
1989 match crate::settings::set_local_inference(enabled) {
1990 Ok(()) => {
1991 let state = if enabled { "on" } else { "off" };
1992 let hint = if enabled {
1993 "On-device models are highlighted. Type /models to pick one."
1994 } else {
1995 "siGit Code Cloud tiers are highlighted. Type /models to pick one."
1996 };
1997 app.messages.push(ChatMessage::system(format!(
1998 "Local inference is {state}. {hint}"
1999 )));
2000 // Refresh the picker so emphasis/order reflects the new mode.
2001 if app.show_model_picker {
2002 app.open_model_picker(&engine);
2003 }
2004 }
2005 Err(error) => {
2006 app.messages.push(ChatMessage::system(format!(
2007 "error: could not save local inference setting: {error}"
2008 )));
2009 }
2010 }
2011 }
2012 SlashCommand::Load => match default_local_model_item(app) {
2013 None => {
2014 app.messages.push(ChatMessage::system(
2015 "No local model available to load. Use /models to see the list.",
2016 ));
2017 }
2018 Some(model) => {
2019 start_local_model_load(app, model, Arc::clone(&engine), terminal);
2020 }
2021 },
2022 SlashCommand::Login(arg) => {
2023 let message = match arg.as_deref().and_then(crate::account::parse_login_args) {
2024 Some((email, password)) => {
2025 match crate::account::authenticate(&email, &password).await {
2026 Ok(email) => format!(
2027 "Signed in as {email}. siGit Code Cloud applies to your next session."
2028 ),
2029 Err(error) => format!("Login failed: {error}"),
2030 }
2031 }
2032 None => "usage: /login <email> <password>".to_string(),
2033 };
2034 app.messages.push(ChatMessage::system(message));
2035 }
2036 SlashCommand::Logout => {
2037 let message = crate::account::end_session().await;
2038 app.messages.push(ChatMessage::system(message));
2039 }
2040 SlashCommand::Whoami => {
2041 let message = crate::account::status_line().await;
2042 app.messages.push(ChatMessage::system(message));
2043 }
2044 SlashCommand::Exit => {
2045 app.quit = true;
2046 }
2047 SlashCommand::Unknown(cmd) => {
2048 app.messages
2049 .push(ChatMessage::system(format!("unknown command: {cmd}")));
2050 }
2051 }
2052 }
2053
2054 // ── Background inference task ─────────────────────────────────────────────
2055
2056 /// cap tool rounds so a confused model can't loop forever; auto-compaction
2057 /// keeps long runs inside the context window, so the cap can be generous
2058 const MAX_TOOL_ROUNDS: usize = 24;
2059
2060 /// The TUI is a single conversation, so it persists under one fixed
2061 /// session-store id (ACP sessions use their protocol-assigned ids).
2062 const TUI_STORE_SESSION: &str = "tui";
2063
2064 fn build_tool_specs() -> Vec<ToolSpec> {
2065 let mut specs: Vec<ToolSpec> = crate::tools::all_tools()
2066 .into_iter()
2067 .map(|t| ToolSpec {
2068 name: t.name.to_string(),
2069 description: t.description.to_string(),
2070 parameters_schema: t.parameters_schema.to_string(),
2071 })
2072 .collect();
2073
2074 // Advertise the Agent Skills `skill` tool only when skills exist on disk
2075 // (https://agentskills.io). The tool description carries the discovery
2076 // list (name + description) for progressive disclosure.
2077 let discovered = crate::skills::discover_skills();
2078 if !discovered.is_empty() {
2079 specs.push(ToolSpec {
2080 name: crate::skills::SKILL_TOOL_NAME.to_string(),
2081 description: crate::skills::skill_tool_description(&discovered),
2082 parameters_schema: crate::skills::skill_tool_schema().to_string(),
2083 });
2084 }
2085
2086 // Delegated research (`task`) is offered only when a subagent backend
2087 // can actually be built — same conditional pattern as `skill` above.
2088 if crate::tools::subagent_available() {
2089 specs.push(crate::tools::task_tool_spec());
2090 }
2091
2092 // Tools discovered from configured MCP servers (incl. the official one).
2093 specs.extend(crate::mcp::tool_specs());
2094
2095 specs
2096 }
2097
2098 /// Close out a cancelled round in backend history: the results of tools
2099 /// that already ran this round, plus cancellation notes for `unreached`
2100 /// calls. Leaving a round's tool calls unanswered breaks strict
2101 /// OpenAI-compatible endpoints on the session's next request.
2102 async fn abandon_round(
2103 backend: &dyn InferenceBackend,
2104 mut tool_results: Vec<ToolResult>,
2105 unreached: &[crate::backend::ToolCall],
2106 ) {
2107 for pending in unreached {
2108 tool_results.push(ToolResult {
2109 tool_call_id: pending.id.clone(),
2110 content: format!(
2111 "`{}` was not executed: the user cancelled the turn.",
2112 pending.name
2113 ),
2114 });
2115 }
2116 backend.record_cancelled_tool_results(tool_results).await;
2117 }
2118
2119 /// run the tool-calling loop off the main thread, posting updates via `tx`.
2120 /// dropping `tx` signals completion to the event loop.
2121 async fn run_inference_task(
2122 backend: Arc<dyn InferenceBackend>,
2123 text: String,
2124 tx: mpsc::Sender<InferenceUpdate>,
2125 tools_enabled: bool,
2126 ) {
2127 let tools = if tools_enabled {
2128 build_tool_specs()
2129 } else {
2130 vec![]
2131 };
2132
2133 // Bridge the backend's token sink (plain strings) onto the UI update
2134 // channel as `Delta` messages. The forwarder lives for the whole turn.
2135 let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
2136 let forward_tx = tx.clone();
2137 let forwarder = tokio::spawn(async move {
2138 while let Some(piece) = delta_rx.recv().await {
2139 if forward_tx
2140 .send(InferenceUpdate::Delta(piece))
2141 .await
2142 .is_err()
2143 {
2144 break;
2145 }
2146 }
2147 });
2148
2149 // The first round offers tools, so on-device inference can't stream it
2150 // (it must buffer to detect tool calls). With tools disabled there are
2151 // none to offer, so it streams directly.
2152 let first_sink = if tools.is_empty() {
2153 Some(&delta_tx)
2154 } else {
2155 None
2156 };
2157 let mut streamed = first_sink.is_some();
2158
2159 let mut result = match backend
2160 .send_message_with_tools(&text, &tools, first_sink)
2161 .await
2162 {
2163 Ok(r) => r,
2164 Err(err) => {
2165 let _ = tx.send(InferenceUpdate::Error(err)).await;
2166 return;
2167 }
2168 };
2169
2170 let mut round = 0;
2171
2172 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
2173 // any tool call means the first round didn't produce a final answer
2174 streamed = false;
2175 round += 1;
2176 log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
2177
2178 // Auto-compaction: long tool runs grow history fast; fold it into
2179 // a summary before the next round rather than blowing the window.
2180 let estimate = crate::backend::estimate_tokens(&backend.history_snapshot().await);
2181 if estimate > crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
2182 log::info!(
2183 "history ≈{estimate} tokens exceeds budget {} — compacting",
2184 crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET
2185 );
2186 match backend
2187 .compact_history(crate::backend::COMPACT_KEEP_LAST)
2188 .await
2189 {
2190 Ok(()) => {
2191 let after =
2192 crate::backend::estimate_tokens(&backend.history_snapshot().await);
2193 log::info!("compacted history to ≈{after} tokens");
2194 }
2195 Err(error) => log::warn!("history compaction failed: {error}"),
2196 }
2197 }
2198
2199 let mut tool_results = Vec::new();
2200
2201 for (call_index, tc) in result.tool_calls.iter().enumerate() {
2202 // The UI drops the receiver on Ctrl+C or quit. Stop the turn
2203 // at the next boundary instead of burning model rounds (and
2204 // possibly running granted tools) in the background.
2205 if tx.is_closed() {
2206 log::info!("turn cancelled by the user — stopping the tool loop");
2207 abandon_round(&*backend, tool_results, &result.tool_calls[call_index..]).await;
2208 return;
2209 }
2210
2211 log::info!(
2212 " → {}({})",
2213 tc.name,
2214 tc.arguments.chars().take(120).collect::<String>()
2215 );
2216
2217 let _ = tx.send(InferenceUpdate::ToolUse(tc.name.clone())).await;
2218
2219 // Permission gate: read-only tools pass straight through; a
2220 // mutating tool consults policy and may pause on the user's
2221 // y/a/n answer (delivered over a oneshot from the event loop).
2222 use crate::permissions::{self, Decision, TUI_SESSION};
2223 let output = match permissions::decision_for(TUI_SESSION, &tc.name) {
2224 Decision::Allow => crate::tools::execute_tool(&tc.name, &tc.arguments).await,
2225 Decision::Deny(reason) => {
2226 log::info!(" ✗ {} denied by policy", tc.name);
2227 reason
2228 }
2229 Decision::Ask => {
2230 let (reply_tx, reply_rx) = oneshot::channel();
2231 let _ = tx
2232 .send(InferenceUpdate::ApprovalRequest {
2233 tool: tc.name.clone(),
2234 args: permissions::approval_preview(&tc.arguments),
2235 reply: reply_tx,
2236 })
2237 .await;
2238 match reply_rx.await {
2239 Ok(ApprovalChoice::Once) => {
2240 crate::tools::execute_tool(&tc.name, &tc.arguments).await
2241 }
2242 Ok(ApprovalChoice::Session) => {
2243 permissions::grant_for_session(TUI_SESSION, &tc.name);
2244 crate::tools::execute_tool(&tc.name, &tc.arguments).await
2245 }
2246 Ok(ApprovalChoice::Deny) => {
2247 log::info!(" ✗ {} denied by user", tc.name);
2248 permissions::user_denial(&tc.name)
2249 }
2250 // The UI dropped the reply channel (Ctrl+C or
2251 // quit): the whole turn is over, not just this
2252 // call. Close out the round and stop instead of
2253 // continuing rounds in the background.
2254 Err(_) => {
2255 log::info!(
2256 "turn cancelled at the approval prompt — stopping the tool loop"
2257 );
2258 abandon_round(
2259 &*backend,
2260 tool_results,
2261 &result.tool_calls[call_index..],
2262 )
2263 .await;
2264 return;
2265 }
2266 }
2267 }
2268 };
2269 log::info!(" ← {} chars", output.len());
2270
2271 tool_results.push(ToolResult {
2272 tool_call_id: tc.id.clone(),
2273 content: output,
2274 });
2275 }
2276
2277 // Cancelled while the round's tools ran: record what executed and
2278 // stop before paying for another model round nobody will see.
2279 if tx.is_closed() {
2280 log::info!("turn cancelled by the user — skipping the next model round");
2281 abandon_round(&*backend, tool_results, &[]).await;
2282 return;
2283 }
2284
2285 // on the last round, pass no tools so the model must produce text —
2286 // that's also the round we can stream on-device.
2287 let next_tools = if round < MAX_TOOL_ROUNDS {
2288 Some(tools.as_slice())
2289 } else {
2290 None
2291 };
2292 let sink = if next_tools.is_none() {
2293 streamed = true;
2294 Some(&delta_tx)
2295 } else {
2296 None
2297 };
2298
2299 match backend
2300 .send_tool_results(tool_results, next_tools, sink)
2301 .await
2302 {
2303 Ok(r) => result = r,
2304 Err(err) => {
2305 let _ = tx.send(InferenceUpdate::Error(err)).await;
2306 return;
2307 }
2308 }
2309 }
2310
2311 // Drop the sink so the forwarder finishes draining any buffered tokens
2312 // before we commit the reply.
2313 drop(delta_tx);
2314 let _ = forwarder.await;
2315
2316 if result.tool_calls.is_empty() {
2317 if result.text.is_empty() {
2318 log::warn!(
2319 "model returned empty reply — may have exhausted max_tokens on thinking"
2320 );
2321 let _ = tx
2322 .send(InferenceUpdate::Error(
2323 "(empty response — the model may have used all tokens on internal reasoning. \
2324 Try a shorter or simpler prompt.)"
2325 .to_string(),
2326 ))
2327 .await;
2328 } else if streamed {
2329 // tokens already went out as deltas; just commit the buffer
2330 let _ = tx.send(InferenceUpdate::StreamEnd).await;
2331 } else {
2332 let _ = tx.send(InferenceUpdate::Response(result.text)).await;
2333 }
2334 }
2335
2336 // Persist the completed turn so /resume (or a restart) can pick the
2337 // conversation back up.
2338 let snapshot = backend.history_snapshot().await;
2339 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot) {
2340 log::warn!("session save failed: {error}");
2341 }
2342
2343 log::info!("inference complete — {} tool round(s)", round);
2344 // tx drops here — event loop gets None from rx.recv()
2345 }
2346
2347 // ── Main loop ─────────────────────────────────────────────────────────────
2348
2349 /// entry point — blocks until the user quits.
2350 /// caller owns terminal init/restore. `load_rx` delivers the model-load result
2351 /// from a dedicated OS thread; we poll it non-blocking each tick.
2352 pub async fn run_with<B: ratatui::backend::Backend>(
2353 terminal: &mut ratatui::Terminal<B>,
2354 engine: Arc<ChatEngine>,
2355 backend: Arc<dyn InferenceBackend>,
2356 load_rx: std_mpsc::Receiver<Result<(), String>>,
2357 load_model_name: String,
2358 ) -> Result<()> {
2359 event_loop(terminal, engine, backend, load_rx, load_model_name).await
2360 }
2361
2362 async fn event_loop<B: ratatui::backend::Backend>(
2363 terminal: &mut ratatui::Terminal<B>,
2364 engine: Arc<ChatEngine>,
2365 backend: Arc<dyn InferenceBackend>,
2366 load_rx: std_mpsc::Receiver<Result<(), String>>,
2367 load_model_name: String,
2368 ) -> Result<()> {
2369 let mut app = App::new(load_model_name, backend);
2370 let mut event_stream = EventStream::new();
2371
2372 // 10 fps is plenty for spinners
2373 let mut ticker = interval(Duration::from_millis(100));
2374
2375 loop {
2376 // ── Poll the loader channel (non-blocking) ────────────────────────
2377 if app.is_loading {
2378 match load_rx.try_recv() {
2379 Ok(Ok(())) => app.finish_loading(),
2380 Ok(Err(e)) => app.set_load_error(e),
2381 Err(std_mpsc::TryRecvError::Empty) => {}
2382 Err(std_mpsc::TryRecvError::Disconnected) => {
2383 app.set_load_error("Model loader thread crashed.".to_string());
2384 }
2385 }
2386 }
2387
2388 // redraw every iteration
2389 terminal.draw(|frame| render(frame, &mut app))?;
2390
2391 if let Some(rx) = app.model_load_rx.as_mut() {
2392 match rx.try_recv() {
2393 Ok(ModelLoadUpdate::Loaded(model_name)) => {
2394 engine.clear_history().await;
2395 if let Some(tc) = app.pending_tool_calling.take() {
2396 app.tool_calling = tc;
2397 }
2398 app.switching_model = false;
2399 app.switching_model_id = None;
2400 app.download_progress = None;
2401 app.model_load_cancelled = false;
2402 app.model_load_rx = None;
2403 app.current_model_name = model_name.clone();
2404
2405 let save_result = app
2406 .model_picker_items
2407 .iter()
2408 .find(|item| item.display_name == model_name)
2409 .map(|item| crate::setup::SelectedModel {
2410 model_id: item.config.model_id.clone(),
2411 gguf_file: item
2412 .config
2413 .files
2414 .first()
2415 .cloned()
2416 .unwrap_or_else(String::new),
2417 })
2418 .filter(|selected| !selected.gguf_file.is_empty())
2419 .map(|selected| crate::setup::save_selected_model(&selected))
2420 .unwrap_or_else(|| {
2421 Err(format!(
2422 "could not determine a stable identifier for {}",
2423 model_name
2424 ))
2425 });
2426
2427 if let Err(error) = save_result {
2428 app.messages.push(ChatMessage::system(format!(
2429 "warning: switched to {} but could not save the selection: {}",
2430 model_name, error
2431 )));
2432 } else {
2433 app.messages
2434 .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
2435 }
2436 }
2437 Ok(ModelLoadUpdate::Error(error)) => {
2438 app.switching_model = false;
2439 app.switching_model_id = None;
2440 app.download_progress = None;
2441 app.model_load_cancelled = false;
2442 app.model_load_rx = None;
2443 app.messages
2444 .push(ChatMessage::system(format!("error loading model: {error}")));
2445 }
2446 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
2447 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
2448 let was_cancelled = app.model_load_cancelled;
2449 app.switching_model = false;
2450 app.switching_model_id = None;
2451 app.download_progress = None;
2452 app.model_load_cancelled = false;
2453 app.model_load_rx = None;
2454 if !was_cancelled {
2455 app.messages.push(ChatMessage::system(
2456 "error loading model: loader task disconnected".to_string(),
2457 ));
2458 }
2459 }
2460 }
2461 }
2462
2463 if app.quit {
2464 break;
2465 }
2466
2467 // multiplex terminal events, streaming tokens, inference updates,
2468 // and the thinking-spinner timer.
2469 tokio::select! {
2470 biased;
2471
2472 // ── Spinner tick (loading phase only) ─────────────────────────
2473 _ = ticker.tick(), if app.is_loading => {
2474 app.tick();
2475 }
2476
2477 // ── inference updates from background task ───────────────────
2478 update = async {
2479 match app.inference_rx.as_mut() {
2480 Some(rx) => rx.recv().await,
2481 None => pending().await,
2482 }
2483 } => {
2484 match update {
2485 Some(InferenceUpdate::ToolUse(name)) => {
2486 app.messages.push(ChatMessage::system(format!("🔧 {name}")));
2487 }
2488 Some(InferenceUpdate::Delta(delta)) => {
2489 app.push_stream_delta(&delta);
2490 }
2491 Some(InferenceUpdate::StreamEnd) => {
2492 app.finalize_stream();
2493 }
2494 Some(InferenceUpdate::Response(text)) => {
2495 app.stop_thinking();
2496 app.messages.push(ChatMessage::assistant(text));
2497 }
2498 Some(InferenceUpdate::Error(msg)) => {
2499 app.finalize_stream();
2500 app.stop_thinking();
2501 app.messages.push(ChatMessage::system(format!("error: {msg}")));
2502 }
2503 Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
2504 // The y/a/n prompt lives on the Session tab; make
2505 // sure the user can see what they're answering.
2506 app.active_tab = Tab::Session;
2507 let call = if args.is_empty() {
2508 tool.clone()
2509 } else {
2510 format!("{tool}({args})")
2511 };
2512 app.messages.push(ChatMessage::system(format!(
2513 "⚠ permission — allow {call}? [y]es · [a]lways this session · [n]o"
2514 )));
2515 app.pending_approval = Some((tool, reply));
2516 }
2517 None => {
2518 // task finished, possibly with no text to show
2519 app.finalize_stream();
2520 app.stop_thinking();
2521 }
2522 }
2523 }
2524
2525 // ── Cloud tab status fetch resolving ─────────────────────────
2526 status = async {
2527 match app.cloud_rx.as_mut() {
2528 Some(rx) => rx.await,
2529 None => pending().await,
2530 }
2531 } => {
2532 app.cloud_rx = None;
2533 app.cloud_lines = Some(status.unwrap_or_else(|_| {
2534 vec!["error: the status fetch task died — press r to retry".to_string()]
2535 }));
2536 }
2537
2538 // ── thinking / switching spinner tick (100ms) ────────────────
2539 _ = async {
2540 if app.thinking || app.switching_model {
2541 tokio::time::sleep(Duration::from_millis(100)).await
2542 } else {
2543 pending().await
2544 }
2545 } => {
2546 app.tick_thinking();
2547 // keep the progress display fresh
2548 if app.switching_model {
2549 app.poll_download_progress();
2550 }
2551 }
2552
2553 // ── Terminal events ───────────────────────────────────────────
2554 maybe_event = event_stream.next() => {
2555 let Some(Ok(event)) = maybe_event else {
2556 break;
2557 };
2558
2559 if let Event::Key(key) = event {
2560 // loading phase — only quit keys work
2561 if app.is_loading {
2562 if key.kind == KeyEventKind::Press {
2563 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
2564 if ctrl
2565 && (key.code == KeyCode::Char('c')
2566 || key.code == KeyCode::Char('d'))
2567 {
2568 app.quit = true;
2569 }
2570 }
2571 continue;
2572 }
2573
2574 // pending tool approval — y/a/n answer the prompt; the
2575 // inference task is paused on the reply channel. Checked
2576 // before the busy gate because the app *is* busy here.
2577 if app.pending_approval.is_some() {
2578 if key.kind == KeyEventKind::Press {
2579 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
2580 let choice = if ctrl
2581 && (key.code == KeyCode::Char('c')
2582 || key.code == KeyCode::Char('d'))
2583 {
2584 // cancel the whole turn: denying is implicit
2585 // in dropping the reply channel
2586 app.pending_approval = None;
2587 app.stop_thinking();
2588 app.messages.push(ChatMessage::system("(cancelled)"));
2589 continue;
2590 } else {
2591 match key.code {
2592 KeyCode::Char('y') | KeyCode::Char('Y') => {
2593 Some(ApprovalChoice::Once)
2594 }
2595 KeyCode::Char('a') | KeyCode::Char('A') => {
2596 Some(ApprovalChoice::Session)
2597 }
2598 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
2599 Some(ApprovalChoice::Deny)
2600 }
2601 _ => None,
2602 }
2603 };
2604 if let Some(choice) = choice
2605 && let Some((tool, reply)) = app.pending_approval.take()
2606 {
2607 let verdict = match &choice {
2608 ApprovalChoice::Once => "allowed once",
2609 ApprovalChoice::Session => "allowed for this session",
2610 ApprovalChoice::Deny => "denied",
2611 };
2612 app.messages.push(ChatMessage::system(format!(
2613 "{tool}: {verdict}"
2614 )));
2615 let _ = reply.send(choice);
2616 }
2617 }
2618 continue;
2619 }
2620
2621 // ── Tab-bar navigation ────────────────────────────────
2622 // Handled before the busy gate so the user can look at
2623 // History/Cloud while inference runs (updates keep
2624 // landing in the Session tab's message list). The Tab
2625 // key only cycles when the input buffer is empty, so
2626 // pasted text containing tabs can't fight it; on
2627 // non-Session tabs the input is hidden, so it always
2628 // cycles there.
2629 if key.kind == KeyEventKind::Press && !app.show_model_picker {
2630 if key.code == KeyCode::Tab
2631 && (app.active_tab != Tab::Session || app.input.is_empty())
2632 {
2633 let next = app.active_tab.next();
2634 switch_tab(&mut app, next, &engine);
2635 continue;
2636 }
2637 if app.active_tab != Tab::Session && key.code == KeyCode::Esc {
2638 app.active_tab = Tab::Session;
2639 continue;
2640 }
2641 }
2642
2643 // busy — only cancel keys work
2644 if app.is_busy() {
2645 if key.kind == KeyEventKind::Press {
2646 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
2647 if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
2648 if app.is_streaming() {
2649 app.finalize_stream();
2650 app.messages.push(ChatMessage::system("(cancelled)"));
2651 }
2652 if app.thinking {
2653 // dropping rx kills the background task
2654 app.stop_thinking();
2655 app.messages.push(ChatMessage::system("(cancelled)"));
2656 }
2657 if app.switching_model {
2658 // flag before drop so Disconnected handler stays quiet
2659 app.model_load_cancelled = true;
2660 app.switching_model = false;
2661 app.switching_model_id = None;
2662 app.download_progress = None;
2663 app.model_load_rx = None;
2664 app.messages
2665 .push(ChatMessage::system("(download cancelled — model switch aborted)"));
2666 }
2667 }
2668 }
2669 continue;
2670 }
2671
2672 // History / Cloud tabs have their own key handling; the
2673 // chat input is inactive there.
2674 if app.active_tab != Tab::Session {
2675 if key.kind == KeyEventKind::Press {
2676 handle_tab_key(&mut app, key, &engine).await;
2677 }
2678 continue;
2679 }
2680
2681 if let Some(text) = handle_key(&mut app, key) {
2682 if let Some(cmd) = parse_slash(&text) {
2683 exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await;
2684 continue;
2685 }
2686
2687 // On-device inference needs a model in memory, and we
2688 // never load one implicitly: the user loads it with
2689 // /load (or /models). Refuse rather than erroring out
2690 // deep in the backend.
2691 if !app.backend.is_remote()
2692 && engine.info().await.status == onde::inference::EngineStatus::Unloaded
2693 {
2694 app.messages.push(ChatMessage::user(&text));
2695 app.messages.push(ChatMessage::system(
2696 "No on-device model is loaded. Run /load to load the selected \
2697 model, or /models to choose one.",
2698 ));
2699 continue;
2700 }
2701
2702 // ── spawn inference ──────────────────────────────
2703 app.messages.push(ChatMessage::user(&text));
2704 app.start_thinking();
2705
2706 let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
2707 app.inference_rx = Some(rx);
2708
2709 let backend_handle = Arc::clone(&app.backend);
2710 let user_text = text.clone();
2711 let tools_enabled = app.tool_calling;
2712 tokio::spawn(async move {
2713 run_inference_task(backend_handle, user_text, tx, tools_enabled).await;
2714 });
2715 }
2716 }
2717 }
2718 }
2719 }
2720
2721 Ok(())
2722 }
2723
2724 // ── Download progress helpers (TUI) ──────────────────────────────────────
2725
2726 /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks)
2727 fn dir_size_recursive(path: &std::path::Path) -> u64 {
2728 let mut total: u64 = 0;
2729 let Ok(entries) = std::fs::read_dir(path) else {
2730 return 0;
2731 };
2732 for entry in entries.flatten() {
2733 let entry_path = entry.path();
2734 if entry_path.is_dir() {
2735 total += dir_size_recursive(&entry_path);
2736 } else if let Ok(meta) = entry_path.metadata() {
2737 total += meta.len();
2738 }
2739 }
2740 total
2741 }
2742
2743 fn format_size_human(bytes: u64) -> String {
2744 const GB: u64 = 1_073_741_824;
2745 const MB: u64 = 1_048_576;
2746 const KB: u64 = 1_024;
2747 if bytes >= GB {
2748 format!("{:.2} GB", bytes as f64 / GB as f64)
2749 } else if bytes >= MB {
2750 format!("{:.1} MB", bytes as f64 / MB as f64)
2751 } else if bytes >= KB {
2752 format!("{:.0} KB", bytes as f64 / KB as f64)
2753 } else {
2754 format!("{bytes} B")
2755 }
2756 }
2757 } // end #[cfg(unix)] mod tui
2758
2759 // re-export so callers write `chat::run_with(...)` on all platforms
2760 #[cfg(unix)]
2761 pub use tui::run_with;
2762
2763 // ── Tests (platform-agnostic) ─────────────────────────────────────────────────
2764
2765 #[cfg(test)]
2766 mod tests {
2767 use std::time::Duration;
2768
2769 use super::{Tab, format_age, history_row, parse_rich_text_segments, strip_think_blocks};
2770
2771 #[test]
2772 fn tab_next_cycles_session_history_cloud() {
2773 assert_eq!(Tab::Session.next(), Tab::History);
2774 assert_eq!(Tab::History.next(), Tab::Cloud);
2775 assert_eq!(Tab::Cloud.next(), Tab::Session);
2776 // Three hops return to the start, matching the tab bar's order.
2777 assert_eq!(Tab::Session.next().next().next(), Tab::Session);
2778 }
2779
2780 #[test]
2781 fn tab_index_matches_titles_order() {
2782 assert_eq!(Tab::TITLES[Tab::Session.index()], "Session");
2783 assert_eq!(Tab::TITLES[Tab::History.index()], "History");
2784 assert_eq!(Tab::TITLES[Tab::Cloud.index()], "Cloud");
2785 }
2786
2787 #[test]
2788 fn format_age_picks_the_coarsest_sensible_unit() {
2789 assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
2790 assert_eq!(format_age(Duration::from_secs(59)), "59s ago");
2791 assert_eq!(format_age(Duration::from_secs(60)), "1m ago");
2792 assert_eq!(format_age(Duration::from_secs(3_599)), "59m ago");
2793 assert_eq!(format_age(Duration::from_secs(3_600)), "1h ago");
2794 assert_eq!(format_age(Duration::from_secs(86_399)), "23h ago");
2795 assert_eq!(format_age(Duration::from_secs(86_400)), "1d ago");
2796 assert_eq!(format_age(Duration::from_secs(3 * 86_400)), "3d ago");
2797 }
2798
2799 #[test]
2800 fn history_row_formats_id_age_and_count() {
2801 assert_eq!(
2802 history_row("tui", Some(Duration::from_secs(120)), 7),
2803 "tui · 2m ago · 7 message(s)"
2804 );
2805 assert_eq!(
2806 history_row("sess-1", None, 0),
2807 "sess-1 · age unknown · 0 message(s)"
2808 );
2809 }
2810
2811 #[test]
2812 fn strip_think_blocks_separates_thinking_and_visible_reply() {
2813 let raw = "<think>I should inspect the code first.</think>Here is the fix.";
2814 let (thinking, visible) = strip_think_blocks(raw);
2815
2816 assert_eq!(thinking, "I should inspect the code first.");
2817 assert_eq!(visible, "Here is the fix.");
2818 }
2819
2820 #[test]
2821 fn strip_think_blocks_handles_unclosed_think_block() {
2822 let raw = "<think>I am still reasoning about the bug";
2823 let (thinking, visible) = strip_think_blocks(raw);
2824
2825 assert_eq!(thinking, "I am still reasoning about the bug");
2826 assert_eq!(visible, "");
2827 }
2828
2829 #[test]
2830 fn strip_think_blocks_leaves_plain_text_untouched() {
2831 let raw = "No hidden reasoning here.";
2832 let (thinking, visible) = strip_think_blocks(raw);
2833
2834 assert_eq!(thinking, "");
2835 assert_eq!(visible, "No hidden reasoning here.");
2836 }
2837
2838 #[test]
2839 fn parse_rich_text_segments_marks_bold_runs() {
2840 let segments = parse_rich_text_segments(
2841 "The current weather is **72°F** with **Partly Cloudy** conditions.",
2842 );
2843
2844 assert_eq!(
2845 segments,
2846 vec![
2847 ("The current weather is ".to_string(), false),
2848 ("72°F".to_string(), true),
2849 (" with ".to_string(), false),
2850 ("Partly Cloudy".to_string(), true),
2851 (" conditions.".to_string(), false),
2852 ]
2853 );
2854 }
2855
2856 #[test]
2857 fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
2858 let segments = parse_rich_text_segments("Prefix **bold");
2859
2860 assert_eq!(
2861 segments,
2862 vec![("Prefix ".to_string(), false), ("bold".to_string(), true),]
2863 );
2864 }
2865 }