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