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 Exit,
1383 Unknown(String),
1384 }
1385
1386 fn parse_slash(input: &str) -> Option<SlashCommand> {
1387 let trimmed = input.trim();
1388 if !trimmed.starts_with('/') {
1389 return None;
1390 }
1391 let mut parts = trimmed.splitn(2, char::is_whitespace);
1392 let cmd = parts.next().unwrap_or("");
1393 let arg = parts.next().map(|s| s.trim());
1394 Some(match cmd {
1395 "/help" => SlashCommand::Help,
1396 "/clear" => SlashCommand::Clear,
1397 "/status" => SlashCommand::Status,
1398 "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),
1399 "/local" => SlashCommand::Local(parse_on_off(arg)),
1400 "/skills" => SlashCommand::Skills,
1401 "/mcp" => SlashCommand::Mcp,
1402 "/load" => SlashCommand::Load,
1403 "/login" => SlashCommand::Login(arg.map(str::to_string)),
1404 "/logout" => SlashCommand::Logout,
1405 "/whoami" => SlashCommand::Whoami,
1406 "/plan" => SlashCommand::Plan(parse_on_off(arg)),
1407 "/permissions" => SlashCommand::Permissions,
1408 "/thinking" => SlashCommand::Thinking(parse_on_off(arg)),
1409 "/tools" => SlashCommand::Tools(parse_on_off(arg)),
1410 "/compact" => SlashCommand::Compact,
1411 "/resume" => SlashCommand::Resume,
1412 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
1413 other => SlashCommand::Unknown(other.to_string()),
1414 })
1415 }
1416
1417 /// `on`/`off` (and synonyms) → `Some(bool)`; missing or unrecognized → `None`
1418 /// (meaning "toggle the current value").
1419 fn parse_on_off(arg: Option<&str>) -> Option<bool> {
1420 match arg.map(|s| s.trim().to_ascii_lowercase())?.as_str() {
1421 "on" | "true" | "1" | "yes" => Some(true),
1422 "off" | "false" | "0" | "no" => Some(false),
1423 _ => None,
1424 }
1425 }
1426
1427 // ── Rendering ─────────────────────────────────────────────────────────────
1428
1429 fn render(frame: &mut Frame, app: &mut App) {
1430 let area = frame.area();
1431
1432 if app.is_loading {
1433 let zones = Layout::vertical([
1434 Constraint::Length(1),
1435 Constraint::Min(1),
1436 Constraint::Length(1),
1437 ])
1438 .split(area);
1439 render_loading_title(frame, app, zones[0]);
1440 render_loading(frame, app, zones[1]);
1441 render_loading_footer(frame, zones[2]);
1442 return;
1443 }
1444
1445 match app.active_tab {
1446 Tab::Session => {
1447 let zones = Layout::vertical([
1448 Constraint::Length(1),
1449 Constraint::Length(1),
1450 Constraint::Min(1),
1451 Constraint::Length(3),
1452 Constraint::Length(1),
1453 ])
1454 .split(area);
1455
1456 render_tab_bar(frame, app, zones[0]);
1457 render_title(frame, app, zones[1]);
1458 render_messages(frame, app, zones[2]);
1459 render_input(frame, app, zones[3]);
1460 render_footer(frame, app, zones[4]);
1461 }
1462 // No input pane on the non-chat tabs: the Tab key always cycles.
1463 Tab::History | Tab::Cloud => {
1464 let zones = Layout::vertical([
1465 Constraint::Length(1),
1466 Constraint::Length(1),
1467 Constraint::Min(1),
1468 Constraint::Length(1),
1469 ])
1470 .split(area);
1471
1472 render_tab_bar(frame, app, zones[0]);
1473 render_title(frame, app, zones[1]);
1474 if app.active_tab == Tab::History {
1475 render_history_tab(frame, app, zones[2]);
1476 } else {
1477 render_cloud_tab(frame, app, zones[2]);
1478 }
1479 render_footer(frame, app, zones[3]);
1480 }
1481 }
1482
1483 if app.show_model_picker {
1484 render_model_picker(frame, app, area);
1485 }
1486 }
1487
1488 fn render_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1489 let model_label = format!(" siGit — {} ", app.current_model_name);
1490 let tool_label = if app.tool_calling {
1491 " [tools on] "
1492 } else {
1493 " [tools off] "
1494 };
1495 let line = Line::from(vec![
1496 Span::styled(
1497 model_label,
1498 Style::default()
1499 .fg(Color::Black)
1500 .bg(Color::Green)
1501 .add_modifier(Modifier::BOLD),
1502 ),
1503 Span::styled(
1504 tool_label,
1505 Style::default().fg(Color::Black).bg(Color::DarkGray),
1506 ),
1507 ]);
1508 frame.render_widget(Paragraph::new(line), area);
1509 }
1510
1511 fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1512 const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
1513 let spin = SPINNER[(app.load_tick as usize) % SPINNER.len()];
1514 let label = format!(" siGit {} loading {}… ", spin, app.load_model_name);
1515 let line = Line::from(Span::styled(
1516 label,
1517 Style::default()
1518 .fg(Color::Black)
1519 .bg(Color::Green)
1520 .add_modifier(Modifier::BOLD),
1521 ));
1522 frame.render_widget(Paragraph::new(line), area);
1523 }
1524
1525 fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1526 let elapsed = app.load_start.elapsed().as_secs();
1527 let elapsed_str = if elapsed < 60 {
1528 format!("{}s", elapsed)
1529 } else {
1530 format!("{}m {}s", elapsed / 60, elapsed % 60)
1531 };
1532
1533 let content = if let Some(ref err) = app.load_error {
1534 format!(
1535 "\n\n ✗ Failed to load model after {}.\n\n {}\n\n Press Ctrl+C to exit.",
1536 elapsed_str, err
1537 )
1538 } else {
1539 format!(
1540 "\n\n Loading model, please wait… ({})\n\n The model is being initialised. This may take a moment on first run.",
1541 elapsed_str
1542 )
1543 };
1544
1545 let style = if app.load_error.is_some() {
1546 Style::default().fg(Color::Red)
1547 } else {
1548 Style::default().fg(Color::White)
1549 };
1550
1551 frame.render_widget(
1552 Paragraph::new(content)
1553 .style(style)
1554 .wrap(Wrap { trim: false }),
1555 area,
1556 );
1557 }
1558
1559 fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) {
1560 let line = Line::from(vec![
1561 Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
1562 Span::styled(" quit", Style::default().fg(Color::DarkGray)),
1563 ]);
1564 frame.render_widget(Paragraph::new(line), area);
1565 }
1566
1567 fn render_messages(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1568 let inner_width = area.width.saturating_sub(2);
1569 let inner_height = area.height.saturating_sub(2);
1570
1571 let block = Block::default()
1572 .borders(Borders::ALL)
1573 .border_style(Style::default().fg(Color::DarkGray));
1574
1575 let inner = block.inner(area);
1576 frame.render_widget(block, area);
1577
1578 let mut lines: Vec<Line> = Vec::new();
1579
1580 for msg in &app.messages {
1581 render_chat_message(
1582 &mut lines,
1583 msg,
1584 inner_width as usize,
1585 app.tools_expanded,
1586 app.show_thinking,
1587 );
1588 }
1589
1590 let streamed_visible = app.visible_stream();
1591 if !streamed_visible.is_empty() {
1592 let fake = ChatMessage {
1593 role: Role::Assistant,
1594 text: streamed_visible,
1595 think_block: None,
1596 tool_detail: None,
1597 tool_result: None,
1598 };
1599 render_chat_message(
1600 &mut lines,
1601 &fake,
1602 inner_width as usize,
1603 app.tools_expanded,
1604 app.show_thinking,
1605 );
1606 if app.blink_on
1607 && let Some(last) = lines.last_mut()
1608 {
1609 last.spans
1610 .push(Span::styled("▋", Style::default().fg(Color::Green)));
1611 }
1612 }
1613
1614 if app.thinking {
1615 lines.push(Line::from(Span::styled(
1616 format!(" {} thinking…", app.thinking_frame()),
1617 Style::default().fg(Color::DarkGray),
1618 )));
1619 // While the stream buffer holds only reasoning, show its live tail
1620 // under the label so the user watches the model think.
1621 let tail_width = (inner_width as usize).saturating_sub(4).max(8);
1622 for tail_line in super::streaming_think_tail(&app.stream_buf, tail_width, 3) {
1623 lines.push(Line::from(Span::styled(
1624 format!(" {tail_line}"),
1625 Style::default()
1626 .fg(Color::DarkGray)
1627 .add_modifier(Modifier::DIM | Modifier::ITALIC),
1628 )));
1629 }
1630 } else if app.switching_model {
1631 // Once the weights have fully landed on disk, swap the spinner for a
1632 // checkmark so it's clear the download finished and we're now loading
1633 // the model into memory (which can still take a while).
1634 let download_complete = matches!(
1635 app.download_progress,
1636 Some((downloaded, expected)) if expected > 0 && downloaded >= expected
1637 );
1638
1639 if download_complete {
1640 let size_str = app
1641 .download_progress
1642 .map(|(_, expected)| format!(" ({})", format_size_human(expected)))
1643 .unwrap_or_default();
1644 lines.push(Line::from(vec![
1645 Span::styled(" ✓ ", Style::default().fg(Color::Green)),
1646 Span::styled(
1647 format!("model downloaded{size_str} — loading into memory…"),
1648 Style::default().fg(Color::DarkGray),
1649 ),
1650 ]));
1651 } else {
1652 let progress_str = if let Some((downloaded, expected)) = app.download_progress {
1653 if expected > 0 {
1654 let pct = (downloaded as f64 / expected as f64 * 100.0).min(100.0) as u8;
1655 let dl_str = format_size_human(downloaded.min(expected));
1656 let ex_str = format_size_human(expected);
1657 format!(" — {dl_str} / {ex_str} ({pct}%)")
1658 } else if downloaded > 0 {
1659 format!(" — {} downloaded", format_size_human(downloaded))
1660 } else {
1661 String::new()
1662 }
1663 } else {
1664 String::new()
1665 };
1666 lines.push(Line::from(Span::styled(
1667 format!(" {} switching model{progress_str}…", app.switching_frame()),
1668 Style::default().fg(Color::DarkGray),
1669 )));
1670 }
1671 }
1672
1673 // Always pin to the bottom so the latest message stays visible. There is
1674 // no scrollback, so we just need the exact number of wrapped rows the
1675 // paragraph occupies at this width — `line_count` runs the same
1676 // WordWrapper as rendering, so it never diverges from what's drawn (an
1677 // estimate would, e.g. by forgetting the `<think>` box lines, and scroll
1678 // too little — the bug this fixes).
1679 let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
1680 let total_lines = paragraph.line_count(inner_width) as u16;
1681 let scroll = total_lines.saturating_sub(inner_height);
1682
1683 frame.render_widget(paragraph.scroll((scroll, 0)), inner);
1684 }
1685
1686 fn render_chat_message(
1687 lines: &mut Vec<Line<'static>>,
1688 msg: &ChatMessage,
1689 _width: usize,
1690 tools_expanded: bool,
1691 show_thinking: bool,
1692 ) {
1693 match msg.role {
1694 Role::Tool => {
1695 let dim = Style::default()
1696 .fg(Color::Rgb(132, 132, 145))
1697 .add_modifier(Modifier::DIM);
1698 for entry_line in super::tool_entry_lines(
1699 &msg.text,
1700 msg.tool_detail.as_deref().unwrap_or(""),
1701 msg.tool_result.as_deref(),
1702 tools_expanded,
1703 ) {
1704 lines.push(Line::from(Span::styled(format!(" {entry_line}"), dim)));
1705 }
1706 }
1707 Role::Banner => {
1708 let palette = [
1709 Color::Red,
1710 Color::Yellow,
1711 Color::Green,
1712 Color::Cyan,
1713 Color::Blue,
1714 Color::Magenta,
1715 ];
1716 let mut spans = Vec::new();
1717 for (i, ch) in msg.text.chars().enumerate() {
1718 let color = palette[i % palette.len()];
1719 spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
1720 }
1721 lines.push(Line::from(spans));
1722 }
1723 Role::System => {
1724 for text_line in msg.text.split('\n') {
1725 let trimmed = text_line.trim();
1726 let (prefix, body) = if trimmed.is_empty() {
1727 ("", "")
1728 } else {
1729 (" · ", trimmed)
1730 };
1731
1732 lines.push(Line::from(vec![
1733 Span::styled(
1734 prefix.to_string(),
1735 Style::default()
1736 .fg(Color::Rgb(90, 90, 98))
1737 .add_modifier(Modifier::DIM),
1738 ),
1739 Span::styled(
1740 body.to_string(),
1741 Style::default()
1742 .fg(Color::Rgb(132, 132, 145))
1743 .add_modifier(Modifier::ITALIC | Modifier::DIM),
1744 ),
1745 ]));
1746 }
1747 }
1748 Role::User => {
1749 let prefix = Span::styled(
1750 "you > ".to_string(),
1751 Style::default()
1752 .fg(Color::Green)
1753 .add_modifier(Modifier::BOLD),
1754 );
1755 let mut first = true;
1756 for text_line in msg.text.split('\n') {
1757 if first {
1758 lines.push(Line::from(vec![
1759 prefix.clone(),
1760 Span::raw(text_line.to_string()),
1761 ]));
1762 first = false;
1763 } else {
1764 lines.push(Line::from(Span::raw(format!(" {text_line}"))));
1765 }
1766 }
1767 }
1768 Role::Assistant => {
1769 if let Some(ref think) = msg.think_block {
1770 if show_thinking {
1771 // /thinking on — the full reasoning, dim italic, with a
1772 // blank line separating it from the answer below.
1773 lines.push(Line::from(Span::styled(
1774 " · thinking".to_string(),
1775 Style::default()
1776 .fg(Color::DarkGray)
1777 .add_modifier(Modifier::DIM),
1778 )));
1779 for think_line in think.split('\n') {
1780 lines.push(Line::from(Span::styled(
1781 format!(" {think_line}"),
1782 Style::default()
1783 .fg(Color::DarkGray)
1784 .add_modifier(Modifier::DIM | Modifier::ITALIC),
1785 )));
1786 }
1787 lines.push(Line::from(""));
1788 } else {
1789 // collapsed — one dim line noting reasoning exists
1790 lines.push(Line::from(Span::styled(
1791 format!(" {}", super::think_indicator(think)),
1792 Style::default()
1793 .fg(Color::DarkGray)
1794 .add_modifier(Modifier::DIM),
1795 )));
1796 }
1797 }
1798
1799 let prefix = Span::styled(
1800 "siGit > ".to_string(),
1801 Style::default()
1802 .fg(Color::Cyan)
1803 .add_modifier(Modifier::BOLD),
1804 );
1805 let body_style = Style::default();
1806 let bold_style = Style::default().add_modifier(Modifier::BOLD);
1807 let mut first = true;
1808 for text_line in msg.text.split('\n') {
1809 if first {
1810 let mut spans = vec![prefix.clone()];
1811 spans.extend(rich_text_spans(text_line, body_style, bold_style));
1812 lines.push(Line::from(spans));
1813 first = false;
1814 } else {
1815 let mut spans = vec![Span::raw(" ".to_string())];
1816 spans.extend(rich_text_spans(text_line, body_style, bold_style));
1817 lines.push(Line::from(spans));
1818 }
1819 }
1820 }
1821 }
1822 }
1823
1824 fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1825 let block = Block::default()
1826 .borders(Borders::ALL)
1827 .border_style(Style::default().fg(Color::DarkGray))
1828 .title(" message ");
1829
1830 let inner = block.inner(area);
1831 frame.render_widget(block, area);
1832
1833 let display = app.input.clone();
1834 frame.render_widget(
1835 Paragraph::new(display.clone()).wrap(Wrap { trim: false }),
1836 inner,
1837 );
1838
1839 let col = (app.cursor as u16) % inner.width;
1840 let row = (app.cursor as u16) / inner.width;
1841 frame.set_cursor_position(Position {
1842 x: inner.x + col,
1843 y: inner.y + row,
1844 });
1845 }
1846
1847 fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1848 let key_style = Style::default().fg(Color::Black).bg(Color::Green);
1849 let label_style = Style::default().fg(Color::DarkGray);
1850
1851 if app.active_tab != Tab::Session {
1852 let hints: &[(&str, &str)] = match app.active_tab {
1853 Tab::History => &[
1854 (" ↑/↓ ", " select "),
1855 (" Enter ", " resume "),
1856 (" d ", " delete "),
1857 (" r ", " refresh "),
1858 (" Tab ", " next tab "),
1859 (" Esc ", " session"),
1860 ],
1861 _ => &[
1862 (" l ", " toggle local inference "),
1863 (" r ", " refresh "),
1864 (" Tab ", " next tab "),
1865 (" Esc ", " session"),
1866 ],
1867 };
1868 let mut spans = Vec::new();
1869 for (key, label) in hints {
1870 spans.push(Span::styled(key.to_string(), key_style));
1871 spans.push(Span::styled(label.to_string(), label_style));
1872 }
1873 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1874 return;
1875 }
1876
1877 let mut spans = vec![
1878 Span::styled(" Enter ", key_style),
1879 Span::styled(" send ", label_style),
1880 Span::styled(" Tab ", key_style),
1881 Span::styled(" tabs ", label_style),
1882 Span::styled(
1883 " /help ",
1884 Style::default().fg(Color::Black).bg(Color::DarkGray),
1885 ),
1886 Span::styled(" commands ", Style::default().fg(Color::DarkGray)),
1887 Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
1888 Span::styled(" quit", Style::default().fg(Color::DarkGray)),
1889 ];
1890
1891 if let Some((tool, _)) = &app.pending_approval {
1892 spans.push(Span::styled(
1893 format!(" allow {tool}? [y]es · [a]lways · [n]o"),
1894 Style::default().fg(Color::Yellow),
1895 ));
1896 } else if app.thinking || app.switching_model || app.is_streaming() {
1897 spans.push(Span::styled(
1898 " (busy — Ctrl+C to cancel)",
1899 Style::default().fg(Color::Yellow),
1900 ));
1901 }
1902
1903 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1904 }
1905
1906 fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
1907 if key.kind != KeyEventKind::Press {
1908 return None;
1909 }
1910
1911 if app.show_model_picker {
1912 match key.code {
1913 KeyCode::Esc => {
1914 app.close_model_picker();
1915 return None;
1916 }
1917 KeyCode::Up => {
1918 app.move_model_picker_up();
1919 return None;
1920 }
1921 KeyCode::Down => {
1922 app.move_model_picker_down();
1923 return None;
1924 }
1925 KeyCode::Enter => {
1926 return Some(format!("/models {}", app.model_picker_index + 1));
1927 }
1928 _ => return None,
1929 }
1930 }
1931
1932 match key.code {
1933 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1934 app.quit = true;
1935 None
1936 }
1937 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1938 app.quit = true;
1939 None
1940 }
1941 KeyCode::Enter => {
1942 if app.input.trim().is_empty() {
1943 return None;
1944 }
1945 let text = app.input.drain(..).collect::<String>();
1946 app.cursor = 0;
1947 Some(text)
1948 }
1949 KeyCode::Backspace => {
1950 if app.cursor > 0 {
1951 app.cursor -= 1;
1952 app.input.remove(app.cursor);
1953 }
1954 None
1955 }
1956 KeyCode::Delete => {
1957 if app.cursor < app.input.len() {
1958 app.input.remove(app.cursor);
1959 }
1960 None
1961 }
1962 KeyCode::Left => {
1963 app.cursor = app.cursor.saturating_sub(1);
1964 None
1965 }
1966 KeyCode::Right => {
1967 if app.cursor < app.input.len() {
1968 app.cursor += 1;
1969 }
1970 None
1971 }
1972 KeyCode::Home => {
1973 app.cursor = 0;
1974 None
1975 }
1976 KeyCode::End => {
1977 app.cursor = app.input.len();
1978 None
1979 }
1980 KeyCode::Char(ch) => {
1981 app.input.insert(app.cursor, ch);
1982 app.cursor += 1;
1983 None
1984 }
1985 _ => None,
1986 }
1987 }
1988
1989 // ── Explicit on-device model loading ──────────────────────────────────────
1990
1991 /// The local model `/load` should bring up: the persisted selection if it
1992 /// still resolves to a known model, otherwise the first on-device (non-cloud)
1993 /// entry in the picker.
1994 fn default_local_model_item(app: &App) -> Option<ModelPickerItem> {
1995 if let Some(selected) = crate::setup::load_selected_model()
1996 && let Some(item) = app.model_picker_items.iter().find(|item| {
1997 item.config.model_id == selected.model_id
1998 && item
1999 .config
2000 .files
2001 .iter()
2002 .any(|file| file == &selected.gguf_file)
2003 })
2004 {
2005 return Some(item.clone());
2006 }
2007 app.model_picker_items
2008 .iter()
2009 .find(|item| item.cloud_tier.is_none())
2010 .cloned()
2011 }
2012
2013 /// Load `model` on-device on a dedicated loader thread, routing inference to a
2014 /// fresh `LocalBackend` and driving the switch-progress UI. The caller is
2015 /// responsible for any cloud-tier handling; this path is on-device only.
2016 fn start_local_model_load<B: ratatui::backend::Backend>(
2017 app: &mut App,
2018 model: ModelPickerItem,
2019 engine: Arc<ChatEngine>,
2020 terminal: &mut ratatui::Terminal<B>,
2021 ) {
2022 if model.cache_health == ModelCacheHealth::Incomplete {
2023 app.messages.push(ChatMessage::system(format!(
2024 "error: {} has an incomplete local cache and cannot be selected yet.",
2025 model.display_name
2026 )));
2027 return;
2028 }
2029
2030 // Loading an on-device model puts us in local inference mode.
2031 let _ = crate::settings::set_local_inference(true);
2032
2033 // Route inference on-device; the loader thread below fills the engine the
2034 // LocalBackend reads from.
2035 app.backend = Arc::new(LocalBackend::new(Arc::clone(&engine)));
2036
2037 let loading_msg = if model.cache_health == ModelCacheHealth::NotDownloaded {
2038 format!(
2039 "Downloading and loading {} ({})… this may take a few minutes.",
2040 model.display_name, model.description
2041 )
2042 } else {
2043 format!("Loading {}…", model.display_name)
2044 };
2045
2046 app.messages.push(ChatMessage::system(loading_msg));
2047 terminal.draw(|frame| render(frame, app)).ok();
2048
2049 let (tx, rx) = mpsc::channel(1);
2050 app.model_load_rx = Some(rx);
2051 app.switching_model = true;
2052 app.switching_model_id = Some(model.config.model_id.clone());
2053 // Only show download progress for models not yet cached.
2054 app.download_progress = if model.cache_health == ModelCacheHealth::NotDownloaded {
2055 Some((0, 0))
2056 } else {
2057 None
2058 };
2059
2060 let sampling = SamplingConfig {
2061 max_tokens: Some(model.max_tokens),
2062 ..SamplingConfig::default()
2063 };
2064
2065 // own thread + runtime so block_in_place doesn't starve the TUI loop.
2066 // Fold in project instruction files (AGENTS.md / CLAUDE.md) for the launch
2067 // directory so the on-device model gets the same always-on context the
2068 // cloud and ACP paths get.
2069 let system_prompt = {
2070 let base = crate::system_prompt_for_model(model.tool_calling).to_string();
2071 match std::env::current_dir()
2072 .ok()
2073 .and_then(|cwd| crate::instructions::load_project_instructions(&cwd))
2074 {
2075 Some(extra) => format!("{base}\n\n{extra}"),
2076 None => base,
2077 }
2078 };
2079 let engine_handle = Arc::clone(&engine);
2080 let tool_calling = model.tool_calling;
2081 std::thread::spawn(move || {
2082 let rt = tokio::runtime::Runtime::new().expect("failed to create model-loader runtime");
2083 let update = rt.block_on(async move {
2084 match engine_handle
2085 .load_gguf_model(
2086 model.config.clone(),
2087 Some(system_prompt.to_string()),
2088 Some(sampling),
2089 )
2090 .await
2091 {
2092 Ok(_) => ModelLoadUpdate::Loaded(model.display_name.clone()),
2093 Err(err) => ModelLoadUpdate::Error(err.to_string()),
2094 }
2095 });
2096 // capacity-1 channel, receiver alive while switching
2097 let _ = tx.blocking_send(update);
2098 });
2099 // applied on ModelLoadUpdate::Loaded
2100 app.pending_tool_calling = Some(tool_calling);
2101 }
2102
2103 // ── Slash command execution ───────────────────────────────────────────────
2104
2105 async fn exec_slash<B: ratatui::backend::Backend>(
2106 app: &mut App,
2107 cmd: SlashCommand,
2108 engine: Arc<ChatEngine>,
2109 terminal: &mut ratatui::Terminal<B>,
2110 ) {
2111 match cmd {
2112 SlashCommand::Help => {
2113 app.messages.push(ChatMessage::system(
2114 "/help — show this message\n\
2115 /models — open the model picker\n\
2116 /models N — switch to model N\n\
2117 /local [on|off]— toggle on-device inference mode\n\
2118 /skills — list available Agent Skills\n\
2119 /mcp — list MCP servers and their tools\n\
2120 /load — load the selected on-device model\n\
2121 /login E P — sign in to siGit Code Cloud\n\
2122 /logout — sign out\n\
2123 /whoami — show the signed-in account\n\
2124 /plan [on|off] — plan mode: research only, no edits or commands\n\
2125 /permissions — show the tool permission policy\n\
2126 /thinking [on|off] — expand or collapse model reasoning\n\
2127 /tools [on|off]— expand or collapse tool-call details\n\
2128 /compact — summarize and shrink conversation history\n\
2129 /resume — restore the saved session from disk\n\
2130 /clear — wipe conversation history\n\
2131 /status — show engine status\n\
2132 /exit — quit chat",
2133 ));
2134 }
2135 SlashCommand::Clear => {
2136 let cleared = engine.clear_history().await;
2137 app.messages.clear();
2138 crate::permissions::reset_session(crate::permissions::TUI_SESSION);
2139 // The saved session must not resurrect what the user just wiped.
2140 crate::session_store::delete(TUI_STORE_SESSION);
2141 app.messages.push(ChatMessage::system(format!(
2142 "Cleared {cleared} turn(s). History is empty.",
2143 )));
2144 }
2145 SlashCommand::Compact => {
2146 let before = crate::backend::estimate_tokens(&app.backend.history_snapshot().await);
2147 match app
2148 .backend
2149 .compact_history(crate::backend::COMPACT_KEEP_LAST)
2150 .await
2151 {
2152 Ok(()) => {
2153 let snapshot = app.backend.history_snapshot().await;
2154 let after = crate::backend::estimate_tokens(&snapshot);
2155 // Keep the saved session in step with the compacted state.
2156 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot)
2157 {
2158 log::warn!("session save after /compact failed: {error}");
2159 }
2160 app.messages.push(ChatMessage::system(format!(
2161 "Compacted history: ~{before} → ~{after} tokens (estimated)."
2162 )));
2163 }
2164 Err(error) => {
2165 app.messages
2166 .push(ChatMessage::system(format!("Compaction failed: {error}")));
2167 }
2168 }
2169 }
2170 SlashCommand::Resume => match crate::session_store::load(TUI_STORE_SESSION) {
2171 Some(history) if !history.is_empty() => {
2172 let restored = history.len();
2173 app.backend.restore_history(history).await;
2174 app.messages.push(ChatMessage::system(format!(
2175 "Restored {restored} message(s) from the saved session. \
2176 The model remembers the conversation; the scrollback above does not \
2177 replay it."
2178 )));
2179 }
2180 _ => {
2181 app.messages.push(ChatMessage::system(
2182 "No saved session to resume. Sessions are saved after each turn.",
2183 ));
2184 }
2185 },
2186 SlashCommand::Plan(value) => {
2187 use crate::permissions::{self, TUI_SESSION};
2188 let enabled = value.unwrap_or_else(|| !permissions::plan_mode(TUI_SESSION));
2189 permissions::set_plan_mode(TUI_SESSION, enabled);
2190 app.messages.push(ChatMessage::system(if enabled {
2191 "Plan mode ON — research with read-only tools only; edits and commands \
2192 are blocked until /plan off."
2193 } else {
2194 "Plan mode OFF — tools may execute again (subject to the permission \
2195 policy)."
2196 }));
2197 }
2198 SlashCommand::Permissions => {
2199 app.messages
2200 .push(ChatMessage::system(crate::permissions::describe(
2201 crate::permissions::TUI_SESSION,
2202 )));
2203 }
2204 SlashCommand::Thinking(value) => {
2205 app.show_thinking = value.unwrap_or(!app.show_thinking);
2206 app.messages.push(ChatMessage::system(if app.show_thinking {
2207 "Thinking display ON — model reasoning is shown above each reply."
2208 } else {
2209 "Thinking display OFF — replies show a one-line indicator; \
2210 /thinking to expand."
2211 }));
2212 }
2213 SlashCommand::Tools(value) => {
2214 app.tools_expanded = value.unwrap_or(!app.tools_expanded);
2215 app.messages
2216 .push(ChatMessage::system(if app.tools_expanded {
2217 "Tool calls expanded — /tools off to collapse them."
2218 } else {
2219 "Tool calls collapsed — /tools on to expand them."
2220 }));
2221 }
2222 SlashCommand::Status => {
2223 let info = engine.as_ref().info().await;
2224 let model = info.model_name.as_deref().unwrap_or("(none)");
2225 let mem = info.approx_memory.as_deref().unwrap_or("unknown");
2226 app.messages.push(ChatMessage::system(format!(
2227 "status: {:?} model: {} memory: {} history: {} turns",
2228 info.status, model, mem, info.history_length,
2229 )));
2230 }
2231 SlashCommand::Skills => {
2232 app.messages
2233 .push(ChatMessage::system(crate::skills::format_skills_list()));
2234 }
2235 SlashCommand::Mcp => {
2236 app.messages
2237 .push(ChatMessage::system(crate::mcp::status_summary()));
2238 }
2239 SlashCommand::Models(selection) => match selection {
2240 None => {
2241 app.open_model_picker(&engine);
2242 }
2243 Some(n) => {
2244 let idx = n.saturating_sub(1);
2245 match app.model_picker_items.get(idx).cloned() {
2246 None => {
2247 app.messages.push(ChatMessage::system(format!(
2248 "error: no model #{n} — type /models to see the list."
2249 )));
2250 }
2251 Some(model) => {
2252 // ── siGit Code Cloud tier: no local load; sign-in gated ──
2253 if let Some(tier) = model.cloud_tier.clone() {
2254 app.close_model_picker();
2255 match crate::provider::cloud_tier_provider(&tier) {
2256 Some(provider) => {
2257 let system_prompt =
2258 crate::system_prompt_for_model(true).to_string();
2259 app.backend = Arc::new(OpenAiBackend::new(
2260 provider.base_url,
2261 provider.api_key,
2262 provider.model,
2263 Some(system_prompt),
2264 ));
2265 app.current_model_name = provider.display_name.clone();
2266 app.tool_calling = true;
2267 // Selecting a cloud tier puts us in cloud mode.
2268 let _ = crate::settings::set_local_inference(false);
2269 app.messages.push(ChatMessage::system(format!(
2270 "Switched to {}.",
2271 provider.display_name
2272 )));
2273 }
2274 None => {
2275 app.messages.push(ChatMessage::system(
2276 "siGit Code Cloud needs an account. Use \
2277 `/login <email> <password>`, or create one at sigit.si.",
2278 ));
2279 }
2280 }
2281 return;
2282 }
2283
2284 app.close_model_picker();
2285 start_local_model_load(app, model, Arc::clone(&engine), terminal);
2286 }
2287 }
2288 }
2289 },
2290 SlashCommand::Local(value) => {
2291 let enabled = value.unwrap_or(!crate::settings::local_inference_enabled());
2292 match crate::settings::set_local_inference(enabled) {
2293 Ok(()) => {
2294 let state = if enabled { "on" } else { "off" };
2295 let hint = if enabled {
2296 "On-device models are highlighted. Type /models to pick one."
2297 } else {
2298 "siGit Code Cloud tiers are highlighted. Type /models to pick one."
2299 };
2300 app.messages.push(ChatMessage::system(format!(
2301 "Local inference is {state}. {hint}"
2302 )));
2303 // Refresh the picker so emphasis/order reflects the new mode.
2304 if app.show_model_picker {
2305 app.open_model_picker(&engine);
2306 }
2307 }
2308 Err(error) => {
2309 app.messages.push(ChatMessage::system(format!(
2310 "error: could not save local inference setting: {error}"
2311 )));
2312 }
2313 }
2314 }
2315 SlashCommand::Load => match default_local_model_item(app) {
2316 None => {
2317 app.messages.push(ChatMessage::system(
2318 "No local model available to load. Use /models to see the list.",
2319 ));
2320 }
2321 Some(model) => {
2322 start_local_model_load(app, model, Arc::clone(&engine), terminal);
2323 }
2324 },
2325 SlashCommand::Login(arg) => {
2326 let message = match arg.as_deref().and_then(crate::account::parse_login_args) {
2327 Some((email, password)) => {
2328 match crate::account::authenticate(&email, &password).await {
2329 Ok(email) => format!(
2330 "Signed in as {email}. siGit Code Cloud applies to your next session."
2331 ),
2332 Err(error) => format!("Login failed: {error}"),
2333 }
2334 }
2335 None => "usage: /login <email> <password>".to_string(),
2336 };
2337 app.messages.push(ChatMessage::system(message));
2338 }
2339 SlashCommand::Logout => {
2340 let message = crate::account::end_session().await;
2341 app.messages.push(ChatMessage::system(message));
2342 }
2343 SlashCommand::Whoami => {
2344 let message = crate::account::status_line().await;
2345 app.messages.push(ChatMessage::system(message));
2346 }
2347 SlashCommand::Exit => {
2348 app.quit = true;
2349 }
2350 SlashCommand::Unknown(cmd) => {
2351 app.messages
2352 .push(ChatMessage::system(format!("unknown command: {cmd}")));
2353 }
2354 }
2355 }
2356
2357 // ── Background inference task ─────────────────────────────────────────────
2358
2359 /// cap tool rounds so a confused model can't loop forever; auto-compaction
2360 /// keeps long runs inside the context window, so the cap can be generous
2361 const MAX_TOOL_ROUNDS: usize = 24;
2362
2363 /// The TUI is a single conversation, so it persists under one fixed
2364 /// session-store id (ACP sessions use their protocol-assigned ids).
2365 const TUI_STORE_SESSION: &str = "tui";
2366
2367 fn build_tool_specs() -> Vec<ToolSpec> {
2368 let mut specs: Vec<ToolSpec> = crate::tools::all_tools()
2369 .into_iter()
2370 .map(|t| ToolSpec {
2371 name: t.name.to_string(),
2372 description: t.description.to_string(),
2373 parameters_schema: t.parameters_schema.to_string(),
2374 })
2375 .collect();
2376
2377 // Advertise the Agent Skills `skill` tool only when skills exist on disk
2378 // (https://agentskills.io). The tool description carries the discovery
2379 // list (name + description) for progressive disclosure.
2380 let discovered = crate::skills::discover_skills();
2381 if !discovered.is_empty() {
2382 specs.push(ToolSpec {
2383 name: crate::skills::SKILL_TOOL_NAME.to_string(),
2384 description: crate::skills::skill_tool_description(&discovered),
2385 parameters_schema: crate::skills::skill_tool_schema().to_string(),
2386 });
2387 }
2388
2389 // Delegated research (`task`) is offered only when a subagent backend
2390 // can actually be built — same conditional pattern as `skill` above.
2391 if crate::tools::subagent_available() {
2392 specs.push(crate::tools::task_tool_spec());
2393 }
2394
2395 // Tools discovered from configured MCP servers (incl. the official one).
2396 specs.extend(crate::mcp::tool_specs());
2397
2398 specs
2399 }
2400
2401 /// Close out a cancelled round in backend history: the results of tools
2402 /// that already ran this round, plus cancellation notes for `unreached`
2403 /// calls. Leaving a round's tool calls unanswered breaks strict
2404 /// OpenAI-compatible endpoints on the session's next request.
2405 async fn abandon_round(
2406 backend: &dyn InferenceBackend,
2407 mut tool_results: Vec<ToolResult>,
2408 unreached: &[crate::backend::ToolCall],
2409 ) {
2410 for pending in unreached {
2411 tool_results.push(ToolResult {
2412 tool_call_id: pending.id.clone(),
2413 content: format!(
2414 "`{}` was not executed: the user cancelled the turn.",
2415 pending.name
2416 ),
2417 });
2418 }
2419 backend.record_cancelled_tool_results(tool_results).await;
2420 }
2421
2422 /// run the tool-calling loop off the main thread, posting updates via `tx`.
2423 /// dropping `tx` signals completion to the event loop.
2424 async fn run_inference_task(
2425 backend: Arc<dyn InferenceBackend>,
2426 text: String,
2427 tx: mpsc::Sender<InferenceUpdate>,
2428 tools_enabled: bool,
2429 ) {
2430 let tools = if tools_enabled {
2431 build_tool_specs()
2432 } else {
2433 vec![]
2434 };
2435
2436 // Bridge the backend's token sink (plain strings) onto the UI update
2437 // channel as `Delta` messages. The forwarder lives for the whole turn.
2438 let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
2439 let forward_tx = tx.clone();
2440 let forwarder = tokio::spawn(async move {
2441 while let Some(piece) = delta_rx.recv().await {
2442 if forward_tx
2443 .send(InferenceUpdate::Delta(piece))
2444 .await
2445 .is_err()
2446 {
2447 break;
2448 }
2449 }
2450 });
2451
2452 // The first round offers tools, so on-device inference can't stream it
2453 // (it must buffer to detect tool calls). With tools disabled there are
2454 // none to offer, so it streams directly.
2455 let first_sink = if tools.is_empty() {
2456 Some(&delta_tx)
2457 } else {
2458 None
2459 };
2460 let mut streamed = first_sink.is_some();
2461
2462 let mut result = match backend
2463 .send_message_with_tools(&text, &tools, first_sink)
2464 .await
2465 {
2466 Ok(r) => r,
2467 Err(err) => {
2468 let _ = tx.send(InferenceUpdate::Error(err)).await;
2469 return;
2470 }
2471 };
2472
2473 let mut round = 0;
2474
2475 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
2476 // any tool call means the first round didn't produce a final answer
2477 streamed = false;
2478 round += 1;
2479 log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
2480
2481 // Auto-compaction: long tool runs grow history fast; fold it into
2482 // a summary before the next round rather than blowing the window.
2483 let estimate = crate::backend::estimate_tokens(&backend.history_snapshot().await);
2484 if estimate > crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
2485 log::info!(
2486 "history ≈{estimate} tokens exceeds budget {} — compacting",
2487 crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET
2488 );
2489 match backend
2490 .compact_history(crate::backend::COMPACT_KEEP_LAST)
2491 .await
2492 {
2493 Ok(()) => {
2494 let after =
2495 crate::backend::estimate_tokens(&backend.history_snapshot().await);
2496 log::info!("compacted history to ≈{after} tokens");
2497 }
2498 Err(error) => log::warn!("history compaction failed: {error}"),
2499 }
2500 }
2501
2502 let mut tool_results = Vec::new();
2503
2504 for (call_index, tc) in result.tool_calls.iter().enumerate() {
2505 // The UI drops the receiver on Ctrl+C or quit. Stop the turn
2506 // at the next boundary instead of burning model rounds (and
2507 // possibly running granted tools) in the background.
2508 if tx.is_closed() {
2509 log::info!("turn cancelled by the user — stopping the tool loop");
2510 abandon_round(&*backend, tool_results, &result.tool_calls[call_index..]).await;
2511 return;
2512 }
2513
2514 log::info!(
2515 " → {}({})",
2516 tc.name,
2517 tc.arguments.chars().take(120).collect::<String>()
2518 );
2519
2520 let _ = tx
2521 .send(InferenceUpdate::ToolUse {
2522 name: tc.name.clone(),
2523 arguments: tc.arguments.clone(),
2524 })
2525 .await;
2526
2527 // Permission gate: read-only tools pass straight through; a
2528 // mutating tool consults policy and may pause on the user's
2529 // y/a/n answer (delivered over a oneshot from the event loop).
2530 use crate::permissions::{self, Decision, TUI_SESSION};
2531 let output = match permissions::decision_for(TUI_SESSION, &tc.name, &tc.arguments) {
2532 Decision::Allow => crate::tools::execute_tool(&tc.name, &tc.arguments).await,
2533 Decision::Deny(reason) => {
2534 log::info!(" ✗ {} denied by policy", tc.name);
2535 reason
2536 }
2537 Decision::Ask => {
2538 let (reply_tx, reply_rx) = oneshot::channel();
2539 let _ = tx
2540 .send(InferenceUpdate::ApprovalRequest {
2541 tool: tc.name.clone(),
2542 args: permissions::approval_preview(&tc.arguments),
2543 reply: reply_tx,
2544 })
2545 .await;
2546 match reply_rx.await {
2547 Ok(ApprovalChoice::Once) => {
2548 crate::tools::execute_tool(&tc.name, &tc.arguments).await
2549 }
2550 Ok(ApprovalChoice::Session) => {
2551 permissions::grant_for_session(
2552 TUI_SESSION,
2553 &tc.name,
2554 &tc.arguments,
2555 );
2556 crate::tools::execute_tool(&tc.name, &tc.arguments).await
2557 }
2558 Ok(ApprovalChoice::Deny) => {
2559 log::info!(" ✗ {} denied by user", tc.name);
2560 permissions::user_denial(&tc.name)
2561 }
2562 // The UI dropped the reply channel (Ctrl+C or
2563 // quit): the whole turn is over, not just this
2564 // call. Close out the round and stop instead of
2565 // continuing rounds in the background.
2566 Err(_) => {
2567 log::info!(
2568 "turn cancelled at the approval prompt — stopping the tool loop"
2569 );
2570 abandon_round(
2571 &*backend,
2572 tool_results,
2573 &result.tool_calls[call_index..],
2574 )
2575 .await;
2576 return;
2577 }
2578 }
2579 }
2580 };
2581 log::info!(" ← {} chars", output.len());
2582
2583 // Display-only preview of the output tail; the model gets the
2584 // full output through `tool_results` below.
2585 let _ = tx
2586 .send(InferenceUpdate::ToolDone {
2587 output_preview: super::cap_output_preview(&output),
2588 })
2589 .await;
2590
2591 tool_results.push(ToolResult {
2592 tool_call_id: tc.id.clone(),
2593 content: output,
2594 });
2595 }
2596
2597 // Cancelled while the round's tools ran: record what executed and
2598 // stop before paying for another model round nobody will see.
2599 if tx.is_closed() {
2600 log::info!("turn cancelled by the user — skipping the next model round");
2601 abandon_round(&*backend, tool_results, &[]).await;
2602 return;
2603 }
2604
2605 // on the last round, pass no tools so the model must produce text —
2606 // that's also the round we can stream on-device.
2607 let next_tools = if round < MAX_TOOL_ROUNDS {
2608 Some(tools.as_slice())
2609 } else {
2610 None
2611 };
2612 let sink = if next_tools.is_none() {
2613 streamed = true;
2614 Some(&delta_tx)
2615 } else {
2616 None
2617 };
2618
2619 match backend
2620 .send_tool_results(tool_results, next_tools, sink)
2621 .await
2622 {
2623 Ok(r) => result = r,
2624 Err(err) => {
2625 let _ = tx.send(InferenceUpdate::Error(err)).await;
2626 return;
2627 }
2628 }
2629 }
2630
2631 // Drop the sink so the forwarder finishes draining any buffered tokens
2632 // before we commit the reply.
2633 drop(delta_tx);
2634 let _ = forwarder.await;
2635
2636 if result.tool_calls.is_empty() {
2637 if result.text.is_empty() {
2638 log::warn!(
2639 "model returned empty reply — may have exhausted max_tokens on thinking"
2640 );
2641 let _ = tx
2642 .send(InferenceUpdate::Error(
2643 "(empty response — the model may have used all tokens on internal reasoning. \
2644 Try a shorter or simpler prompt.)"
2645 .to_string(),
2646 ))
2647 .await;
2648 } else if streamed {
2649 // tokens already went out as deltas; just commit the buffer
2650 let _ = tx.send(InferenceUpdate::StreamEnd).await;
2651 } else {
2652 let _ = tx.send(InferenceUpdate::Response(result.text)).await;
2653 }
2654 }
2655
2656 // Persist the completed turn so /resume (or a restart) can pick the
2657 // conversation back up.
2658 let snapshot = backend.history_snapshot().await;
2659 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot) {
2660 log::warn!("session save failed: {error}");
2661 }
2662
2663 log::info!("inference complete — {} tool round(s)", round);
2664 // tx drops here — event loop gets None from rx.recv()
2665 }
2666
2667 // ── Main loop ─────────────────────────────────────────────────────────────
2668
2669 /// entry point — blocks until the user quits.
2670 /// caller owns terminal init/restore. `load_rx` delivers the model-load result
2671 /// from a dedicated OS thread; we poll it non-blocking each tick.
2672 pub async fn run_with<B: ratatui::backend::Backend>(
2673 terminal: &mut ratatui::Terminal<B>,
2674 engine: Arc<ChatEngine>,
2675 backend: Arc<dyn InferenceBackend>,
2676 load_rx: std_mpsc::Receiver<Result<(), String>>,
2677 load_model_name: String,
2678 ) -> Result<()> {
2679 event_loop(terminal, engine, backend, load_rx, load_model_name).await
2680 }
2681
2682 async fn event_loop<B: ratatui::backend::Backend>(
2683 terminal: &mut ratatui::Terminal<B>,
2684 engine: Arc<ChatEngine>,
2685 backend: Arc<dyn InferenceBackend>,
2686 load_rx: std_mpsc::Receiver<Result<(), String>>,
2687 load_model_name: String,
2688 ) -> Result<()> {
2689 let mut app = App::new(load_model_name, backend);
2690 let mut event_stream = EventStream::new();
2691
2692 // 10 fps is plenty for spinners
2693 let mut ticker = interval(Duration::from_millis(100));
2694
2695 loop {
2696 // ── Poll the loader channel (non-blocking) ────────────────────────
2697 if app.is_loading {
2698 match load_rx.try_recv() {
2699 Ok(Ok(())) => app.finish_loading(),
2700 Ok(Err(e)) => app.set_load_error(e),
2701 Err(std_mpsc::TryRecvError::Empty) => {}
2702 Err(std_mpsc::TryRecvError::Disconnected) => {
2703 app.set_load_error("Model loader thread crashed.".to_string());
2704 }
2705 }
2706 }
2707
2708 // redraw every iteration
2709 terminal.draw(|frame| render(frame, &mut app))?;
2710
2711 if let Some(rx) = app.model_load_rx.as_mut() {
2712 match rx.try_recv() {
2713 Ok(ModelLoadUpdate::Loaded(model_name)) => {
2714 engine.clear_history().await;
2715 if let Some(tc) = app.pending_tool_calling.take() {
2716 app.tool_calling = tc;
2717 }
2718 app.switching_model = false;
2719 app.switching_model_id = None;
2720 app.download_progress = None;
2721 app.model_load_cancelled = false;
2722 app.model_load_rx = None;
2723 app.current_model_name = model_name.clone();
2724
2725 let save_result = app
2726 .model_picker_items
2727 .iter()
2728 .find(|item| item.display_name == model_name)
2729 .map(|item| crate::setup::SelectedModel {
2730 model_id: item.config.model_id.clone(),
2731 gguf_file: item
2732 .config
2733 .files
2734 .first()
2735 .cloned()
2736 .unwrap_or_else(String::new),
2737 })
2738 .filter(|selected| !selected.gguf_file.is_empty())
2739 .map(|selected| crate::setup::save_selected_model(&selected))
2740 .unwrap_or_else(|| {
2741 Err(format!(
2742 "could not determine a stable identifier for {}",
2743 model_name
2744 ))
2745 });
2746
2747 if let Err(error) = save_result {
2748 app.messages.push(ChatMessage::system(format!(
2749 "warning: switched to {} but could not save the selection: {}",
2750 model_name, error
2751 )));
2752 } else {
2753 app.messages
2754 .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
2755 }
2756 }
2757 Ok(ModelLoadUpdate::Error(error)) => {
2758 app.switching_model = false;
2759 app.switching_model_id = None;
2760 app.download_progress = None;
2761 app.model_load_cancelled = false;
2762 app.model_load_rx = None;
2763 app.messages
2764 .push(ChatMessage::system(format!("error loading model: {error}")));
2765 }
2766 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
2767 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
2768 let was_cancelled = app.model_load_cancelled;
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 if !was_cancelled {
2775 app.messages.push(ChatMessage::system(
2776 "error loading model: loader task disconnected".to_string(),
2777 ));
2778 }
2779 }
2780 }
2781 }
2782
2783 if app.quit {
2784 break;
2785 }
2786
2787 // multiplex terminal events, streaming tokens, inference updates,
2788 // and the thinking-spinner timer.
2789 tokio::select! {
2790 biased;
2791
2792 // ── Spinner tick (loading phase only) ─────────────────────────
2793 _ = ticker.tick(), if app.is_loading => {
2794 app.tick();
2795 }
2796
2797 // ── inference updates from background task ───────────────────
2798 update = async {
2799 match app.inference_rx.as_mut() {
2800 Some(rx) => rx.recv().await,
2801 None => pending().await,
2802 }
2803 } => {
2804 match update {
2805 Some(InferenceUpdate::ToolUse { name, arguments }) => {
2806 let title = super::tool_title(&name, &arguments);
2807 let detail = super::pretty_tool_arguments(&arguments);
2808 app.messages.push(ChatMessage::tool_call(title, detail));
2809 }
2810 Some(InferenceUpdate::ToolDone { output_preview }) => {
2811 // Attach to the most recent tool entry still
2812 // waiting for its result.
2813 if let Some(entry) = app
2814 .messages
2815 .iter_mut()
2816 .rev()
2817 .find(|m| m.role == Role::Tool && m.tool_result.is_none())
2818 {
2819 entry.tool_result = Some(output_preview);
2820 }
2821 }
2822 Some(InferenceUpdate::Delta(delta)) => {
2823 app.push_stream_delta(&delta);
2824 }
2825 Some(InferenceUpdate::StreamEnd) => {
2826 app.finalize_stream();
2827 }
2828 Some(InferenceUpdate::Response(text)) => {
2829 app.stop_thinking();
2830 app.messages.push(ChatMessage::assistant(text));
2831 }
2832 Some(InferenceUpdate::Error(msg)) => {
2833 app.finalize_stream();
2834 app.stop_thinking();
2835 app.messages.push(ChatMessage::system(format!("error: {msg}")));
2836 }
2837 Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
2838 // The y/a/n prompt lives on the Session tab; make
2839 // sure the user can see what they're answering.
2840 app.active_tab = Tab::Session;
2841 let call = if args.is_empty() {
2842 tool.clone()
2843 } else {
2844 format!("{tool}({args})")
2845 };
2846 app.messages.push(ChatMessage::system(format!(
2847 "⚠ permission — allow {call}? [y]es · [a]lways this session · [n]o"
2848 )));
2849 app.pending_approval = Some((tool, reply));
2850 }
2851 None => {
2852 // task finished, possibly with no text to show
2853 app.finalize_stream();
2854 app.stop_thinking();
2855 }
2856 }
2857 }
2858
2859 // ── Cloud tab status fetch resolving ─────────────────────────
2860 status = async {
2861 match app.cloud_rx.as_mut() {
2862 Some(rx) => rx.await,
2863 None => pending().await,
2864 }
2865 } => {
2866 app.cloud_rx = None;
2867 app.cloud_lines = Some(status.unwrap_or_else(|_| {
2868 vec!["error: the status fetch task died — press r to retry".to_string()]
2869 }));
2870 }
2871
2872 // ── thinking / switching spinner tick (100ms) ────────────────
2873 _ = async {
2874 if app.thinking || app.switching_model {
2875 tokio::time::sleep(Duration::from_millis(100)).await
2876 } else {
2877 pending().await
2878 }
2879 } => {
2880 app.tick_thinking();
2881 // keep the progress display fresh
2882 if app.switching_model {
2883 app.poll_download_progress();
2884 }
2885 }
2886
2887 // ── Terminal events ───────────────────────────────────────────
2888 maybe_event = event_stream.next() => {
2889 let Some(Ok(event)) = maybe_event else {
2890 break;
2891 };
2892
2893 if let Event::Key(key) = event {
2894 // loading phase — only quit keys work
2895 if app.is_loading {
2896 if key.kind == KeyEventKind::Press {
2897 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
2898 if ctrl
2899 && (key.code == KeyCode::Char('c')
2900 || key.code == KeyCode::Char('d'))
2901 {
2902 app.quit = true;
2903 }
2904 }
2905 continue;
2906 }
2907
2908 // pending tool approval — y/a/n answer the prompt; the
2909 // inference task is paused on the reply channel. Checked
2910 // before the busy gate because the app *is* busy here.
2911 if app.pending_approval.is_some() {
2912 if key.kind == KeyEventKind::Press {
2913 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
2914 let choice = if ctrl
2915 && (key.code == KeyCode::Char('c')
2916 || key.code == KeyCode::Char('d'))
2917 {
2918 // cancel the whole turn: denying is implicit
2919 // in dropping the reply channel
2920 app.pending_approval = None;
2921 app.stop_thinking();
2922 app.messages.push(ChatMessage::system("(cancelled)"));
2923 continue;
2924 } else {
2925 match key.code {
2926 KeyCode::Char('y') | KeyCode::Char('Y') => {
2927 Some(ApprovalChoice::Once)
2928 }
2929 KeyCode::Char('a') | KeyCode::Char('A') => {
2930 Some(ApprovalChoice::Session)
2931 }
2932 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
2933 Some(ApprovalChoice::Deny)
2934 }
2935 _ => None,
2936 }
2937 };
2938 if let Some(choice) = choice
2939 && let Some((tool, reply)) = app.pending_approval.take()
2940 {
2941 let verdict = match &choice {
2942 ApprovalChoice::Once => "allowed once",
2943 ApprovalChoice::Session => "allowed for this session",
2944 ApprovalChoice::Deny => "denied",
2945 };
2946 app.messages.push(ChatMessage::system(format!(
2947 "{tool}: {verdict}"
2948 )));
2949 let _ = reply.send(choice);
2950 }
2951 }
2952 continue;
2953 }
2954
2955 // ── Tab-bar navigation ────────────────────────────────
2956 // Handled before the busy gate so the user can look at
2957 // History/Cloud while inference runs (updates keep
2958 // landing in the Session tab's message list). The Tab
2959 // key only cycles when the input buffer is empty, so
2960 // pasted text containing tabs can't fight it; on
2961 // non-Session tabs the input is hidden, so it always
2962 // cycles there.
2963 if key.kind == KeyEventKind::Press && !app.show_model_picker {
2964 if key.code == KeyCode::Tab
2965 && (app.active_tab != Tab::Session || app.input.is_empty())
2966 {
2967 let next = app.active_tab.next();
2968 switch_tab(&mut app, next, &engine);
2969 continue;
2970 }
2971 if app.active_tab != Tab::Session && key.code == KeyCode::Esc {
2972 app.active_tab = Tab::Session;
2973 continue;
2974 }
2975 }
2976
2977 // busy — only cancel keys work
2978 if app.is_busy() {
2979 if key.kind == KeyEventKind::Press {
2980 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
2981 if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
2982 if app.is_streaming() {
2983 app.finalize_stream();
2984 app.messages.push(ChatMessage::system("(cancelled)"));
2985 }
2986 if app.thinking {
2987 // dropping rx kills the background task
2988 app.stop_thinking();
2989 app.messages.push(ChatMessage::system("(cancelled)"));
2990 }
2991 if app.switching_model {
2992 // flag before drop so Disconnected handler stays quiet
2993 app.model_load_cancelled = true;
2994 app.switching_model = false;
2995 app.switching_model_id = None;
2996 app.download_progress = None;
2997 app.model_load_rx = None;
2998 app.messages
2999 .push(ChatMessage::system("(download cancelled — model switch aborted)"));
3000 }
3001 }
3002 }
3003 continue;
3004 }
3005
3006 // History / Cloud tabs have their own key handling; the
3007 // chat input is inactive there.
3008 if app.active_tab != Tab::Session {
3009 if key.kind == KeyEventKind::Press {
3010 handle_tab_key(&mut app, key, &engine).await;
3011 }
3012 continue;
3013 }
3014
3015 if let Some(text) = handle_key(&mut app, key) {
3016 if let Some(cmd) = parse_slash(&text) {
3017 exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await;
3018 continue;
3019 }
3020
3021 // On-device inference needs a model in memory, and we
3022 // never load one implicitly: the user loads it with
3023 // /load (or /models). Refuse rather than erroring out
3024 // deep in the backend.
3025 if !app.backend.is_remote()
3026 && engine.info().await.status == onde::inference::EngineStatus::Unloaded
3027 {
3028 app.messages.push(ChatMessage::user(&text));
3029 app.messages.push(ChatMessage::system(
3030 "No on-device model is loaded. Run /load to load the selected \
3031 model, or /models to choose one.",
3032 ));
3033 continue;
3034 }
3035
3036 // ── spawn inference ──────────────────────────────
3037 app.messages.push(ChatMessage::user(&text));
3038 app.start_thinking();
3039
3040 let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
3041 app.inference_rx = Some(rx);
3042
3043 let backend_handle = Arc::clone(&app.backend);
3044 let user_text = text.clone();
3045 let tools_enabled = app.tool_calling;
3046 tokio::spawn(async move {
3047 run_inference_task(backend_handle, user_text, tx, tools_enabled).await;
3048 });
3049 }
3050 }
3051 }
3052 }
3053 }
3054
3055 Ok(())
3056 }
3057
3058 // ── Download progress helpers (TUI) ──────────────────────────────────────
3059
3060 /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks)
3061 fn dir_size_recursive(path: &std::path::Path) -> u64 {
3062 let mut total: u64 = 0;
3063 let Ok(entries) = std::fs::read_dir(path) else {
3064 return 0;
3065 };
3066 for entry in entries.flatten() {
3067 let entry_path = entry.path();
3068 if entry_path.is_dir() {
3069 total += dir_size_recursive(&entry_path);
3070 } else if let Ok(meta) = entry_path.metadata() {
3071 total += meta.len();
3072 }
3073 }
3074 total
3075 }
3076
3077 fn format_size_human(bytes: u64) -> String {
3078 const GB: u64 = 1_073_741_824;
3079 const MB: u64 = 1_048_576;
3080 const KB: u64 = 1_024;
3081 if bytes >= GB {
3082 format!("{:.2} GB", bytes as f64 / GB as f64)
3083 } else if bytes >= MB {
3084 format!("{:.1} MB", bytes as f64 / MB as f64)
3085 } else if bytes >= KB {
3086 format!("{:.0} KB", bytes as f64 / KB as f64)
3087 } else {
3088 format!("{bytes} B")
3089 }
3090 }
3091 } // end #[cfg(unix)] mod tui
3092
3093 // re-export so callers write `chat::run_with(...)` on all platforms
3094 #[cfg(unix)]
3095 pub use tui::run_with;
3096
3097 // ── Tests (platform-agnostic) ─────────────────────────────────────────────────
3098
3099 #[cfg(test)]
3100 mod tests {
3101 use std::time::Duration;
3102
3103 use super::{
3104 TOOL_RESULT_PREVIEW_MAX, Tab, cap_output_preview, format_age, history_row,
3105 parse_rich_text_segments, pretty_tool_arguments, streaming_think_tail, strip_think_blocks,
3106 think_indicator, tool_entry_lines, tool_title,
3107 };
3108
3109 // ── tool_title ────────────────────────────────────────────────────────────
3110
3111 #[test]
3112 fn tool_title_run_command_shows_the_command() {
3113 assert_eq!(
3114 tool_title("run_command", r#"{"command":"cargo test"}"#),
3115 "run_command · cargo test"
3116 );
3117 }
3118
3119 #[test]
3120 fn tool_title_file_tools_show_the_path() {
3121 for name in [
3122 "read_file",
3123 "create_file",
3124 "edit_file",
3125 "multi_edit",
3126 "delete_file",
3127 ] {
3128 assert_eq!(
3129 tool_title(name, r#"{"path":"src/main.rs","old_text":"a"}"#),
3130 format!("{name} · src/main.rs")
3131 );
3132 }
3133 }
3134
3135 #[test]
3136 fn tool_title_pattern_tools_show_the_pattern() {
3137 assert_eq!(
3138 tool_title("search_files", r#"{"pattern":"fn main","path":"src"}"#),
3139 "search_files · fn main"
3140 );
3141 assert_eq!(
3142 tool_title("glob", r#"{"pattern":"**/*.rs"}"#),
3143 "glob · **/*.rs"
3144 );
3145 }
3146
3147 #[test]
3148 fn tool_title_mcp_tools_show_server_and_bare_tool_name() {
3149 assert_eq!(
3150 tool_title("mcp__sigit__list_issues", r#"{"repo":"getsigit/sigit"}"#),
3151 "sigit · list_issues"
3152 );
3153 }
3154
3155 #[test]
3156 fn tool_title_unknown_tool_is_the_name_alone() {
3157 assert_eq!(
3158 tool_title("write_todos", r#"{"todos":[{"text":"x"}]}"#),
3159 "write_todos"
3160 );
3161 }
3162
3163 #[test]
3164 fn tool_title_malformed_json_falls_back_to_the_name() {
3165 assert_eq!(tool_title("run_command", "{not json"), "run_command");
3166 assert_eq!(tool_title("edit_file", ""), "edit_file");
3167 }
3168
3169 #[test]
3170 fn tool_title_missing_or_non_string_arg_falls_back_to_the_name() {
3171 assert_eq!(tool_title("run_command", r#"{"other":"x"}"#), "run_command");
3172 assert_eq!(
3173 tool_title("run_command", r#"{"command":42}"#),
3174 "run_command"
3175 );
3176 }
3177
3178 #[test]
3179 fn tool_title_truncates_long_summaries_with_an_ellipsis() {
3180 let long = "a".repeat(100);
3181 let title = tool_title("run_command", &format!(r#"{{"command":"{long}"}}"#));
3182
3183 let summary = title.strip_prefix("run_command · ").expect("summary");
3184 assert_eq!(summary.chars().count(), 60);
3185 assert!(summary.ends_with('…'));
3186 }
3187
3188 #[test]
3189 fn tool_title_collapses_multiline_commands_to_one_line() {
3190 assert_eq!(
3191 tool_title("run_command", "{\"command\":\"echo a &&\\n echo b\"}"),
3192 "run_command · echo a && echo b"
3193 );
3194 }
3195
3196 // ── cap_output_preview ────────────────────────────────────────────────────
3197
3198 #[test]
3199 fn cap_output_preview_passes_short_output_through() {
3200 assert_eq!(cap_output_preview("hello\nworld"), "hello\nworld");
3201 }
3202
3203 #[test]
3204 fn cap_output_preview_keeps_the_tail_and_notes_the_truncation() {
3205 let output = format!("{}{}", "x".repeat(3000), "the-very-end");
3206 let preview = cap_output_preview(&output);
3207
3208 assert!(preview.starts_with("… (truncated — showing the last 2000 of 3012 chars)\n"));
3209 assert!(preview.ends_with("the-very-end"));
3210 let (_note, tail) = preview.split_once('\n').expect("note line");
3211 assert_eq!(tail.chars().count(), TOOL_RESULT_PREVIEW_MAX);
3212 }
3213
3214 // ── pretty_tool_arguments ─────────────────────────────────────────────────
3215
3216 #[test]
3217 fn pretty_tool_arguments_pretty_prints_valid_json() {
3218 assert_eq!(
3219 pretty_tool_arguments(r#"{"command":"ls"}"#),
3220 "{\n \"command\": \"ls\"\n}"
3221 );
3222 }
3223
3224 #[test]
3225 fn pretty_tool_arguments_shows_malformed_json_raw() {
3226 assert_eq!(pretty_tool_arguments("{oops"), "{oops");
3227 }
3228
3229 // ── tool_entry_lines ──────────────────────────────────────────────────────
3230
3231 #[test]
3232 fn tool_entry_lines_collapsed_is_a_single_title_line() {
3233 let lines = tool_entry_lines(
3234 "run_command · cargo test",
3235 "{\n \"command\": \"cargo test\"\n}",
3236 Some("ok"),
3237 false,
3238 );
3239 assert_eq!(lines, vec!["▸ 🔧 run_command · cargo test"]);
3240 }
3241
3242 #[test]
3243 fn tool_entry_lines_expanded_shows_detail_and_result() {
3244 let lines = tool_entry_lines(
3245 "run_command · cargo test",
3246 "{\n \"command\": \"cargo test\"\n}",
3247 Some("test ok\ndone"),
3248 true,
3249 );
3250 assert_eq!(
3251 lines,
3252 vec![
3253 "▾ 🔧 run_command · cargo test",
3254 " {",
3255 " \"command\": \"cargo test\"",
3256 " }",
3257 " result:",
3258 " test ok",
3259 " done",
3260 ]
3261 );
3262 }
3263
3264 #[test]
3265 fn tool_entry_lines_expanded_without_result_omits_the_result_block() {
3266 let lines = tool_entry_lines("glob · **/*.rs", "{}", None, true);
3267 assert_eq!(lines, vec!["▾ 🔧 glob · **/*.rs", " {}"]);
3268 }
3269
3270 // ── Tab / format_age / history_row ────────────────────────────────────────
3271
3272 #[test]
3273 fn tab_next_cycles_session_history_cloud() {
3274 assert_eq!(Tab::Session.next(), Tab::History);
3275 assert_eq!(Tab::History.next(), Tab::Cloud);
3276 assert_eq!(Tab::Cloud.next(), Tab::Session);
3277 // Three hops return to the start, matching the tab bar's order.
3278 assert_eq!(Tab::Session.next().next().next(), Tab::Session);
3279 }
3280
3281 #[test]
3282 fn tab_index_matches_titles_order() {
3283 assert_eq!(Tab::TITLES[Tab::Session.index()], "Session");
3284 assert_eq!(Tab::TITLES[Tab::History.index()], "History");
3285 assert_eq!(Tab::TITLES[Tab::Cloud.index()], "Cloud");
3286 }
3287
3288 #[test]
3289 fn format_age_picks_the_coarsest_sensible_unit() {
3290 assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
3291 assert_eq!(format_age(Duration::from_secs(59)), "59s ago");
3292 assert_eq!(format_age(Duration::from_secs(60)), "1m ago");
3293 assert_eq!(format_age(Duration::from_secs(3_599)), "59m ago");
3294 assert_eq!(format_age(Duration::from_secs(3_600)), "1h ago");
3295 assert_eq!(format_age(Duration::from_secs(86_399)), "23h ago");
3296 assert_eq!(format_age(Duration::from_secs(86_400)), "1d ago");
3297 assert_eq!(format_age(Duration::from_secs(3 * 86_400)), "3d ago");
3298 }
3299
3300 #[test]
3301 fn history_row_formats_id_age_and_count() {
3302 assert_eq!(
3303 history_row("tui", Some(Duration::from_secs(120)), 7),
3304 "tui · 2m ago · 7 message(s)"
3305 );
3306 assert_eq!(
3307 history_row("sess-1", None, 0),
3308 "sess-1 · age unknown · 0 message(s)"
3309 );
3310 }
3311
3312 #[test]
3313 fn strip_think_blocks_separates_thinking_and_visible_reply() {
3314 let raw = "<think>I should inspect the code first.</think>Here is the fix.";
3315 let (thinking, visible) = strip_think_blocks(raw);
3316
3317 assert_eq!(thinking, "I should inspect the code first.");
3318 assert_eq!(visible, "Here is the fix.");
3319 }
3320
3321 #[test]
3322 fn strip_think_blocks_handles_unclosed_think_block() {
3323 let raw = "<think>I am still reasoning about the bug";
3324 let (thinking, visible) = strip_think_blocks(raw);
3325
3326 assert_eq!(thinking, "I am still reasoning about the bug");
3327 assert_eq!(visible, "");
3328 }
3329
3330 #[test]
3331 fn strip_think_blocks_leaves_plain_text_untouched() {
3332 let raw = "No hidden reasoning here.";
3333 let (thinking, visible) = strip_think_blocks(raw);
3334
3335 assert_eq!(thinking, "");
3336 assert_eq!(visible, "No hidden reasoning here.");
3337 }
3338
3339 #[test]
3340 fn streaming_think_tail_shows_last_lines_of_unclosed_think_buffer() {
3341 // the common mid-stream shape: `<think>` opened, not yet closed
3342 let raw = "<think>First thought.\nSecond thought.\nThird thought.\nFourth thought.";
3343 let tail = streaming_think_tail(raw, 40, 3);
3344
3345 assert_eq!(
3346 tail,
3347 vec!["Second thought.", "Third thought.", "Fourth thought."]
3348 );
3349 }
3350
3351 #[test]
3352 fn streaming_think_tail_wraps_long_lines_at_width() {
3353 let raw = "<think>one two three four five six seven";
3354 let tail = streaming_think_tail(raw, 10, 3);
3355
3356 // wrapped at 10 cols: ["one two", "three four", "five six", "seven"];
3357 // the tail keeps only the last 3
3358 assert_eq!(tail, vec!["three four", "five six", "seven"]);
3359 }
3360
3361 #[test]
3362 fn streaming_think_tail_is_empty_once_visible_text_arrives() {
3363 let raw = "<think>hidden reasoning</think>The answer is 42.";
3364 assert!(streaming_think_tail(raw, 40, 3).is_empty());
3365 }
3366
3367 #[test]
3368 fn streaming_think_tail_is_empty_for_only_think_closed_but_no_visible_yet() {
3369 // closed think, visible not started: still show the reasoning tail
3370 let raw = "<think>done reasoning</think>";
3371 assert_eq!(streaming_think_tail(raw, 40, 3), vec!["done reasoning"]);
3372 }
3373
3374 #[test]
3375 fn streaming_think_tail_is_empty_for_empty_or_plain_buffers() {
3376 assert!(streaming_think_tail("", 40, 3).is_empty());
3377 assert!(streaming_think_tail("plain visible text", 40, 3).is_empty());
3378 assert!(streaming_think_tail("<think>", 40, 3).is_empty());
3379 }
3380
3381 #[test]
3382 fn streaming_think_tail_hard_splits_words_wider_than_the_wrap_column() {
3383 let raw = "<think>abcdefghijkl";
3384 assert_eq!(
3385 streaming_think_tail(raw, 5, 3),
3386 vec!["abcde", "fghij", "kl"]
3387 );
3388 }
3389
3390 #[test]
3391 fn think_indicator_counts_lines_and_pluralizes() {
3392 assert_eq!(
3393 think_indicator("only one line of reasoning"),
3394 "· thought for a bit (1 line) — /thinking to show"
3395 );
3396 assert_eq!(
3397 think_indicator("a\nb\nc"),
3398 "· thought for a bit (3 lines) — /thinking to show"
3399 );
3400 }
3401
3402 #[test]
3403 fn parse_rich_text_segments_marks_bold_runs() {
3404 let segments = parse_rich_text_segments(
3405 "The current weather is **72°F** with **Partly Cloudy** conditions.",
3406 );
3407
3408 assert_eq!(
3409 segments,
3410 vec![
3411 ("The current weather is ".to_string(), false),
3412 ("72°F".to_string(), true),
3413 (" with ".to_string(), false),
3414 ("Partly Cloudy".to_string(), true),
3415 (" conditions.".to_string(), false),
3416 ]
3417 );
3418 }
3419
3420 #[test]
3421 fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
3422 let segments = parse_rich_text_segments("Prefix **bold");
3423
3424 assert_eq!(
3425 segments,
3426 vec![("Prefix ".to_string(), false), ("bold".to_string(), true),]
3427 );
3428 }
3429 }