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