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