1 //! Full-screen terminal chat UI.
2 //!
3 //! Two phases: a loading spinner while the model initializes, then
4 //! interactive chat. Uses `tokio::select!` to multiplex terminal events
5 //! with streaming LLM tokens.
6
7 // ── Think-block stripping ─────────────────────────────────────────────────────
8
9 /// Split out `<think>…</think>` blocks from a model response.
10 ///
11 /// Qwen 3 emits reasoning inside `<think>` tags before the actual answer.
12 /// Returns `(thinking_text, visible_reply)`. Either may be empty.
13 pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) {
14 let mut thinking = String::new();
15 let mut remainder = raw;
16
17 while let Some(start) = remainder.find("<think>") {
18 let before = &remainder[..start];
19 if let Some(end) = remainder[start..].find("</think>") {
20 let block = &remainder[start + 7..start + end];
21 thinking.push_str(block.trim());
22 remainder = &remainder[start + end + 8..];
23 if !before.trim().is_empty() {
24 // rare: text before <think> — keep it visible
25 let mut combined = before.to_string();
26 combined.push_str(remainder);
27 return (thinking, combined.trim().to_string());
28 }
29 } else {
30 // unclosed tag — model probably ran out of tokens
31 thinking.push_str(remainder[start + 7..].trim());
32 remainder = before;
33 break;
34 }
35 }
36
37 (thinking, remainder.trim().to_string())
38 }
39
40 /// The live reasoning tail to show while a streaming buffer holds only
41 /// `<think>` content (no visible reply yet).
42 ///
43 /// Returns the last `max_lines` lines of the in-progress thinking text after
44 /// word-wrapping at `width` columns, so the TUI can render them under the
45 /// "thinking…" label and the user watches the model reason. Returns an empty
46 /// vec once visible text has arrived (the reply streams instead) or when
47 /// there is no thinking text yet (plain spinner).
48 pub(crate) fn streaming_think_tail(raw: &str, width: usize, max_lines: usize) -> Vec<String> {
49 let (thinking, visible) = strip_think_blocks(raw);
50 if thinking.is_empty() || !visible.trim().is_empty() || max_lines == 0 {
51 return Vec::new();
52 }
53
54 let width = width.max(1);
55 let mut wrapped: Vec<String> = Vec::new();
56 for line in thinking.lines() {
57 let mut current = String::new();
58 for word in line.split_whitespace() {
59 // hard-split words wider than the wrap column
60 let mut word = word;
61 while word.chars().count() > width {
62 if !current.is_empty() {
63 wrapped.push(std::mem::take(&mut current));
64 }
65 let head: String = word.chars().take(width).collect();
66 word = &word[head.len()..];
67 wrapped.push(head);
68 }
69 if current.is_empty() {
70 current.push_str(word);
71 } else if current.chars().count() + 1 + word.chars().count() <= width {
72 current.push(' ');
73 current.push_str(word);
74 } else {
75 wrapped.push(std::mem::take(&mut current));
76 current.push_str(word);
77 }
78 }
79 if !current.is_empty() {
80 wrapped.push(current);
81 }
82 }
83
84 let skip = wrapped.len().saturating_sub(max_lines);
85 wrapped.split_off(skip)
86 }
87
88 /// One-line dim indicator shown above a reply whose reasoning is collapsed,
89 /// e.g. `· thought for a bit (12 lines) — /thinking to show`.
90 pub(crate) fn think_indicator(think: &str) -> String {
91 let count = think.lines().count().max(1);
92 let noun = if count == 1 { "line" } else { "lines" };
93 format!("· thought for a bit ({count} {noun}) — /thinking to show")
94 }
95
96 pub(crate) fn parse_rich_text_segments(text: &str) -> Vec<(String, bool)> {
97 let mut segments = Vec::new();
98 let mut current = String::new();
99 let mut chars = text.chars().peekable();
100 let mut bold = false;
101
102 while let Some(ch) = chars.next() {
103 if ch == '*' && chars.peek() == Some(&'*') {
104 chars.next();
105 if !current.is_empty() {
106 segments.push((std::mem::take(&mut current), bold));
107 }
108 bold = !bold;
109 } else {
110 current.push(ch);
111 }
112 }
113
114 if !current.is_empty() {
115 segments.push((current, bold));
116 }
117
118 segments
119 }
120
121 // ── Tool-call display helpers ─────────────────────────────────────────────────
122 //
123 // Pure helpers for the TUI's collapsible tool-call entries. Top-level (not in
124 // `mod tui`) so they are testable on every target; only the Unix TUI consumes
125 // them, hence the non-Unix dead-code allowance.
126
127 /// Max characters for the summary half of a tool-call title line.
128 #[cfg_attr(not(unix), allow(dead_code))]
129 const TOOL_TITLE_SUMMARY_MAX: usize = 60;
130
131 /// Max characters of tool output kept for the expanded-entry result preview.
132 #[cfg_attr(not(unix), allow(dead_code))]
133 const TOOL_RESULT_PREVIEW_MAX: usize = 2000;
134
135 /// Truncate to `max_chars` characters, ending with an ellipsis when cut.
136 #[cfg_attr(not(unix), allow(dead_code))]
137 fn truncate_with_ellipsis(text: &str, max_chars: usize) -> String {
138 if text.chars().count() <= max_chars {
139 return text.to_string();
140 }
141 let mut out: String = text.chars().take(max_chars.saturating_sub(1)).collect();
142 out.push('…');
143 out
144 }
145
146 /// One-line title for a tool call, Claude Code style: the tool name plus the
147 /// argument that best identifies the call (`run_command · cargo test`,
148 /// `edit_file · src/main.rs`, `sigit · list_issues` for MCP tools). Falls back
149 /// to the bare tool name when the arguments are malformed or carry nothing
150 /// summarizable.
151 #[cfg_attr(not(unix), allow(dead_code))]
152 pub(crate) fn tool_title(name: &str, arguments_json: &str) -> String {
153 let args: Option<serde_json::Value> = serde_json::from_str(arguments_json).ok();
154 let str_arg =
155 |key: &str| -> Option<String> { Some(args.as_ref()?.get(key)?.as_str()?.to_string()) };
156
157 let (label, summary) = if let Some(rest) = name.strip_prefix("mcp__") {
158 // mcp__<server>__<tool> → "<server> · <tool>"
159 match rest.split_once("__") {
160 Some((server, tool)) => (server.to_string(), Some(tool.to_string())),
161 None => (rest.to_string(), None),
162 }
163 } else {
164 let summary = match name {
165 "run_command" => str_arg("command"),
166 "read_file" | "create_file" | "edit_file" | "multi_edit" | "delete_file" => {
167 str_arg("path")
168 }
169 "search_files" | "glob" => str_arg("pattern"),
170 _ => None,
171 };
172 (name.to_string(), summary)
173 };
174
175 // A title is a single line: collapse any internal whitespace runs
176 // (multi-line commands) before truncating.
177 let summary = summary
178 .map(|s| s.split_whitespace().collect::<Vec<_>>().join(" "))
179 .filter(|s| !s.is_empty())
180 .map(|s| truncate_with_ellipsis(&s, TOOL_TITLE_SUMMARY_MAX));
181
182 match summary {
183 Some(summary) => format!("{label} · {summary}"),
184 None => label,
185 }
186 }
187
188 /// Pretty-print a tool call's JSON arguments for the expanded view; malformed
189 /// JSON is logged and shown raw.
190 #[cfg_attr(not(unix), allow(dead_code))]
191 pub(crate) fn pretty_tool_arguments(arguments_json: &str) -> String {
192 match serde_json::from_str::<serde_json::Value>(arguments_json) {
193 Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|e| {
194 log::warn!("failed to pretty-print tool arguments: {e}");
195 arguments_json.to_string()
196 }),
197 Err(e) => {
198 log::warn!("malformed JSON in tool arguments: {e}");
199 arguments_json.to_string()
200 }
201 }
202 }
203
204 /// The tail of a tool's output for display under an expanded tool entry,
205 /// capped at [`TOOL_RESULT_PREVIEW_MAX`] chars with a truncation note. Display
206 /// only — the model always receives the full output.
207 #[cfg_attr(not(unix), allow(dead_code))]
208 pub(crate) fn cap_output_preview(output: &str) -> String {
209 let total = output.chars().count();
210 if total <= TOOL_RESULT_PREVIEW_MAX {
211 return output.to_string();
212 }
213 let tail: String = output
214 .chars()
215 .skip(total - TOOL_RESULT_PREVIEW_MAX)
216 .collect();
217 format!("… (truncated — showing the last {TOOL_RESULT_PREVIEW_MAX} of {total} chars)\n{tail}")
218 }
219
220 /// The display lines of one tool-call entry. Collapsed: a single `▸ 🔧 title`
221 /// line. Expanded: `▾ 🔧 title`, the indented argument detail, and the result
222 /// preview under a `result:` label when present.
223 #[cfg_attr(not(unix), allow(dead_code))]
224 pub(crate) fn tool_entry_lines(
225 title: &str,
226 detail: &str,
227 result_preview: Option<&str>,
228 expanded: bool,
229 ) -> Vec<String> {
230 if !expanded {
231 return vec![format!("▸ 🔧 {title}")];
232 }
233 let mut out = vec![format!("▾ 🔧 {title}")];
234 if !detail.is_empty() {
235 for line in detail.split('\n') {
236 out.push(format!(" {line}"));
237 }
238 }
239 if let Some(result) = result_preview {
240 out.push(" result:".to_string());
241 for line in result.split('\n') {
242 out.push(format!(" {line}"));
243 }
244 }
245 out
246 }
247
248 // ── Tabs ──────────────────────────────────────────────────────────────────────
249 //
250 // The top-level tab bar (GitHub Copilot CLI-style). Defined outside `mod tui`
251 // so the pure cycling/formatting logic is testable on every target; only the
252 // Unix-only TUI consumes it at runtime, hence the non-Unix dead-code gates
253 // (same pattern as `permissions::TUI_SESSION`).
254
255 /// The top-level TUI tabs, cycled with the Tab key. `Repo` only exists when
256 /// the session cwd's `origin` remote points at the sigit.si host (see
257 /// [`parse_repo_remote`]); every method takes `repo_visible` so the hidden tab
258 /// is skipped entirely and the bar renders exactly three tabs without it.
259 #[cfg_attr(not(unix), allow(dead_code))]
260 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
261 pub(crate) enum Tab {
262 /// The chat itself (default).
263 Session,
264 /// Saved sessions from the session store.
265 History,
266 /// Issues and pull requests of the sigit.si-hosted repo (when detected).
267 Repo,
268 /// siGit Code Cloud status and settings.
269 Cloud,
270 }
271
272 #[cfg_attr(not(unix), allow(dead_code))]
273 impl Tab {
274 /// Tab-bar titles, in cycle order.
275 pub(crate) fn titles(repo_visible: bool) -> Vec<&'static str> {
276 if repo_visible {
277 vec!["Session", "History", "Repo", "Cloud"]
278 } else {
279 vec!["Session", "History", "Cloud"]
280 }
281 }
282
283 /// Session → History → (Repo →) Cloud → Session.
284 pub(crate) fn next(self, repo_visible: bool) -> Self {
285 match self {
286 Tab::Session => Tab::History,
287 Tab::History => {
288 if repo_visible {
289 Tab::Repo
290 } else {
291 Tab::Cloud
292 }
293 }
294 Tab::Repo => Tab::Cloud,
295 Tab::Cloud => Tab::Session,
296 }
297 }
298
299 /// Position in [`Tab::titles`], for the ratatui `Tabs` widget.
300 pub(crate) fn index(self, repo_visible: bool) -> usize {
301 match self {
302 Tab::Session => 0,
303 Tab::History => 1,
304 Tab::Repo => 2,
305 Tab::Cloud => {
306 if repo_visible {
307 3
308 } else {
309 2
310 }
311 }
312 }
313 }
314 }
315
316 /// Coarse "how long ago" label for the History tab (no date dependency).
317 #[cfg_attr(not(unix), allow(dead_code))]
318 pub(crate) fn format_age(age: std::time::Duration) -> String {
319 let secs = age.as_secs();
320 if secs < 60 {
321 format!("{secs}s ago")
322 } else if secs < 3_600 {
323 format!("{}m ago", secs / 60)
324 } else if secs < 86_400 {
325 format!("{}h ago", secs / 3_600)
326 } else {
327 format!("{}d ago", secs / 86_400)
328 }
329 }
330
331 /// One History-tab row: id, age, message count. `age` is `None` when the
332 /// file's mtime could not be read (or lies in the future).
333 #[cfg_attr(not(unix), allow(dead_code))]
334 pub(crate) fn history_row(
335 id: &str,
336 age: Option<std::time::Duration>,
337 message_count: usize,
338 ) -> String {
339 let when = age
340 .map(format_age)
341 .unwrap_or_else(|| "age unknown".to_string());
342 format!("{id} · {when} · {message_count} message(s)")
343 }
344
345 // ── Repo tab: remote detection and data parsing ───────────────────────────────
346 //
347 // Pure helpers for the Repo tab (issues / pull requests of a sigit.si-hosted
348 // repo). Kept at the top level so they compile and test on every target; only
349 // the Unix-only TUI consumes them at runtime.
350
351 /// Host of the sigit.si instance this build talks to: the host of
352 /// `SIGIT_API_URL` when set (same variable `account.rs` uses for the account
353 /// API), else the production default.
354 #[cfg_attr(not(unix), allow(dead_code))]
355 pub(crate) fn sigit_host() -> String {
356 host_from_api_url(std::env::var("SIGIT_API_URL").ok().as_deref())
357 }
358
359 /// Pure core of [`sigit_host`]: derive the host from an optional
360 /// `SIGIT_API_URL` value, defaulting to `sigit.si`.
361 #[cfg_attr(not(unix), allow(dead_code))]
362 pub(crate) fn host_from_api_url(api_url: Option<&str>) -> String {
363 api_url
364 .and_then(url_host)
365 .unwrap_or_else(|| "sigit.si".to_string())
366 }
367
368 /// Extract the hostname (no scheme, userinfo, port, or path) from an http(s)
369 /// URL. `None` for anything else.
370 #[cfg_attr(not(unix), allow(dead_code))]
371 pub(crate) fn url_host(url: &str) -> Option<String> {
372 let url = url.trim();
373 let rest = url
374 .strip_prefix("https://")
375 .or_else(|| url.strip_prefix("http://"))?;
376 let authority = rest.split(['/', '?', '#']).next()?;
377 let host = authority.rsplit('@').next()?.split(':').next()?;
378 if host.is_empty() {
379 None
380 } else {
381 Some(host.to_ascii_lowercase())
382 }
383 }
384
385 /// Extract `owner/name` from a git remote URL *iff* it points at `host`.
386 ///
387 /// Understands the two forms sigit.si issues:
388 /// - ssh (scp-like): `git@sigit.si:owner/name.git` (and without `.git`)
389 /// - https: `https://sigit.si/owner/name.git` (and without `.git`)
390 ///
391 /// A different host, extra path segments, or anything unparsable → `None`,
392 /// which hides the Repo tab.
393 #[cfg_attr(not(unix), allow(dead_code))]
394 pub(crate) fn parse_repo_remote(remote: &str, host: &str) -> Option<String> {
395 let remote = remote.trim();
396
397 // ssh, scp-like: git@<host>:<owner>/<name>(.git)
398 if let Some(rest) = remote.strip_prefix("git@") {
399 let (remote_host, path) = rest.split_once(':')?;
400 if !remote_host.eq_ignore_ascii_case(host) {
401 return None;
402 }
403 return repo_owner_name(path);
404 }
405
406 // http(s)://<host>(:port)/<owner>/<name>(.git)
407 let rest = remote
408 .strip_prefix("https://")
409 .or_else(|| remote.strip_prefix("http://"))?;
410 let (authority, path) = rest.split_once('/')?;
411 let remote_host = authority.rsplit('@').next()?.split(':').next()?;
412 if !remote_host.eq_ignore_ascii_case(host) {
413 return None;
414 }
415 repo_owner_name(path)
416 }
417
418 /// Validate and normalize a remote path into `owner/name` (strips a trailing
419 /// `.git` / `/`). Exactly two non-empty segments, or `None`.
420 fn repo_owner_name(path: &str) -> Option<String> {
421 let path = path.trim().trim_end_matches('/');
422 let path = path.strip_suffix(".git").unwrap_or(path);
423 let mut segments = path.split('/');
424 let owner = segments.next()?;
425 let name = segments.next()?;
426 if owner.is_empty() || name.is_empty() || segments.next().is_some() {
427 return None;
428 }
429 Some(format!("{owner}/{name}"))
430 }
431
432 /// One row of the Repo tab's Issues / Pull requests lists.
433 #[cfg_attr(not(unix), allow(dead_code))]
434 #[derive(Debug, Clone, PartialEq, Eq)]
435 pub(crate) struct RepoItem {
436 pub(crate) number: u64,
437 pub(crate) title: String,
438 pub(crate) state: String,
439 }
440
441 /// Parse the JSON text block a `list_issues` / `list_pull_requests` MCP tool
442 /// returns into rows. Accepts a top-level array or an object wrapping one
443 /// under `issues` / `pull_requests` / `items`; entries missing `number` or
444 /// `title` are skipped. Unparsable input (e.g. an `Error: …` string from
445 /// `mcp::call_tool`) comes back as `Err` with the original text so the tab can
446 /// show it verbatim.
447 #[cfg_attr(not(unix), allow(dead_code))]
448 pub(crate) fn parse_repo_items(text: &str) -> Result<Vec<RepoItem>, String> {
449 let trimmed = text.trim();
450 let value: serde_json::Value =
451 serde_json::from_str(trimmed).map_err(|_| trimmed.to_string())?;
452 let entries = value
453 .as_array()
454 .cloned()
455 .or_else(|| {
456 value.as_object().and_then(|object| {
457 ["issues", "pull_requests", "items"]
458 .iter()
459 .find_map(|key| object.get(*key).and_then(|v| v.as_array()).cloned())
460 })
461 })
462 .ok_or_else(|| trimmed.to_string())?;
463
464 Ok(entries
465 .iter()
466 .filter_map(|entry| {
467 Some(RepoItem {
468 number: entry.get("number")?.as_u64()?,
469 title: entry.get("title")?.as_str()?.to_string(),
470 state: entry
471 .get("state")
472 .and_then(|v| v.as_str())
473 .unwrap_or("")
474 .to_string(),
475 })
476 })
477 .collect())
478 }
479
480 /// One list row: number, state, title.
481 #[cfg_attr(not(unix), allow(dead_code))]
482 pub(crate) fn repo_row(item: &RepoItem) -> String {
483 if item.state.is_empty() {
484 format!("#{} {}", item.number, item.title)
485 } else {
486 format!("#{} [{}] {}", item.number, item.state, item.title)
487 }
488 }
489
490 /// Render a `get_issue` / `get_pull_request` result for the detail view:
491 /// pretty-print when it's JSON, pass anything else (e.g. error text) through.
492 #[cfg_attr(not(unix), allow(dead_code))]
493 pub(crate) fn format_repo_detail(text: &str) -> String {
494 match serde_json::from_str::<serde_json::Value>(text.trim()) {
495 Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| text.to_string()),
496 Err(_) => text.to_string(),
497 }
498 }
499
500 // ── Unix-only TUI ─────────────────────────────────────────────────────────────
501 //
502 // macOS + Linux only. Windows uses ACP mode instead.
503
504 #[cfg(unix)]
505 mod tui {
506 use std::future::pending;
507 use std::sync::Arc;
508 use std::sync::mpsc as std_mpsc;
509
510 use anyhow::Result;
511 use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
512 use futures::StreamExt;
513 use onde::inference::{ChatEngine, SamplingConfig};
514
515 use super::{RepoItem, Tab};
516 use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend, ToolResult, ToolSpec};
517 use crate::models::{
518 InferenceKind, ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items,
519 };
520 use crate::session_store::SessionEntry;
521 use ratatui::{
522 Frame,
523 layout::{Constraint, Layout, Position},
524 style::{Color, Modifier, Style},
525 text::{Line, Span},
526 widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap},
527 };
528 use tokio::sync::{mpsc, oneshot};
529 use tokio::time::{Duration, Instant, interval};
530
531 // ── Message types ─────────────────────────────────────────────────────────
532
533 #[derive(Clone, Copy, PartialEq, Eq)]
534 enum Role {
535 User,
536 Assistant,
537 System,
538 /// rainbow-colored banner art
539 Banner,
540 /// a collapsible tool-call entry (`text` holds the title line)
541 Tool,
542 }
543
544 struct ChatMessage {
545 role: Role,
546 text: String,
547 /// Qwen 3 reasoning extracted from `<think>` tags, if any.
548 think_block: Option<String>,
549 /// pretty-printed tool arguments (Role::Tool only)
550 tool_detail: Option<String>,
551 /// tail of the tool output, filled in when the call finishes
552 /// (Role::Tool only)
553 tool_result: Option<String>,
554 }
555
556 impl ChatMessage {
557 fn user(text: impl Into<String>) -> Self {
558 Self {
559 role: Role::User,
560 text: text.into(),
561 think_block: None,
562 tool_detail: None,
563 tool_result: None,
564 }
565 }
566
567 fn assistant(text: impl Into<String>) -> Self {
568 let raw = text.into();
569 let (think, visible) = super::strip_think_blocks(&raw);
570 Self {
571 role: Role::Assistant,
572 text: visible,
573 think_block: if think.is_empty() { None } else { Some(think) },
574 tool_detail: None,
575 tool_result: None,
576 }
577 }
578
579 fn system(text: impl Into<String>) -> Self {
580 Self {
581 role: Role::System,
582 text: text.into(),
583 think_block: None,
584 tool_detail: None,
585 tool_result: None,
586 }
587 }
588
589 fn banner(text: impl Into<String>) -> Self {
590 Self {
591 role: Role::Banner,
592 text: text.into(),
593 think_block: None,
594 tool_detail: None,
595 tool_result: None,
596 }
597 }
598
599 fn tool_call(title: impl Into<String>, detail: impl Into<String>) -> Self {
600 Self {
601 role: Role::Tool,
602 text: title.into(),
603 think_block: None,
604 tool_detail: Some(detail.into()),
605 tool_result: None,
606 }
607 }
608 }
609
610 // ── Inference updates from background task ────────────────────────────────
611
612 enum InferenceUpdate {
613 /// a tool call started — show a collapsible entry in the transcript
614 ToolUse {
615 name: String,
616 /// raw JSON arguments, pretty-printed by the UI for the expanded view
617 arguments: String,
618 },
619 /// the most recent tool call finished; attach a display-only preview of
620 /// the tail of its output (the model always gets the full output)
621 ToolDone {
622 output_preview: String,
623 },
624 /// a streamed token fragment of the assistant's reply
625 Delta(String),
626 /// the streamed reply is complete; commit the accumulated buffer
627 StreamEnd,
628 /// a complete (non-streamed) assistant reply
629 Response(String),
630 /// estimated tokens the conversation now occupies; drives the context
631 /// gauge in the title bar. Sent after compaction and at end of turn.
632 ContextUsage(usize),
633 Error(String),
634 /// the inference task wants to run a mutating tool and is paused on
635 /// `reply`; the user answers with y (once) / a (session) / n (deny)
636 ApprovalRequest {
637 tool: String,
638 /// arguments preview so the user can see what they are approving
639 args: String,
640 reply: oneshot::Sender<ApprovalChoice>,
641 },
642 }
643
644 /// The user's answer to a tool-approval prompt. Dropping the reply channel
645 /// (quit, cancel) counts as a denial on the inference side.
646 enum ApprovalChoice {
647 /// run this one call
648 Once,
649 /// run it and stop asking for this tool for the rest of the session
650 Session,
651 /// skip the call; the model gets an explanatory tool result
652 Deny,
653 }
654
655 enum ModelLoadUpdate {
656 Loaded(String),
657 Error(String),
658 }
659
660 // ── Repo tab types ────────────────────────────────────────────────────────
661
662 /// Which list the Repo tab is showing.
663 #[derive(Clone, Copy, PartialEq, Eq)]
664 enum RepoSection {
665 Issues,
666 PullRequests,
667 }
668
669 impl RepoSection {
670 fn other(self) -> Self {
671 match self {
672 RepoSection::Issues => RepoSection::PullRequests,
673 RepoSection::PullRequests => RepoSection::Issues,
674 }
675 }
676 }
677
678 /// Lifecycle of one Repo-tab list. `Failed` carries the error text returned
679 /// by the MCP layer (signed out, MCP off, tool error, unparsable reply) so
680 /// the tab can show it verbatim plus a hint.
681 enum RepoData {
682 /// Nothing fetched yet — kicked off on first entry to the tab.
683 NotFetched,
684 Loading,
685 Ready(Vec<RepoItem>),
686 Failed(String),
687 }
688
689 impl RepoData {
690 fn len(&self) -> usize {
691 match self {
692 RepoData::Ready(items) => items.len(),
693 _ => 0,
694 }
695 }
696 }
697
698 /// Identifies which list row a detail fetch belongs to, so a slow reply
699 /// can be matched against the currently-open detail and dropped if the
700 /// user has since navigated elsewhere.
701 #[derive(Clone, Copy, PartialEq, Eq)]
702 struct RepoTarget {
703 section: RepoSection,
704 number: u64,
705 }
706
707 /// The full-tab detail view opened with Enter on a list row.
708 struct RepoDetail {
709 /// Which issue/PR this view is showing; matched against arriving
710 /// `RepoUpdate::Detail` replies so a stale fetch can't fill it.
711 target: RepoTarget,
712 title: String,
713 /// `None` while the `get_issue` / `get_pull_request` fetch runs.
714 text: Option<String>,
715 scroll: u16,
716 }
717
718 /// Results from the Repo tab's spawned fetch tasks, multiplexed into the
719 /// event loop alongside inference updates (same pattern as the Cloud tab).
720 enum RepoUpdate {
721 Issues(Result<Vec<RepoItem>, String>),
722 PullRequests(Result<Vec<RepoItem>, String>),
723 Detail { target: RepoTarget, text: String },
724 }
725
726 // ── App state ─────────────────────────────────────────────────────────────
727
728 struct App {
729 messages: Vec<ChatMessage>,
730 input: String,
731 cursor: usize,
732 /// true while assistant tokens are streaming into `stream_buf`
733 streaming: bool,
734 stream_buf: String,
735 inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
736 model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>,
737 /// a tool call waiting on the user's y/a/n answer; the inference task is
738 /// paused on the other end of the channel
739 pending_approval: Option<(String, oneshot::Sender<ApprovalChoice>)>,
740 thinking: bool,
741 thinking_tick: u8,
742 quit: bool,
743 /// toggled periodically so the streaming cursor blinks
744 blink_on: bool,
745 blink_counter: u8,
746 switching_model: bool,
747 /// stashed until ModelLoadUpdate::Loaded applies it to `app.tool_calling`
748 pending_tool_calling: Option<bool>,
749 /// suppresses the spurious "disconnected" error when we drop model_load_rx on cancel
750 model_load_cancelled: bool,
751
752 // ── Loading-phase state ───────────────────────────────────────────────
753 is_loading: bool,
754 load_tick: u32,
755 /// keeps the loading view visible so the user can read the error
756 load_error: Option<String>,
757 load_start: Instant,
758 load_model_name: String,
759
760 // ── Model picker state ────────────────────────────────────────────────
761 show_model_picker: bool,
762 model_picker_index: usize,
763 model_picker_items: Vec<ModelPickerItem>,
764 current_model_name: String,
765 tool_calling: bool,
766 /// Estimated tokens the conversation currently occupies, as of the last
767 /// completed turn. `None` until the first turn finishes.
768 context_tokens_used: Option<usize>,
769 /// `/thinking` — expand the model's reasoning on rendered messages.
770 /// Display-only: never changes what is sent to the model or saved.
771 show_thinking: bool,
772 /// `/tools` — expand every tool-call entry (default: collapsed titles)
773 tools_expanded: bool,
774
775 // ── Model-switch download progress ────────────────────────────────────
776 switching_model_id: Option<String>,
777 /// (downloaded, expected) bytes — polled every tick during a model switch
778 download_progress: Option<(u64, u64)>,
779
780 // ── Active inference backend ──────────────────────────────────────────
781 /// The backend serving inference. Swapped in place when the user picks a
782 /// different model or cloud tier via `/models`.
783 backend: Arc<dyn InferenceBackend>,
784
785 // ── Tab bar state ─────────────────────────────────────────────────────
786 /// Which top-level tab is showing. Inference keeps running while the
787 /// user is on History/Cloud; updates land in `messages` regardless.
788 active_tab: Tab,
789
790 // History tab: saved sessions from the session store.
791 history_sessions: Vec<SessionEntry>,
792 history_index: usize,
793 /// Session id awaiting the confirming second `d`; any other key clears it.
794 history_pending_delete: Option<String>,
795 /// One-shot notice shown under the session list (e.g. a failed restore).
796 history_notice: Option<String>,
797
798 // Cloud tab: status text is fetched async when the tab opens and cached.
799 /// `None` while a fetch is in flight (renders as "fetching…").
800 cloud_lines: Option<Vec<String>>,
801 cloud_rx: Option<oneshot::Receiver<Vec<String>>>,
802
803 // Repo tab: issues and pull requests of a sigit.si-hosted repo.
804 /// `owner/name` when the cwd's `origin` remote points at the sigit.si
805 /// host; `None` hides the Repo tab entirely.
806 repo: Option<String>,
807 repo_section: RepoSection,
808 repo_issues: RepoData,
809 repo_prs: RepoData,
810 repo_issue_index: usize,
811 repo_pr_index: usize,
812 /// Detail view over the list, opened with Enter, closed with Esc.
813 repo_detail: Option<RepoDetail>,
814 /// Sender cloned into the spawned fetch tasks; kept here so the
815 /// receiver never reads a spurious disconnect between fetches.
816 repo_tx: Option<mpsc::UnboundedSender<RepoUpdate>>,
817 repo_rx: Option<mpsc::UnboundedReceiver<RepoUpdate>>,
818 }
819
820 const BANNER_ART: &str = "\
821 77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777
822 77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777
823 1111111125555555555555555555555511113222311159 5002 088 3081771691111111111111
824 1111111111111111111111111111131136841 1482853332007 05 9043332891 400811111111111
825 1111111111111111111111111111111201 109 304 40 00 79 100041111111111
826 333333255555555555555555555552392 102 503 90 7000000005 903 0000023333333333
827 333333245454545454545454545433381 7600000 302 61 780 109 20009533333333333
828 3333333333333333333333333333333402 7001 08 761 202 902 90003333333333333
829 2222255555555555555555555555250899901 49 304 403 08 108 300042222222222222
830 2222222222222222222222222222269 106 03 901 06 505 402 000052222222222222
831 2222255555555555555555555555299 708 1002 80 00 90852222222222222
832 55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555
833 88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888";
834
835 const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
836
837 fn rich_text_spans(text: &str, base_style: Style, bold_style: Style) -> Vec<Span<'static>> {
838 let mut spans = Vec::new();
839
840 for (segment, is_bold) in super::parse_rich_text_segments(text) {
841 let style = if is_bold { bold_style } else { base_style };
842 spans.push(Span::styled(segment, style));
843 }
844
845 if spans.is_empty() {
846 spans.push(Span::styled(String::new(), base_style));
847 }
848
849 spans
850 }
851
852 impl App {
853 fn new(load_model_name: String, backend: Arc<dyn InferenceBackend>) -> Self {
854 let is_remote = backend.is_remote();
855 let items = build_model_picker_items();
856 let tool_calling = items
857 .iter()
858 .find(|m| m.display_name == load_model_name)
859 .map(|m| m.tool_calling)
860 .unwrap_or(true);
861 // For a remote provider the passed-in name is authoritative; the
862 // persisted local selection must not override it (or the title would
863 // show an on-device model while requests go to the cloud).
864 let current_model_name = if is_remote {
865 load_model_name.clone()
866 } else {
867 crate::setup::load_selected_model_name().unwrap_or_else(|| load_model_name.clone())
868 };
869 Self {
870 messages: Vec::new(),
871 input: String::new(),
872 cursor: 0,
873 streaming: false,
874 stream_buf: String::new(),
875 inference_rx: None,
876 model_load_rx: None,
877 pending_approval: None,
878 thinking: false,
879 thinking_tick: 0,
880 quit: false,
881 blink_on: true,
882 blink_counter: 0,
883 switching_model: false,
884 pending_tool_calling: None,
885 model_load_cancelled: false,
886 switching_model_id: None,
887 download_progress: None,
888 is_loading: true,
889 load_tick: 0,
890 load_error: None,
891 load_start: Instant::now(),
892 load_model_name: load_model_name.clone(),
893 show_model_picker: false,
894 model_picker_index: 0,
895 model_picker_items: items,
896 current_model_name,
897 tool_calling,
898 context_tokens_used: None,
899 show_thinking: false,
900 tools_expanded: false,
901 backend,
902 active_tab: Tab::Session,
903 history_sessions: Vec::new(),
904 history_index: 0,
905 history_pending_delete: None,
906 history_notice: None,
907 cloud_lines: None,
908 cloud_rx: None,
909 repo: detect_sigit_repo(),
910 repo_section: RepoSection::Issues,
911 repo_issues: RepoData::NotFetched,
912 repo_prs: RepoData::NotFetched,
913 repo_issue_index: 0,
914 repo_pr_index: 0,
915 repo_detail: None,
916 repo_tx: None,
917 repo_rx: None,
918 }
919 }
920
921 /// Whether the Repo tab exists for this session.
922 fn repo_visible(&self) -> bool {
923 self.repo.is_some()
924 }
925
926 /// The data behind the Repo tab's active section.
927 fn repo_list(&self) -> &RepoData {
928 match self.repo_section {
929 RepoSection::Issues => &self.repo_issues,
930 RepoSection::PullRequests => &self.repo_prs,
931 }
932 }
933
934 /// Selection index of the Repo tab's active section.
935 fn repo_index_mut(&mut self) -> &mut usize {
936 match self.repo_section {
937 RepoSection::Issues => &mut self.repo_issue_index,
938 RepoSection::PullRequests => &mut self.repo_pr_index,
939 }
940 }
941
942 /// Reload the History tab's session list, keeping the selection in
943 /// bounds and dropping any pending delete confirmation.
944 fn refresh_history(&mut self) {
945 self.history_sessions = crate::session_store::list();
946 self.history_index = self
947 .history_index
948 .min(self.history_sessions.len().saturating_sub(1));
949 self.history_pending_delete = None;
950 }
951
952 /// The History entry the cursor is on, if any.
953 fn selected_session(&self) -> Option<&SessionEntry> {
954 self.history_sessions.get(self.history_index)
955 }
956
957 fn is_busy(&self) -> bool {
958 self.is_streaming() || self.thinking || self.switching_model
959 }
960
961 fn switching_frame(&self) -> &'static str {
962 let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
963 THINKING_FRAMES[idx]
964 }
965
966 fn is_streaming(&self) -> bool {
967 self.streaming
968 }
969
970 fn finalize_stream(&mut self) {
971 self.streaming = false;
972 if !self.stream_buf.is_empty() {
973 let text = std::mem::take(&mut self.stream_buf);
974 self.messages.push(ChatMessage::assistant(text));
975 }
976 self.blink_on = false;
977 }
978
979 fn push_stream_delta(&mut self, delta: &str) {
980 self.streaming = true;
981 self.stream_buf.push_str(delta);
982 // Hide reasoning the way the rest of the app does: keep the "thinking"
983 // spinner until visible (non-<think>) text appears, then show the
984 // live reply. Don't call stop_thinking() — that drops the channel.
985 let (_think, visible) = super::strip_think_blocks(&self.stream_buf);
986 self.thinking = visible.trim().is_empty();
987 self.blink_counter = self.blink_counter.wrapping_add(1);
988 self.blink_on = self.blink_counter % 4 < 2;
989 }
990
991 /// The portion of the streaming buffer to show live, with reasoning hidden.
992 fn visible_stream(&self) -> String {
993 let (_think, visible) = super::strip_think_blocks(&self.stream_buf);
994 visible
995 }
996
997 fn start_thinking(&mut self) {
998 self.thinking = true;
999 self.thinking_tick = 0;
1000 }
1001
1002 fn stop_thinking(&mut self) {
1003 self.thinking = false;
1004 self.inference_rx = None;
1005 // Dropping a pending reply channel reads as a denial on the
1006 // inference side, so a cancelled turn can't leave a tool waiting.
1007 self.pending_approval = None;
1008 }
1009
1010 fn tick_thinking(&mut self) {
1011 self.thinking_tick = self.thinking_tick.wrapping_add(1);
1012 }
1013
1014 fn thinking_frame(&self) -> &'static str {
1015 let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
1016 THINKING_FRAMES[idx]
1017 }
1018
1019 /// Context window of the model currently serving inference, falling back
1020 /// to the compaction budget when the model isn't in the picker list
1021 /// (e.g. a provider-supplied name we don't ship a config for).
1022 ///
1023 /// An env-configured provider names itself after its model id rather
1024 /// than a catalogue display name, so match either. Getting this wrong
1025 /// is silent: the gauge just divides by the wrong number.
1026 fn context_window_tokens(&self) -> u64 {
1027 self.model_picker_items
1028 .iter()
1029 .find(|item| {
1030 item.display_name == self.current_model_name
1031 || item.config.model_id == self.current_model_name
1032 })
1033 .map(|item| item.context_window_tokens)
1034 .unwrap_or(crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET as u64)
1035 }
1036
1037 fn tick(&mut self) {
1038 self.load_tick = self.load_tick.wrapping_add(1);
1039 }
1040
1041 /// check how much of the model has landed on disk so far
1042 fn poll_download_progress(&mut self) {
1043 let Some(ref model_id) = self.switching_model_id else {
1044 return;
1045 };
1046 let cache_path = onde::hf_cache::model_cache_path(model_id);
1047 let downloaded = cache_path
1048 .as_ref()
1049 .filter(|p| p.exists())
1050 .map(|p| dir_size_recursive(p))
1051 .unwrap_or(0);
1052 let expected = onde::inference::models::SUPPORTED_MODEL_INFO
1053 .iter()
1054 .find(|m| m.id == model_id.as_str())
1055 .map(|m| m.expected_size_bytes)
1056 .unwrap_or(0);
1057 self.download_progress = Some((downloaded, expected));
1058 }
1059
1060 /// switch to chat phase and show the welcome banner
1061 fn finish_loading(&mut self) {
1062 self.is_loading = false;
1063 for line in BANNER_ART.lines() {
1064 self.messages.push(ChatMessage::banner(line));
1065 }
1066 self.messages.push(ChatMessage::system(""));
1067 self.messages.push(ChatMessage::system(
1068 "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit",
1069 ));
1070 if self.backend.is_remote() {
1071 self.messages.push(ChatMessage::system(format!(
1072 "Current model: {}",
1073 self.current_model_name
1074 )));
1075 } else {
1076 // On-device models are never loaded implicitly; prompt the user to
1077 // load one explicitly before their first message.
1078 self.messages.push(ChatMessage::system(format!(
1079 "No on-device model loaded. Run /load to load {}, or /models to choose one.",
1080 self.current_model_name
1081 )));
1082 }
1083 self.messages
1084 .push(ChatMessage::system("Type /help for commands."));
1085 }
1086
1087 /// store the error but stay in loading view so the user can read it
1088 fn set_load_error(&mut self, error: String) {
1089 self.load_error = Some(error);
1090 // is_loading stays true so render_loading() keeps rendering.
1091 }
1092
1093 fn open_model_picker(&mut self, engine: &ChatEngine) {
1094 let current = crate::setup::load_selected_model();
1095 let current_name = crate::setup::load_selected_model_name().unwrap_or_else(|| {
1096 futures::executor::block_on(engine.info())
1097 .model_name
1098 .unwrap_or_else(|| self.current_model_name.clone())
1099 });
1100
1101 self.model_picker_items = build_model_picker_items();
1102 self.model_picker_index = current
1103 .as_ref()
1104 .and_then(|selected| {
1105 self.model_picker_items.iter().position(|item| {
1106 item.config.model_id == selected.model_id
1107 && item
1108 .config
1109 .files
1110 .iter()
1111 .any(|file| file == &selected.gguf_file)
1112 })
1113 })
1114 .or_else(|| {
1115 self.model_picker_items
1116 .iter()
1117 .position(|item| item.display_name == current_name)
1118 })
1119 .unwrap_or(0);
1120 self.show_model_picker = true;
1121 }
1122
1123 fn close_model_picker(&mut self) {
1124 self.show_model_picker = false;
1125 }
1126
1127 fn move_model_picker_up(&mut self) {
1128 if self.model_picker_items.is_empty() {
1129 return;
1130 }
1131 if self.model_picker_index == 0 {
1132 self.model_picker_index = self.model_picker_items.len().saturating_sub(1);
1133 } else {
1134 self.model_picker_index -= 1;
1135 }
1136 }
1137
1138 fn move_model_picker_down(&mut self) {
1139 if self.model_picker_items.is_empty() {
1140 return;
1141 }
1142 self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len();
1143 }
1144 }
1145
1146 // ── Model picker ─────────────────────────────────────────────────────────
1147 //
1148 // picker data types live in crate::models so Windows (ACP-only) can use them too
1149
1150 fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1151 let popup = centered_rect(82, 72, area);
1152
1153 // clear the background so text doesn't bleed through
1154 frame.render_widget(Clear, popup);
1155
1156 let block = Block::default()
1157 .title(" Select a model… ")
1158 .borders(Borders::ALL)
1159 .border_style(Style::default().fg(Color::DarkGray))
1160 .style(Style::default().bg(Color::Black));
1161
1162 let inner = block.inner(popup);
1163 frame.render_widget(block, popup);
1164
1165 let active_kind = crate::models::active_inference_kind();
1166 let mut lines = Vec::new();
1167
1168 // State banner: which mode is active, and how to flip it.
1169 let (state_word, state_style) = match active_kind {
1170 InferenceKind::Local => (
1171 "ON (on-device)",
1172 Style::default().fg(Color::Green).bg(Color::Black),
1173 ),
1174 InferenceKind::Cloud => (
1175 "OFF (siGit Code Cloud)",
1176 Style::default().fg(Color::Magenta).bg(Color::Black),
1177 ),
1178 };
1179 lines.push(Line::from(vec![
1180 Span::styled(
1181 "Local inference: ",
1182 Style::default()
1183 .fg(Color::White)
1184 .bg(Color::Black)
1185 .add_modifier(Modifier::BOLD),
1186 ),
1187 Span::styled(state_word, state_style.add_modifier(Modifier::BOLD)),
1188 Span::styled(
1189 " toggle with /local on|off",
1190 Style::default().fg(Color::DarkGray).bg(Color::Black),
1191 ),
1192 ]));
1193 lines.push(Line::from("").style(Style::default().bg(Color::Black)));
1194
1195 let mut last_section: Option<ModelSource> = None;
1196 let mut last_kind: Option<InferenceKind> = None;
1197
1198 for (index, item) in app.model_picker_items.iter().enumerate() {
1199 let item_kind = item.source.kind();
1200 let item_active = item_kind == active_kind;
1201
1202 // Top-level group header (Local / Cloud) whenever the nature changes.
1203 if last_kind != Some(item_kind) {
1204 if last_kind.is_some() {
1205 lines.push(Line::from("").style(Style::default().bg(Color::Black)));
1206 }
1207 let group_label = match item_kind {
1208 InferenceKind::Local => "LOCAL — on-device inference",
1209 InferenceKind::Cloud => "CLOUD — siGit Code Cloud",
1210 };
1211 let group_style = if item_active {
1212 Style::default()
1213 .fg(Color::White)
1214 .bg(Color::Black)
1215 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
1216 } else {
1217 Style::default().fg(Color::DarkGray).bg(Color::Black)
1218 };
1219 lines.push(
1220 Line::from(vec![Span::styled(group_label, group_style)])
1221 .style(Style::default().bg(Color::Black)),
1222 );
1223 last_kind = Some(item_kind);
1224 last_section = None;
1225 }
1226
1227 if last_section != Some(item.source) {
1228 if last_section.is_some() {
1229 lines.push(Line::from("").style(Style::default().bg(Color::Black)));
1230 }
1231
1232 let (section_mark, section_name, section_style) = match item.source {
1233 ModelSource::Onde => (
1234 "◉",
1235 "Onde Inference",
1236 Style::default()
1237 .fg(Color::Green)
1238 .bg(Color::Black)
1239 .add_modifier(Modifier::BOLD),
1240 ),
1241 ModelSource::HuggingFace => (
1242 "○",
1243 "Hugging Face cache",
1244 Style::default()
1245 .fg(Color::Cyan)
1246 .bg(Color::Black)
1247 .add_modifier(Modifier::BOLD),
1248 ),
1249 ModelSource::Available => (
1250 "↓",
1251 "Available for download",
1252 Style::default()
1253 .fg(Color::Blue)
1254 .bg(Color::Black)
1255 .add_modifier(Modifier::BOLD),
1256 ),
1257 ModelSource::Fallback => (
1258 "◎",
1259 "Fallback",
1260 Style::default()
1261 .fg(Color::Yellow)
1262 .bg(Color::Black)
1263 .add_modifier(Modifier::BOLD),
1264 ),
1265 ModelSource::Cloud => (
1266 "☁",
1267 "siGit Code Cloud",
1268 Style::default()
1269 .fg(Color::Magenta)
1270 .bg(Color::Black)
1271 .add_modifier(Modifier::BOLD),
1272 ),
1273 };
1274
1275 // Dim the section header when it belongs to the inactive group.
1276 let section_style = if item_active {
1277 section_style
1278 } else {
1279 Style::default().fg(Color::DarkGray).bg(Color::Black)
1280 };
1281
1282 lines.push(
1283 Line::from(vec![
1284 Span::styled(format!(" {section_mark} "), section_style),
1285 Span::styled(section_name, section_style),
1286 ])
1287 .style(Style::default().bg(Color::Black)),
1288 );
1289 last_section = Some(item.source);
1290 }
1291
1292 let selected = index == app.model_picker_index;
1293 let current = item.display_name == app.current_model_name;
1294 let marker = if selected { "› " } else { " " };
1295 let tool_badge = if item.tool_calling {
1296 " ✓ tool calling"
1297 } else {
1298 ""
1299 };
1300 let health_badge = match item.cache_health {
1301 ModelCacheHealth::Complete => "",
1302 ModelCacheHealth::Incomplete => " ! incomplete cache",
1303 ModelCacheHealth::NotDownloaded => " ↓ download",
1304 };
1305 let current_badge = if current { " ← current" } else { "" };
1306 let context_badge = format!(
1307 " {}",
1308 crate::models::format_context_window(item.context_window_tokens)
1309 );
1310 let disabled_badge = match item.cache_health {
1311 ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "",
1312 ModelCacheHealth::Incomplete => " (unselectable)",
1313 };
1314 let brand_mark = match item.source {
1315 ModelSource::Onde => "◉",
1316 ModelSource::HuggingFace => "○",
1317 ModelSource::Available => "↓",
1318 ModelSource::Fallback => "◎",
1319 ModelSource::Cloud => "☁",
1320 };
1321 let source = format!(" [{} {}]", brand_mark, item.source_label);
1322
1323 let base_style = if selected {
1324 Style::default().fg(Color::Black).bg(Color::Green)
1325 } else if item_active {
1326 Style::default().fg(Color::White).bg(Color::Black)
1327 } else {
1328 // Inactive group: still visible (we surface the offering) but dimmed.
1329 Style::default().fg(Color::DarkGray).bg(Color::Black)
1330 };
1331
1332 let source_style = if selected {
1333 Style::default().fg(Color::Black).bg(Color::Green)
1334 } else if !item_active {
1335 Style::default().fg(Color::DarkGray).bg(Color::Black)
1336 } else {
1337 match item.source {
1338 ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black),
1339 ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black),
1340 ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black),
1341 ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black),
1342 ModelSource::Cloud => Style::default().fg(Color::Magenta).bg(Color::Black),
1343 }
1344 };
1345
1346 let health_style = if selected {
1347 Style::default().fg(Color::Red).bg(Color::Green)
1348 } else {
1349 Style::default().fg(Color::Red).bg(Color::Black)
1350 };
1351
1352 lines.push(Line::from(vec![
1353 Span::styled(
1354 format!("{marker}{} {}", item.display_name, item.description),
1355 base_style,
1356 ),
1357 Span::styled(
1358 tool_badge.to_string(),
1359 if selected {
1360 Style::default().fg(Color::Black).bg(Color::Green)
1361 } else {
1362 Style::default().fg(Color::Green).bg(Color::Black)
1363 },
1364 ),
1365 Span::styled(
1366 context_badge,
1367 if selected {
1368 Style::default().fg(Color::Black).bg(Color::Green)
1369 } else if item_active {
1370 Style::default().fg(Color::Gray).bg(Color::Black)
1371 } else {
1372 Style::default().fg(Color::DarkGray).bg(Color::Black)
1373 },
1374 ),
1375 Span::styled(health_badge.to_string(), health_style),
1376 Span::styled(
1377 disabled_badge.to_string(),
1378 if selected {
1379 Style::default().fg(Color::Black).bg(Color::Green)
1380 } else {
1381 Style::default().fg(Color::DarkGray).bg(Color::Black)
1382 },
1383 ),
1384 Span::styled(
1385 current_badge.to_string(),
1386 if selected {
1387 Style::default().fg(Color::Black).bg(Color::Green)
1388 } else {
1389 Style::default().fg(Color::Cyan).bg(Color::Black)
1390 },
1391 ),
1392 Span::styled(source, source_style),
1393 ]));
1394 }
1395
1396 frame.render_widget(
1397 Paragraph::new(lines)
1398 .wrap(Wrap { trim: false })
1399 .style(Style::default().bg(Color::Black)),
1400 inner,
1401 );
1402 }
1403
1404 fn centered_rect(
1405 percent_x: u16,
1406 percent_y: u16,
1407 area: ratatui::layout::Rect,
1408 ) -> ratatui::layout::Rect {
1409 let vertical = Layout::vertical([
1410 Constraint::Percentage((100 - percent_y) / 2),
1411 Constraint::Percentage(percent_y),
1412 Constraint::Percentage((100 - percent_y) / 2),
1413 ])
1414 .split(area);
1415
1416 Layout::horizontal([
1417 Constraint::Percentage((100 - percent_x) / 2),
1418 Constraint::Percentage(percent_x),
1419 Constraint::Percentage((100 - percent_x) / 2),
1420 ])
1421 .split(vertical[1])[1]
1422 }
1423
1424 // ── Tab bar (Session / History / Cloud) ───────────────────────────────────
1425
1426 /// Switch to `tab`, refreshing the data it shows. Entering History rescans
1427 /// the sessions dir; entering Cloud kicks off the async status fetch;
1428 /// entering Repo fetches the issue/PR lists on first visit.
1429 fn switch_tab(app: &mut App, tab: Tab, engine: &Arc<ChatEngine>) {
1430 app.active_tab = tab;
1431 match tab {
1432 Tab::Session => {}
1433 Tab::History => {
1434 app.refresh_history();
1435 app.history_notice = None;
1436 }
1437 Tab::Repo => {
1438 // Fetch once on first entry; `r` refreshes after that.
1439 if matches!(app.repo_issues, RepoData::NotFetched) {
1440 refresh_repo(app);
1441 }
1442 }
1443 Tab::Cloud => refresh_cloud(app, engine),
1444 }
1445 }
1446
1447 /// Fetch the Cloud tab's status text on a background task and cache it.
1448 /// Account status and engine info are async (the account check may hit the
1449 /// network), so the tab shows "fetching…" until the oneshot resolves in
1450 /// the event loop.
1451 fn refresh_cloud(app: &mut App, engine: &Arc<ChatEngine>) {
1452 let (tx, rx) = oneshot::channel();
1453 app.cloud_rx = Some(rx);
1454 app.cloud_lines = None;
1455
1456 let engine = Arc::clone(engine);
1457 let is_remote = app.backend.is_remote();
1458 let model_name = app.current_model_name.clone();
1459 tokio::spawn(async move {
1460 // `status_line` already folds failures into its message, so a dead
1461 // network degrades to an error string rather than a stuck tab.
1462 let account = crate::account::status_line().await;
1463 let info = engine.info().await;
1464
1465 let mut lines = Vec::new();
1466 lines.push(format!("Account: {account}"));
1467 lines.push(format!(
1468 "Inference: {}",
1469 if is_remote {
1470 "remote (siGit Code Cloud / hosted endpoint)"
1471 } else {
1472 "on-device"
1473 }
1474 ));
1475 lines.push(format!("Model: {model_name}"));
1476 lines.push(format!(
1477 "Engine: status: {:?} model: {} memory: {} history: {} turns",
1478 info.status,
1479 info.model_name.as_deref().unwrap_or("(none)"),
1480 info.approx_memory.as_deref().unwrap_or("unknown"),
1481 info.history_length,
1482 ));
1483 lines.push(format!(
1484 "Local inference: {}",
1485 if crate::settings::local_inference_enabled() {
1486 "on"
1487 } else {
1488 "off"
1489 }
1490 ));
1491 let config_dir = std::env::var("SIGIT_CONFIG_DIR").unwrap_or_else(|_| {
1492 let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
1493 format!("{home}/.config/sigit")
1494 });
1495 lines.push(format!("Config dir: {config_dir}"));
1496 lines.push(String::new());
1497 for perm_line in crate::permissions::describe(crate::permissions::TUI_SESSION).lines() {
1498 lines.push(perm_line.to_string());
1499 }
1500
1501 let _ = tx.send(lines);
1502 });
1503 }
1504
1505 // ── Repo tab (issues / pull requests via the official MCP server) ─────────
1506
1507 /// `owner/name` when the current directory's `origin` remote lives on the
1508 /// sigit.si host (per `SIGIT_API_URL`). Runs `git remote get-url origin`
1509 /// once at startup; any failure (no git, no repo, no origin) → `None`.
1510 fn detect_sigit_repo() -> Option<String> {
1511 let output = std::process::Command::new("git")
1512 .args(["remote", "get-url", "origin"])
1513 .output()
1514 .ok()?;
1515 if !output.status.success() {
1516 return None;
1517 }
1518 let url = String::from_utf8_lossy(&output.stdout);
1519 super::parse_repo_remote(url.trim(), &super::sigit_host())
1520 }
1521
1522 /// Fetch both Repo-tab lists on spawned tasks feeding `repo_rx` — the same
1523 /// spawned-fetch pattern as the Cloud tab, so the render loop never blocks
1524 /// on the network. Replacing the channel drops any in-flight fetch's
1525 /// updates on the floor, which is exactly what a refresh wants.
1526 fn refresh_repo(app: &mut App) {
1527 let Some(repo) = app.repo.clone() else {
1528 return;
1529 };
1530
1531 let (tx, rx) = mpsc::unbounded_channel();
1532 app.repo_tx = Some(tx.clone());
1533 app.repo_rx = Some(rx);
1534 app.repo_issues = RepoData::Loading;
1535 app.repo_prs = RepoData::Loading;
1536 // A stale detail view would outlive the list it was opened from.
1537 app.repo_detail = None;
1538
1539 let args = serde_json::json!({ "repo": repo }).to_string();
1540
1541 let issues_tx = tx.clone();
1542 let issues_args = args.clone();
1543 tokio::spawn(async move {
1544 let tool = crate::mcp::official_tool_name("list_issues");
1545 let text = crate::mcp::call_tool(&tool, &issues_args).await;
1546 let _ = issues_tx.send(RepoUpdate::Issues(super::parse_repo_items(&text)));
1547 });
1548
1549 tokio::spawn(async move {
1550 let tool = crate::mcp::official_tool_name("list_pull_requests");
1551 let text = crate::mcp::call_tool(&tool, &args).await;
1552 let _ = tx.send(RepoUpdate::PullRequests(super::parse_repo_items(&text)));
1553 });
1554 }
1555
1556 /// Open the detail view for the selected row and fetch its body
1557 /// (`get_issue` / `get_pull_request`) on a spawned task.
1558 fn open_repo_detail(app: &mut App) {
1559 let Some(repo) = app.repo.clone() else {
1560 return;
1561 };
1562 let (tool, label, index) = match app.repo_section {
1563 RepoSection::Issues => ("get_issue", "Issue", app.repo_issue_index),
1564 RepoSection::PullRequests => ("get_pull_request", "Pull request", app.repo_pr_index),
1565 };
1566 let RepoData::Ready(items) = app.repo_list() else {
1567 return;
1568 };
1569 let Some(item) = items.get(index) else {
1570 return;
1571 };
1572
1573 let number = item.number;
1574 let target = RepoTarget {
1575 section: app.repo_section,
1576 number,
1577 };
1578 app.repo_detail = Some(RepoDetail {
1579 target,
1580 title: format!("{label} #{number} — {}", item.title),
1581 text: None,
1582 scroll: 0,
1583 });
1584
1585 let Some(tx) = app.repo_tx.clone() else {
1586 return;
1587 };
1588 let tool = crate::mcp::official_tool_name(tool);
1589 let args = serde_json::json!({ "repo": repo, "number": number }).to_string();
1590 tokio::spawn(async move {
1591 let text = crate::mcp::call_tool(&tool, &args).await;
1592 let _ = tx.send(RepoUpdate::Detail {
1593 target,
1594 text: super::format_repo_detail(&text),
1595 });
1596 });
1597 }
1598
1599 /// Keys on the History and Cloud tabs (the Session tab keeps `handle_key`).
1600 /// Tab/Esc navigation is handled earlier in the event loop; this gets the
1601 /// rest.
1602 async fn handle_tab_key(app: &mut App, key: KeyEvent, engine: &Arc<ChatEngine>) {
1603 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1604 if ctrl && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')) {
1605 app.quit = true;
1606 return;
1607 }
1608
1609 match app.active_tab {
1610 Tab::Session => {}
1611 Tab::History => match key.code {
1612 KeyCode::Up => {
1613 app.history_pending_delete = None;
1614 app.history_index = app.history_index.saturating_sub(1);
1615 }
1616 KeyCode::Down => {
1617 app.history_pending_delete = None;
1618 if app.history_index + 1 < app.history_sessions.len() {
1619 app.history_index += 1;
1620 }
1621 }
1622 KeyCode::Char('r') => {
1623 app.refresh_history();
1624 app.history_notice = None;
1625 }
1626 KeyCode::Char('d') => {
1627 let Some(id) = app.selected_session().map(|e| e.id.clone()) else {
1628 return;
1629 };
1630 if app.history_pending_delete.as_deref() == Some(id.as_str()) {
1631 crate::session_store::delete(&id);
1632 app.refresh_history();
1633 app.history_notice = Some(format!("Deleted session '{id}'."));
1634 } else {
1635 app.history_pending_delete = Some(id);
1636 }
1637 }
1638 KeyCode::Enter => {
1639 app.history_pending_delete = None;
1640 let Some(id) = app.selected_session().map(|e| e.id.clone()) else {
1641 return;
1642 };
1643 match crate::session_store::load(&id) {
1644 Some(history) if !history.is_empty() => {
1645 let restored = history.len();
1646 app.backend.restore_history(history).await;
1647 app.messages.push(ChatMessage::system(format!(
1648 "Restored {restored} message(s) from session '{id}'. \
1649 The model remembers the conversation; the scrollback \
1650 above does not replay it."
1651 )));
1652 app.active_tab = Tab::Session;
1653 }
1654 _ => {
1655 app.history_notice = Some(format!(
1656 "Could not restore '{id}': the session is empty or unreadable."
1657 ));
1658 }
1659 }
1660 }
1661 // Any other key cancels a pending delete confirmation.
1662 _ => app.history_pending_delete = None,
1663 },
1664 Tab::Repo => {
1665 // Detail view open: Up/Down scroll it; Esc (handled in the
1666 // event loop) closes it back to the list.
1667 if app.repo_detail.is_some() {
1668 match key.code {
1669 KeyCode::Up => {
1670 if let Some(detail) = app.repo_detail.as_mut() {
1671 detail.scroll = detail.scroll.saturating_sub(1);
1672 }
1673 }
1674 KeyCode::Down => {
1675 if let Some(detail) = app.repo_detail.as_mut() {
1676 detail.scroll = detail.scroll.saturating_add(1);
1677 }
1678 }
1679 // "r refresh" (as the footer advertises) re-fetches the
1680 // item on screen, rather than closing the view back to
1681 // the list as refresh_repo would.
1682 KeyCode::Char('r') => open_repo_detail(app),
1683 _ => {}
1684 }
1685 return;
1686 }
1687 match key.code {
1688 KeyCode::Left | KeyCode::Right => {
1689 app.repo_section = app.repo_section.other();
1690 }
1691 KeyCode::Char('i') => app.repo_section = RepoSection::Issues,
1692 KeyCode::Char('p') => app.repo_section = RepoSection::PullRequests,
1693 KeyCode::Up => {
1694 let index = app.repo_index_mut();
1695 *index = index.saturating_sub(1);
1696 }
1697 KeyCode::Down => {
1698 let max = app.repo_list().len().saturating_sub(1);
1699 let index = app.repo_index_mut();
1700 *index = (*index + 1).min(max);
1701 }
1702 KeyCode::Enter => open_repo_detail(app),
1703 KeyCode::Char('r') => refresh_repo(app),
1704 _ => {}
1705 }
1706 }
1707 Tab::Cloud => match key.code {
1708 KeyCode::Char('l') => {
1709 let enabled = !crate::settings::local_inference_enabled();
1710 match crate::settings::set_local_inference(enabled) {
1711 Ok(()) => refresh_cloud(app, engine),
1712 Err(error) => {
1713 app.cloud_lines
1714 .get_or_insert_with(Vec::new)
1715 .push(format!("error: could not save the setting: {error}"));
1716 }
1717 }
1718 }
1719 KeyCode::Char('r') => refresh_cloud(app, engine),
1720 _ => {}
1721 },
1722 }
1723 }
1724
1725 fn render_tab_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1726 let titles: Vec<Line> = Tab::titles(app.repo_visible())
1727 .into_iter()
1728 .map(Line::from)
1729 .collect();
1730 let tabs = Tabs::new(titles)
1731 .select(app.active_tab.index(app.repo_visible()))
1732 .style(Style::default().fg(Color::DarkGray))
1733 .highlight_style(
1734 Style::default()
1735 .fg(Color::Black)
1736 .bg(Color::Green)
1737 .add_modifier(Modifier::BOLD),
1738 );
1739 frame.render_widget(tabs, area);
1740 }
1741
1742 fn render_history_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1743 let block = Block::default()
1744 .borders(Borders::ALL)
1745 .border_style(Style::default().fg(Color::DarkGray))
1746 .title(" saved sessions ");
1747 let inner = block.inner(area);
1748 frame.render_widget(block, area);
1749
1750 let mut lines: Vec<Line> = Vec::new();
1751
1752 if app.history_sessions.is_empty() {
1753 lines.push(Line::from(Span::styled(
1754 " No saved sessions yet. Sessions are saved after each turn.",
1755 Style::default().fg(Color::DarkGray),
1756 )));
1757 } else {
1758 let now = std::time::SystemTime::now();
1759 for (index, entry) in app.history_sessions.iter().enumerate() {
1760 let selected = index == app.history_index;
1761 let marker = if selected { "› " } else { " " };
1762 let age = now.duration_since(entry.modified).ok();
1763 let row = super::history_row(&entry.id, age, entry.message_count);
1764 let style = if selected {
1765 Style::default().fg(Color::Black).bg(Color::Green)
1766 } else {
1767 Style::default().fg(Color::White)
1768 };
1769 lines.push(Line::from(Span::styled(format!("{marker}{row}"), style)));
1770 }
1771 }
1772
1773 if let Some(ref id) = app.history_pending_delete {
1774 lines.push(Line::from(""));
1775 lines.push(Line::from(Span::styled(
1776 format!(" Delete '{id}'? Press d again to confirm — any other key cancels."),
1777 Style::default().fg(Color::Yellow),
1778 )));
1779 } else if let Some(ref notice) = app.history_notice {
1780 lines.push(Line::from(""));
1781 lines.push(Line::from(Span::styled(
1782 format!(" {notice}"),
1783 Style::default().fg(Color::Yellow),
1784 )));
1785 }
1786
1787 // Keep the selection visible when the list outgrows the pane.
1788 let inner_height = inner.height as usize;
1789 let scroll = app
1790 .history_index
1791 .saturating_sub(inner_height.saturating_sub(1)) as u16;
1792 frame.render_widget(
1793 Paragraph::new(lines)
1794 .wrap(Wrap { trim: false })
1795 .scroll((scroll, 0)),
1796 inner,
1797 );
1798 }
1799
1800 fn render_repo_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1801 let block = Block::default()
1802 .borders(Borders::ALL)
1803 .border_style(Style::default().fg(Color::DarkGray))
1804 .title(format!(" {} ", app.repo.as_deref().unwrap_or("repo")));
1805 let inner = block.inner(area);
1806 frame.render_widget(block, area);
1807
1808 // Detail view: a full-tab scrollable paragraph; Esc goes back.
1809 if let Some(ref detail) = app.repo_detail {
1810 let mut lines: Vec<Line> = vec![
1811 Line::from(Span::styled(
1812 format!(" {}", detail.title),
1813 Style::default()
1814 .fg(Color::Green)
1815 .add_modifier(Modifier::BOLD),
1816 )),
1817 Line::from(""),
1818 ];
1819 match detail.text {
1820 None => lines.push(Line::from(Span::styled(
1821 " fetching…",
1822 Style::default().fg(Color::DarkGray),
1823 ))),
1824 Some(ref text) => {
1825 for text_line in text.lines() {
1826 lines.push(Line::from(Span::styled(
1827 format!(" {text_line}"),
1828 Style::default().fg(Color::White),
1829 )));
1830 }
1831 }
1832 }
1833 frame.render_widget(
1834 Paragraph::new(lines)
1835 .wrap(Wrap { trim: false })
1836 .scroll((detail.scroll, 0)),
1837 inner,
1838 );
1839 return;
1840 }
1841
1842 // Section header: Issues ←→ Pull requests.
1843 let selected_style = Style::default()
1844 .fg(Color::Black)
1845 .bg(Color::Green)
1846 .add_modifier(Modifier::BOLD);
1847 let idle_style = Style::default().fg(Color::DarkGray);
1848 let issues_active = app.repo_section == RepoSection::Issues;
1849 let mut lines: Vec<Line> = vec![
1850 Line::from(vec![
1851 Span::raw(" "),
1852 Span::styled(
1853 " Issues ",
1854 if issues_active {
1855 selected_style
1856 } else {
1857 idle_style
1858 },
1859 ),
1860 Span::raw(" "),
1861 Span::styled(
1862 " Pull requests ",
1863 if issues_active {
1864 idle_style
1865 } else {
1866 selected_style
1867 },
1868 ),
1869 ]),
1870 Line::from(""),
1871 ];
1872 // Rows above the first list entry, for the keep-selection-visible math.
1873 let header_rows = lines.len();
1874
1875 let (data, index) = match app.repo_section {
1876 RepoSection::Issues => (&app.repo_issues, app.repo_issue_index),
1877 RepoSection::PullRequests => (&app.repo_prs, app.repo_pr_index),
1878 };
1879
1880 match data {
1881 RepoData::NotFetched | RepoData::Loading => {
1882 lines.push(Line::from(Span::styled(
1883 " fetching…",
1884 Style::default().fg(Color::DarkGray),
1885 )));
1886 }
1887 RepoData::Failed(error) => {
1888 for error_line in error.lines() {
1889 lines.push(Line::from(Span::styled(
1890 format!(" {error_line}"),
1891 Style::default().fg(Color::Red),
1892 )));
1893 }
1894 lines.push(Line::from(""));
1895 lines.push(Line::from(Span::styled(
1896 " These lists come from the official sigit.si MCP server. Sign in \
1897 with /login, and make sure SIGIT_MCP / SIGIT_MCP_OFFICIAL are not \
1898 set to off. Press r to retry.",
1899 Style::default().fg(Color::Yellow),
1900 )));
1901 }
1902 RepoData::Ready(items) if items.is_empty() => {
1903 lines.push(Line::from(Span::styled(
1904 if issues_active {
1905 " No issues."
1906 } else {
1907 " No pull requests."
1908 },
1909 Style::default().fg(Color::DarkGray),
1910 )));
1911 }
1912 RepoData::Ready(items) => {
1913 for (row_index, item) in items.iter().enumerate() {
1914 let selected = row_index == index;
1915 let marker = if selected { "› " } else { " " };
1916 let style = if selected {
1917 Style::default().fg(Color::Black).bg(Color::Green)
1918 } else {
1919 Style::default().fg(Color::White)
1920 };
1921 lines.push(Line::from(Span::styled(
1922 format!("{marker}{}", super::repo_row(item)),
1923 style,
1924 )));
1925 }
1926 }
1927 }
1928
1929 // Keep the selection visible when the list outgrows the pane (same
1930 // approach as the History tab, offset by the section header rows).
1931 let inner_height = inner.height as usize;
1932 let scroll = (index + header_rows).saturating_sub(inner_height.saturating_sub(1)) as u16;
1933 frame.render_widget(
1934 Paragraph::new(lines)
1935 .wrap(Wrap { trim: false })
1936 .scroll((scroll, 0)),
1937 inner,
1938 );
1939 }
1940
1941 fn render_cloud_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1942 let block = Block::default()
1943 .borders(Borders::ALL)
1944 .border_style(Style::default().fg(Color::DarkGray))
1945 .title(" siGit Code Cloud ");
1946 let inner = block.inner(area);
1947 frame.render_widget(block, area);
1948
1949 let mut lines: Vec<Line> = Vec::new();
1950 match app.cloud_lines {
1951 None => lines.push(Line::from(Span::styled(
1952 " fetching status…",
1953 Style::default().fg(Color::DarkGray),
1954 ))),
1955 Some(ref cloud_lines) => {
1956 for text in cloud_lines {
1957 lines.push(Line::from(Span::styled(
1958 format!(" {text}"),
1959 Style::default().fg(Color::White),
1960 )));
1961 }
1962 }
1963 }
1964 lines.push(Line::from(""));
1965 lines.push(Line::from(Span::styled(
1966 " Note: toggling Local Inference takes effect for the next model \
1967 selection (/models); the running backend is not swapped.",
1968 Style::default().fg(Color::DarkGray),
1969 )));
1970
1971 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
1972 }
1973
1974 // ── Slash commands ────────────────────────────────────────────────────────
1975
1976 enum SlashCommand {
1977 Help,
1978 Clear,
1979 Status,
1980 /// picker UI, or jump straight to model N
1981 Models(Option<usize>),
1982 /// toggle on-device inference mode. `Some(true/false)` sets it, `None` flips it.
1983 Local(Option<bool>),
1984 /// List discovered Agent Skills.
1985 Skills,
1986 /// List configured subagent types (`.sigit/agents/*.md`) for `task`.
1987 Agents,
1988 /// List discovered user-defined commands (`.sigit/commands/*.md`).
1989 Commands,
1990 /// List configured MCP servers and their tools.
1991 Mcp,
1992 /// explicitly load the selected (or default) on-device model
1993 Load,
1994 /// `/login` (browser) or `/login <email> <password>` — the raw argument,
1995 /// parsed when executed.
1996 Login(Option<String>),
1997 Logout,
1998 Whoami,
1999 /// Toggle plan mode (research only; mutating tools are denied with a
2000 /// prompt to present a plan). `Some(true/false)` sets it, `None` flips it.
2001 Plan(Option<bool>),
2002 /// Show the effective permission policy for this session.
2003 Permissions,
2004 /// Expand or collapse model reasoning on rendered messages.
2005 /// `Some(true/false)` sets it, `None` flips it.
2006 Thinking(Option<bool>),
2007 /// Expand or collapse all tool-call entries in the transcript.
2008 /// `Some(true/false)` sets it, `None` flips it. TUI-only.
2009 Tools(Option<bool>),
2010 /// Summarize-and-shrink the conversation history on demand.
2011 Compact,
2012 /// Restore the saved TUI session from disk.
2013 Resume,
2014 /// Generate (or improve) the repo's AGENTS.md by running an agent
2015 /// turn with [`crate::instructions::INIT_PROMPT`] as the user text.
2016 Init,
2017 Exit,
2018 /// An unrecognized command word, plus whatever followed it on the
2019 /// line — a caller may still resolve it as a user-defined command
2020 /// before falling back to "unknown command".
2021 Unknown(String, Option<String>),
2022 }
2023
2024 fn parse_slash(input: &str) -> Option<SlashCommand> {
2025 let trimmed = input.trim();
2026 if !trimmed.starts_with('/') {
2027 return None;
2028 }
2029 let mut parts = trimmed.splitn(2, char::is_whitespace);
2030 let cmd = parts.next().unwrap_or("");
2031 let arg = parts.next().map(|s| s.trim());
2032 Some(match cmd {
2033 "/help" => SlashCommand::Help,
2034 "/clear" => SlashCommand::Clear,
2035 "/status" => SlashCommand::Status,
2036 "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),
2037 "/local" => SlashCommand::Local(parse_on_off(arg)),
2038 "/skills" => SlashCommand::Skills,
2039 "/agents" => SlashCommand::Agents,
2040 "/commands" => SlashCommand::Commands,
2041 "/mcp" => SlashCommand::Mcp,
2042 "/load" => SlashCommand::Load,
2043 "/login" => SlashCommand::Login(arg.map(str::to_string)),
2044 "/logout" => SlashCommand::Logout,
2045 "/whoami" => SlashCommand::Whoami,
2046 "/plan" => SlashCommand::Plan(parse_on_off(arg)),
2047 "/permissions" => SlashCommand::Permissions,
2048 "/thinking" => SlashCommand::Thinking(parse_on_off(arg)),
2049 "/tools" => SlashCommand::Tools(parse_on_off(arg)),
2050 "/compact" => SlashCommand::Compact,
2051 "/resume" => SlashCommand::Resume,
2052 "/init" => SlashCommand::Init,
2053 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
2054 other => SlashCommand::Unknown(other.to_string(), arg.map(str::to_string)),
2055 })
2056 }
2057
2058 /// `on`/`off` (and synonyms) → `Some(bool)`; missing or unrecognized → `None`
2059 /// (meaning "toggle the current value").
2060 fn parse_on_off(arg: Option<&str>) -> Option<bool> {
2061 match arg.map(|s| s.trim().to_ascii_lowercase())?.as_str() {
2062 "on" | "true" | "1" | "yes" => Some(true),
2063 "off" | "false" | "0" | "no" => Some(false),
2064 _ => None,
2065 }
2066 }
2067
2068 // ── Rendering ─────────────────────────────────────────────────────────────
2069
2070 fn render(frame: &mut Frame, app: &mut App) {
2071 let area = frame.area();
2072
2073 if app.is_loading {
2074 let zones = Layout::vertical([
2075 Constraint::Length(1),
2076 Constraint::Min(1),
2077 Constraint::Length(1),
2078 ])
2079 .split(area);
2080 render_loading_title(frame, app, zones[0]);
2081 render_loading(frame, app, zones[1]);
2082 render_loading_footer(frame, zones[2]);
2083 return;
2084 }
2085
2086 match app.active_tab {
2087 Tab::Session => {
2088 let zones = Layout::vertical([
2089 Constraint::Length(1),
2090 Constraint::Length(1),
2091 Constraint::Min(1),
2092 Constraint::Length(3),
2093 Constraint::Length(1),
2094 ])
2095 .split(area);
2096
2097 render_tab_bar(frame, app, zones[0]);
2098 render_title(frame, app, zones[1]);
2099 render_messages(frame, app, zones[2]);
2100 render_input(frame, app, zones[3]);
2101 render_footer(frame, app, zones[4]);
2102 }
2103 // No input pane on the non-chat tabs: the Tab key always cycles.
2104 Tab::History | Tab::Repo | Tab::Cloud => {
2105 let zones = Layout::vertical([
2106 Constraint::Length(1),
2107 Constraint::Length(1),
2108 Constraint::Min(1),
2109 Constraint::Length(1),
2110 ])
2111 .split(area);
2112
2113 render_tab_bar(frame, app, zones[0]);
2114 render_title(frame, app, zones[1]);
2115 match app.active_tab {
2116 Tab::History => render_history_tab(frame, app, zones[2]),
2117 Tab::Repo => render_repo_tab(frame, app, zones[2]),
2118 _ => render_cloud_tab(frame, app, zones[2]),
2119 }
2120 render_footer(frame, app, zones[3]);
2121 }
2122 }
2123
2124 if app.show_model_picker {
2125 render_model_picker(frame, app, area);
2126 }
2127 }
2128
2129 fn render_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2130 let model_label = format!(" siGit — {} ", app.current_model_name);
2131 let tool_label = if app.tool_calling {
2132 " [tools on] "
2133 } else {
2134 " [tools off] "
2135 };
2136 let window = app.context_window_tokens();
2137 let used = app.context_tokens_used.unwrap_or(0) as u64;
2138 let line = Line::from(vec![
2139 Span::styled(
2140 model_label,
2141 Style::default()
2142 .fg(Color::Black)
2143 .bg(Color::Green)
2144 .add_modifier(Modifier::BOLD),
2145 ),
2146 Span::styled(
2147 tool_label,
2148 Style::default().fg(Color::Black).bg(Color::DarkGray),
2149 ),
2150 Span::styled(
2151 format!(" {} ", context_gauge_label(used, window)),
2152 Style::default()
2153 .fg(Color::Black)
2154 .bg(context_gauge_color(used, window)),
2155 ),
2156 ]);
2157 frame.render_widget(Paragraph::new(line), area);
2158 }
2159
2160 /// `"84% · 168K/200K ctx"` — the share of the active model's context window
2161 /// the conversation occupies, as of the last completed turn.
2162 fn context_gauge_label(used: u64, window: u64) -> String {
2163 if window == 0 {
2164 return "ctx —".to_string();
2165 }
2166 let percent = (used.saturating_mul(100) / window).min(100);
2167 format!(
2168 "{percent}% · {}/{}",
2169 compact_tokens(used),
2170 crate::models::format_context_window(window)
2171 )
2172 }
2173
2174 /// Warn as the window fills: auto-compaction kicks in near the budget, so
2175 /// the colour is a heads-up that the next turn may be summarized.
2176 fn context_gauge_color(used: u64, window: u64) -> Color {
2177 if window == 0 {
2178 return Color::DarkGray;
2179 }
2180 match used.saturating_mul(100) / window {
2181 0..=69 => Color::DarkGray,
2182 70..=89 => Color::Yellow,
2183 _ => Color::Red,
2184 }
2185 }
2186
2187 fn compact_tokens(tokens: u64) -> String {
2188 if tokens >= 1_000 {
2189 format!("{}K", tokens / 1_000)
2190 } else {
2191 tokens.to_string()
2192 }
2193 }
2194
2195 fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2196 const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
2197 let spin = SPINNER[(app.load_tick as usize) % SPINNER.len()];
2198 let label = format!(" siGit {} loading {}… ", spin, app.load_model_name);
2199 let line = Line::from(Span::styled(
2200 label,
2201 Style::default()
2202 .fg(Color::Black)
2203 .bg(Color::Green)
2204 .add_modifier(Modifier::BOLD),
2205 ));
2206 frame.render_widget(Paragraph::new(line), area);
2207 }
2208
2209 fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2210 let elapsed = app.load_start.elapsed().as_secs();
2211 let elapsed_str = if elapsed < 60 {
2212 format!("{}s", elapsed)
2213 } else {
2214 format!("{}m {}s", elapsed / 60, elapsed % 60)
2215 };
2216
2217 let content = if let Some(ref err) = app.load_error {
2218 format!(
2219 "\n\n ✗ Failed to load model after {}.\n\n {}\n\n Press Ctrl+C to exit.",
2220 elapsed_str, err
2221 )
2222 } else {
2223 format!(
2224 "\n\n Loading model, please wait… ({})\n\n The model is being initialised. This may take a moment on first run.",
2225 elapsed_str
2226 )
2227 };
2228
2229 let style = if app.load_error.is_some() {
2230 Style::default().fg(Color::Red)
2231 } else {
2232 Style::default().fg(Color::White)
2233 };
2234
2235 frame.render_widget(
2236 Paragraph::new(content)
2237 .style(style)
2238 .wrap(Wrap { trim: false }),
2239 area,
2240 );
2241 }
2242
2243 fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) {
2244 let line = Line::from(vec![
2245 Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
2246 Span::styled(" quit", Style::default().fg(Color::DarkGray)),
2247 ]);
2248 frame.render_widget(Paragraph::new(line), area);
2249 }
2250
2251 fn render_messages(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2252 let inner_width = area.width.saturating_sub(2);
2253 let inner_height = area.height.saturating_sub(2);
2254
2255 let block = Block::default()
2256 .borders(Borders::ALL)
2257 .border_style(Style::default().fg(Color::DarkGray));
2258
2259 let inner = block.inner(area);
2260 frame.render_widget(block, area);
2261
2262 let mut lines: Vec<Line> = Vec::new();
2263
2264 for msg in &app.messages {
2265 render_chat_message(
2266 &mut lines,
2267 msg,
2268 inner_width as usize,
2269 app.tools_expanded,
2270 app.show_thinking,
2271 );
2272 }
2273
2274 let streamed_visible = app.visible_stream();
2275 if !streamed_visible.is_empty() {
2276 let fake = ChatMessage {
2277 role: Role::Assistant,
2278 text: streamed_visible,
2279 think_block: None,
2280 tool_detail: None,
2281 tool_result: None,
2282 };
2283 render_chat_message(
2284 &mut lines,
2285 &fake,
2286 inner_width as usize,
2287 app.tools_expanded,
2288 app.show_thinking,
2289 );
2290 if app.blink_on
2291 && let Some(last) = lines.last_mut()
2292 {
2293 last.spans
2294 .push(Span::styled("▋", Style::default().fg(Color::Green)));
2295 }
2296 }
2297
2298 if app.thinking {
2299 lines.push(Line::from(Span::styled(
2300 format!(" {} thinking…", app.thinking_frame()),
2301 Style::default().fg(Color::DarkGray),
2302 )));
2303 // While the stream buffer holds only reasoning, show its live tail
2304 // under the label so the user watches the model think.
2305 let tail_width = (inner_width as usize).saturating_sub(4).max(8);
2306 for tail_line in super::streaming_think_tail(&app.stream_buf, tail_width, 3) {
2307 lines.push(Line::from(Span::styled(
2308 format!(" {tail_line}"),
2309 Style::default()
2310 .fg(Color::DarkGray)
2311 .add_modifier(Modifier::DIM | Modifier::ITALIC),
2312 )));
2313 }
2314 } else if app.switching_model {
2315 // Once the weights have fully landed on disk, swap the spinner for a
2316 // checkmark so it's clear the download finished and we're now loading
2317 // the model into memory (which can still take a while).
2318 let download_complete = matches!(
2319 app.download_progress,
2320 Some((downloaded, expected)) if expected > 0 && downloaded >= expected
2321 );
2322
2323 if download_complete {
2324 let size_str = app
2325 .download_progress
2326 .map(|(_, expected)| format!(" ({})", format_size_human(expected)))
2327 .unwrap_or_default();
2328 lines.push(Line::from(vec![
2329 Span::styled(" ✓ ", Style::default().fg(Color::Green)),
2330 Span::styled(
2331 format!("model downloaded{size_str} — loading into memory…"),
2332 Style::default().fg(Color::DarkGray),
2333 ),
2334 ]));
2335 } else {
2336 let progress_str = if let Some((downloaded, expected)) = app.download_progress {
2337 if expected > 0 {
2338 let pct = (downloaded as f64 / expected as f64 * 100.0).min(100.0) as u8;
2339 let dl_str = format_size_human(downloaded.min(expected));
2340 let ex_str = format_size_human(expected);
2341 format!(" — {dl_str} / {ex_str} ({pct}%)")
2342 } else if downloaded > 0 {
2343 format!(" — {} downloaded", format_size_human(downloaded))
2344 } else {
2345 String::new()
2346 }
2347 } else {
2348 String::new()
2349 };
2350 lines.push(Line::from(Span::styled(
2351 format!(" {} switching model{progress_str}…", app.switching_frame()),
2352 Style::default().fg(Color::DarkGray),
2353 )));
2354 }
2355 }
2356
2357 // Always pin to the bottom so the latest message stays visible. There is
2358 // no scrollback, so we just need the exact number of wrapped rows the
2359 // paragraph occupies at this width — `line_count` runs the same
2360 // WordWrapper as rendering, so it never diverges from what's drawn (an
2361 // estimate would, e.g. by forgetting the `<think>` box lines, and scroll
2362 // too little — the bug this fixes).
2363 let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
2364 let total_lines = paragraph.line_count(inner_width) as u16;
2365 let scroll = total_lines.saturating_sub(inner_height);
2366
2367 frame.render_widget(paragraph.scroll((scroll, 0)), inner);
2368 }
2369
2370 fn render_chat_message(
2371 lines: &mut Vec<Line<'static>>,
2372 msg: &ChatMessage,
2373 _width: usize,
2374 tools_expanded: bool,
2375 show_thinking: bool,
2376 ) {
2377 match msg.role {
2378 Role::Tool => {
2379 let dim = Style::default()
2380 .fg(Color::Rgb(132, 132, 145))
2381 .add_modifier(Modifier::DIM);
2382 for entry_line in super::tool_entry_lines(
2383 &msg.text,
2384 msg.tool_detail.as_deref().unwrap_or(""),
2385 msg.tool_result.as_deref(),
2386 tools_expanded,
2387 ) {
2388 lines.push(Line::from(Span::styled(format!(" {entry_line}"), dim)));
2389 }
2390 }
2391 Role::Banner => {
2392 let palette = [
2393 Color::Red,
2394 Color::Yellow,
2395 Color::Green,
2396 Color::Cyan,
2397 Color::Blue,
2398 Color::Magenta,
2399 ];
2400 let mut spans = Vec::new();
2401 for (i, ch) in msg.text.chars().enumerate() {
2402 let color = palette[i % palette.len()];
2403 spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
2404 }
2405 lines.push(Line::from(spans));
2406 }
2407 Role::System => {
2408 for text_line in msg.text.split('\n') {
2409 let trimmed = text_line.trim();
2410 let (prefix, body) = if trimmed.is_empty() {
2411 ("", "")
2412 } else {
2413 (" · ", trimmed)
2414 };
2415
2416 lines.push(Line::from(vec![
2417 Span::styled(
2418 prefix.to_string(),
2419 Style::default()
2420 .fg(Color::Rgb(90, 90, 98))
2421 .add_modifier(Modifier::DIM),
2422 ),
2423 Span::styled(
2424 body.to_string(),
2425 Style::default()
2426 .fg(Color::Rgb(132, 132, 145))
2427 .add_modifier(Modifier::ITALIC | Modifier::DIM),
2428 ),
2429 ]));
2430 }
2431 }
2432 Role::User => {
2433 let prefix = Span::styled(
2434 "you > ".to_string(),
2435 Style::default()
2436 .fg(Color::Green)
2437 .add_modifier(Modifier::BOLD),
2438 );
2439 let mut first = true;
2440 for text_line in msg.text.split('\n') {
2441 if first {
2442 lines.push(Line::from(vec![
2443 prefix.clone(),
2444 Span::raw(text_line.to_string()),
2445 ]));
2446 first = false;
2447 } else {
2448 lines.push(Line::from(Span::raw(format!(" {text_line}"))));
2449 }
2450 }
2451 }
2452 Role::Assistant => {
2453 if let Some(ref think) = msg.think_block {
2454 if show_thinking {
2455 // /thinking on — the full reasoning, dim italic, with a
2456 // blank line separating it from the answer below.
2457 lines.push(Line::from(Span::styled(
2458 " · thinking".to_string(),
2459 Style::default()
2460 .fg(Color::DarkGray)
2461 .add_modifier(Modifier::DIM),
2462 )));
2463 for think_line in think.split('\n') {
2464 lines.push(Line::from(Span::styled(
2465 format!(" {think_line}"),
2466 Style::default()
2467 .fg(Color::DarkGray)
2468 .add_modifier(Modifier::DIM | Modifier::ITALIC),
2469 )));
2470 }
2471 lines.push(Line::from(""));
2472 } else {
2473 // collapsed — one dim line noting reasoning exists
2474 lines.push(Line::from(Span::styled(
2475 format!(" {}", super::think_indicator(think)),
2476 Style::default()
2477 .fg(Color::DarkGray)
2478 .add_modifier(Modifier::DIM),
2479 )));
2480 }
2481 }
2482
2483 let prefix = Span::styled(
2484 "siGit > ".to_string(),
2485 Style::default()
2486 .fg(Color::Cyan)
2487 .add_modifier(Modifier::BOLD),
2488 );
2489 let body_style = Style::default();
2490 let bold_style = Style::default().add_modifier(Modifier::BOLD);
2491 let mut first = true;
2492 for text_line in msg.text.split('\n') {
2493 if first {
2494 let mut spans = vec![prefix.clone()];
2495 spans.extend(rich_text_spans(text_line, body_style, bold_style));
2496 lines.push(Line::from(spans));
2497 first = false;
2498 } else {
2499 let mut spans = vec![Span::raw(" ".to_string())];
2500 spans.extend(rich_text_spans(text_line, body_style, bold_style));
2501 lines.push(Line::from(spans));
2502 }
2503 }
2504 }
2505 }
2506 }
2507
2508 fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2509 let block = Block::default()
2510 .borders(Borders::ALL)
2511 .border_style(Style::default().fg(Color::DarkGray))
2512 .title(" message ");
2513
2514 let inner = block.inner(area);
2515 frame.render_widget(block, area);
2516
2517 let display = app.input.clone();
2518 frame.render_widget(
2519 Paragraph::new(display.clone()).wrap(Wrap { trim: false }),
2520 inner,
2521 );
2522
2523 let col = (app.cursor as u16) % inner.width;
2524 let row = (app.cursor as u16) / inner.width;
2525 frame.set_cursor_position(Position {
2526 x: inner.x + col,
2527 y: inner.y + row,
2528 });
2529 }
2530
2531 fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2532 let key_style = Style::default().fg(Color::Black).bg(Color::Green);
2533 let label_style = Style::default().fg(Color::DarkGray);
2534
2535 if app.active_tab != Tab::Session {
2536 let hints: &[(&str, &str)] = match app.active_tab {
2537 Tab::History => &[
2538 (" ↑/↓ ", " select "),
2539 (" Enter ", " resume "),
2540 (" d ", " delete "),
2541 (" r ", " refresh "),
2542 (" Tab ", " next tab "),
2543 (" Esc ", " session"),
2544 ],
2545 Tab::Repo if app.repo_detail.is_some() => &[
2546 (" ↑/↓ ", " scroll "),
2547 (" Esc ", " back "),
2548 (" r ", " refresh "),
2549 (" Tab ", " next tab"),
2550 ],
2551 Tab::Repo => &[
2552 (" ←/→ i/p ", " section "),
2553 (" ↑/↓ ", " select "),
2554 (" Enter ", " open "),
2555 (" r ", " refresh "),
2556 (" Tab ", " next tab "),
2557 (" Esc ", " session"),
2558 ],
2559 _ => &[
2560 (" l ", " toggle local inference "),
2561 (" r ", " refresh "),
2562 (" Tab ", " next tab "),
2563 (" Esc ", " session"),
2564 ],
2565 };
2566 let mut spans = Vec::new();
2567 for (key, label) in hints {
2568 spans.push(Span::styled(key.to_string(), key_style));
2569 spans.push(Span::styled(label.to_string(), label_style));
2570 }
2571 frame.render_widget(Paragraph::new(Line::from(spans)), area);
2572 return;
2573 }
2574
2575 let mut spans = vec![
2576 Span::styled(" Enter ", key_style),
2577 Span::styled(" send ", label_style),
2578 Span::styled(" Tab ", key_style),
2579 Span::styled(" tabs ", label_style),
2580 Span::styled(
2581 " /help ",
2582 Style::default().fg(Color::Black).bg(Color::DarkGray),
2583 ),
2584 Span::styled(" commands ", Style::default().fg(Color::DarkGray)),
2585 Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
2586 Span::styled(" quit", Style::default().fg(Color::DarkGray)),
2587 ];
2588
2589 if let Some((tool, _)) = &app.pending_approval {
2590 spans.push(Span::styled(
2591 format!(" allow {tool}? [y]es · [a]lways · [n]o"),
2592 Style::default().fg(Color::Yellow),
2593 ));
2594 } else if app.thinking || app.switching_model || app.is_streaming() {
2595 spans.push(Span::styled(
2596 " (busy — Ctrl+C to cancel)",
2597 Style::default().fg(Color::Yellow),
2598 ));
2599 }
2600
2601 frame.render_widget(Paragraph::new(Line::from(spans)), area);
2602 }
2603
2604 fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
2605 if key.kind != KeyEventKind::Press {
2606 return None;
2607 }
2608
2609 if app.show_model_picker {
2610 match key.code {
2611 KeyCode::Esc => {
2612 app.close_model_picker();
2613 return None;
2614 }
2615 KeyCode::Up => {
2616 app.move_model_picker_up();
2617 return None;
2618 }
2619 KeyCode::Down => {
2620 app.move_model_picker_down();
2621 return None;
2622 }
2623 KeyCode::Enter => {
2624 return Some(format!("/models {}", app.model_picker_index + 1));
2625 }
2626 _ => return None,
2627 }
2628 }
2629
2630 match key.code {
2631 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2632 app.quit = true;
2633 None
2634 }
2635 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2636 app.quit = true;
2637 None
2638 }
2639 KeyCode::Enter => {
2640 if app.input.trim().is_empty() {
2641 return None;
2642 }
2643 let text = std::mem::take(&mut app.input);
2644 app.cursor = 0;
2645 Some(text)
2646 }
2647 KeyCode::Backspace => {
2648 if app.cursor > 0 {
2649 app.cursor -= 1;
2650 app.input.remove(app.cursor);
2651 }
2652 None
2653 }
2654 KeyCode::Delete => {
2655 if app.cursor < app.input.len() {
2656 app.input.remove(app.cursor);
2657 }
2658 None
2659 }
2660 KeyCode::Left => {
2661 app.cursor = app.cursor.saturating_sub(1);
2662 None
2663 }
2664 KeyCode::Right => {
2665 if app.cursor < app.input.len() {
2666 app.cursor += 1;
2667 }
2668 None
2669 }
2670 KeyCode::Home => {
2671 app.cursor = 0;
2672 None
2673 }
2674 KeyCode::End => {
2675 app.cursor = app.input.len();
2676 None
2677 }
2678 KeyCode::Char(ch) => {
2679 app.input.insert(app.cursor, ch);
2680 app.cursor += 1;
2681 None
2682 }
2683 _ => None,
2684 }
2685 }
2686
2687 // ── Explicit on-device model loading ──────────────────────────────────────
2688
2689 /// The local model `/load` should bring up: the persisted selection if it
2690 /// still resolves to a known model, otherwise the first on-device (non-cloud)
2691 /// entry in the picker.
2692 fn default_local_model_item(app: &App) -> Option<ModelPickerItem> {
2693 if let Some(selected) = crate::setup::load_selected_model()
2694 && let Some(item) = app.model_picker_items.iter().find(|item| {
2695 item.config.model_id == selected.model_id
2696 && item
2697 .config
2698 .files
2699 .iter()
2700 .any(|file| file == &selected.gguf_file)
2701 })
2702 {
2703 return Some(item.clone());
2704 }
2705 app.model_picker_items
2706 .iter()
2707 .find(|item| item.cloud_tier.is_none())
2708 .cloned()
2709 }
2710
2711 /// Load `model` on-device on a dedicated loader thread, routing inference to a
2712 /// fresh `LocalBackend` and driving the switch-progress UI. The caller is
2713 /// responsible for any cloud-tier handling; this path is on-device only.
2714 fn start_local_model_load<B: ratatui::backend::Backend>(
2715 app: &mut App,
2716 model: ModelPickerItem,
2717 engine: Arc<ChatEngine>,
2718 terminal: &mut ratatui::Terminal<B>,
2719 ) {
2720 if model.cache_health == ModelCacheHealth::Incomplete {
2721 app.messages.push(ChatMessage::system(format!(
2722 "error: {} has an incomplete local cache and cannot be selected yet.",
2723 model.display_name
2724 )));
2725 return;
2726 }
2727
2728 // Loading an on-device model puts us in local inference mode.
2729 let _ = crate::settings::set_local_inference(true);
2730
2731 // Route inference on-device; the loader thread below fills the engine the
2732 // LocalBackend reads from.
2733 app.backend = Arc::new(LocalBackend::new(Arc::clone(&engine)));
2734
2735 let loading_msg = if model.cache_health == ModelCacheHealth::NotDownloaded {
2736 format!(
2737 "Downloading and loading {} ({})… this may take a few minutes.",
2738 model.display_name, model.description
2739 )
2740 } else {
2741 format!("Loading {}…", model.display_name)
2742 };
2743
2744 app.messages.push(ChatMessage::system(loading_msg));
2745 terminal.draw(|frame| render(frame, app)).ok();
2746
2747 let (tx, rx) = mpsc::channel(1);
2748 app.model_load_rx = Some(rx);
2749 app.switching_model = true;
2750 app.switching_model_id = Some(model.config.model_id.clone());
2751 // Only show download progress for models not yet cached.
2752 app.download_progress = if model.cache_health == ModelCacheHealth::NotDownloaded {
2753 Some((0, 0))
2754 } else {
2755 None
2756 };
2757
2758 let sampling = SamplingConfig {
2759 max_tokens: Some(model.max_tokens),
2760 ..SamplingConfig::default()
2761 };
2762
2763 // own thread + runtime so block_in_place doesn't starve the TUI loop.
2764 // Fold in the launch directory and its project instruction files
2765 // (AGENTS.md / CLAUDE.md) so the on-device model gets the same always-on
2766 // context the cloud and ACP paths get.
2767 let system_prompt =
2768 crate::with_launch_context(&crate::system_prompt_for_model(model.tool_calling));
2769 let engine_handle = Arc::clone(&engine);
2770 let tool_calling = model.tool_calling;
2771 std::thread::spawn(move || {
2772 let rt = tokio::runtime::Runtime::new().expect("failed to create model-loader runtime");
2773 let update = rt.block_on(async move {
2774 match engine_handle
2775 .load_gguf_model(
2776 model.config.clone(),
2777 Some(system_prompt.to_string()),
2778 Some(sampling),
2779 )
2780 .await
2781 {
2782 Ok(_) => ModelLoadUpdate::Loaded(model.display_name.clone()),
2783 Err(err) => ModelLoadUpdate::Error(err.to_string()),
2784 }
2785 });
2786 // capacity-1 channel, receiver alive while switching
2787 let _ = tx.blocking_send(update);
2788 });
2789 // applied on ModelLoadUpdate::Loaded
2790 app.pending_tool_calling = Some(tool_calling);
2791 }
2792
2793 // ── Slash command execution ───────────────────────────────────────────────
2794
2795 async fn exec_slash<B: ratatui::backend::Backend>(
2796 app: &mut App,
2797 cmd: SlashCommand,
2798 engine: Arc<ChatEngine>,
2799 terminal: &mut ratatui::Terminal<B>,
2800 ) {
2801 match cmd {
2802 SlashCommand::Help => {
2803 app.messages.push(ChatMessage::system(
2804 "/help — show this message\n\
2805 /models — open the model picker\n\
2806 /models N — switch to model N\n\
2807 /local [on|off]— toggle on-device inference mode\n\
2808 /skills — list available Agent Skills\n\
2809 /agents — list configured subagent types (.sigit/agents/*.md)\n\
2810 /commands — list user-defined commands (.sigit/commands/*.md)\n\
2811 /mcp — list MCP servers and their tools\n\
2812 /load — load the selected on-device model\n\
2813 /login — sign in via your browser\n\
2814 /login E P — sign in with an email and password\n\
2815 /logout — sign out\n\
2816 /whoami — show the signed-in account\n\
2817 /plan [on|off] — plan mode: research only, no edits or commands\n\
2818 /permissions — show the tool permission policy\n\
2819 /thinking [on|off] — expand or collapse model reasoning\n\
2820 /tools [on|off]— expand or collapse tool-call details\n\
2821 /compact — summarize and shrink conversation history\n\
2822 /resume — restore the saved session from disk\n\
2823 /init — generate or improve the repo's AGENTS.md\n\
2824 /clear — wipe conversation history\n\
2825 /status — show engine status\n\
2826 /exit — quit chat",
2827 ));
2828 }
2829 SlashCommand::Clear => {
2830 let cleared = engine.clear_history().await;
2831 app.messages.clear();
2832 crate::permissions::reset_session(crate::permissions::TUI_SESSION);
2833 // The saved session must not resurrect what the user just wiped.
2834 crate::session_store::delete(TUI_STORE_SESSION);
2835 app.context_tokens_used = None;
2836 app.messages.push(ChatMessage::system(format!(
2837 "Cleared {cleared} turn(s). History is empty.",
2838 )));
2839 }
2840 SlashCommand::Compact => {
2841 let before = crate::backend::estimate_tokens(&app.backend.history_snapshot().await);
2842 match app
2843 .backend
2844 .compact_history(crate::backend::COMPACT_KEEP_LAST)
2845 .await
2846 {
2847 Ok(()) => {
2848 let snapshot = app.backend.history_snapshot().await;
2849 let after = crate::backend::estimate_tokens(&snapshot);
2850 // Keep the saved session in step with the compacted state.
2851 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot)
2852 {
2853 log::warn!("session save after /compact failed: {error}");
2854 }
2855 app.context_tokens_used = Some(after);
2856 app.messages.push(ChatMessage::system(format!(
2857 "Compacted history: ~{before} → ~{after} tokens (estimated)."
2858 )));
2859 }
2860 Err(error) => {
2861 app.messages
2862 .push(ChatMessage::system(format!("Compaction failed: {error}")));
2863 }
2864 }
2865 }
2866 SlashCommand::Resume => match crate::session_store::load(TUI_STORE_SESSION) {
2867 Some(history) if !history.is_empty() => {
2868 let restored = history.len();
2869 app.context_tokens_used = Some(crate::backend::estimate_tokens(&history));
2870 app.backend.restore_history(history).await;
2871 app.messages.push(ChatMessage::system(format!(
2872 "Restored {restored} message(s) from the saved session. \
2873 The model remembers the conversation; the scrollback above does not \
2874 replay it."
2875 )));
2876 }
2877 _ => {
2878 app.messages.push(ChatMessage::system(
2879 "No saved session to resume. Sessions are saved after each turn.",
2880 ));
2881 }
2882 },
2883 SlashCommand::Plan(value) => {
2884 use crate::permissions::{self, TUI_SESSION};
2885 let enabled = value.unwrap_or_else(|| !permissions::plan_mode(TUI_SESSION));
2886 permissions::set_plan_mode(TUI_SESSION, enabled);
2887 app.messages.push(ChatMessage::system(if enabled {
2888 "Plan mode ON — research with read-only tools only; edits and commands \
2889 are blocked until /plan off."
2890 } else {
2891 "Plan mode OFF — tools may execute again (subject to the permission \
2892 policy)."
2893 }));
2894 }
2895 SlashCommand::Permissions => {
2896 app.messages
2897 .push(ChatMessage::system(crate::permissions::describe(
2898 crate::permissions::TUI_SESSION,
2899 )));
2900 }
2901 SlashCommand::Thinking(value) => {
2902 app.show_thinking = value.unwrap_or(!app.show_thinking);
2903 app.messages.push(ChatMessage::system(if app.show_thinking {
2904 "Thinking display ON — model reasoning is shown above each reply."
2905 } else {
2906 "Thinking display OFF — replies show a one-line indicator; \
2907 /thinking to expand."
2908 }));
2909 }
2910 SlashCommand::Tools(value) => {
2911 app.tools_expanded = value.unwrap_or(!app.tools_expanded);
2912 app.messages
2913 .push(ChatMessage::system(if app.tools_expanded {
2914 "Tool calls expanded — /tools off to collapse them."
2915 } else {
2916 "Tool calls collapsed — /tools on to expand them."
2917 }));
2918 }
2919 SlashCommand::Status => {
2920 let info = engine.as_ref().info().await;
2921 let model = info.model_name.as_deref().unwrap_or("(none)");
2922 let mem = info.approx_memory.as_deref().unwrap_or("unknown");
2923 app.messages.push(ChatMessage::system(format!(
2924 "status: {:?} model: {} memory: {} history: {} turns",
2925 info.status, model, mem, info.history_length,
2926 )));
2927 }
2928 SlashCommand::Skills => {
2929 app.messages
2930 .push(ChatMessage::system(crate::skills::format_skills_list()));
2931 }
2932 SlashCommand::Agents => {
2933 app.messages.push(ChatMessage::system(
2934 crate::subagents::format_agent_types_list(),
2935 ));
2936 }
2937 SlashCommand::Commands => {
2938 app.messages
2939 .push(ChatMessage::system(crate::commands::format_commands_list()));
2940 }
2941 SlashCommand::Mcp => {
2942 app.messages
2943 .push(ChatMessage::system(crate::mcp::status_summary()));
2944 }
2945 SlashCommand::Models(selection) => match selection {
2946 None => {
2947 app.open_model_picker(&engine);
2948 }
2949 Some(n) => {
2950 let idx = n.saturating_sub(1);
2951 match app.model_picker_items.get(idx).cloned() {
2952 None => {
2953 app.messages.push(ChatMessage::system(format!(
2954 "error: no model #{n} — type /models to see the list."
2955 )));
2956 }
2957 Some(model) => {
2958 // ── siGit Code Cloud tier: no local load; sign-in gated ──
2959 if let Some(tier) = model.cloud_tier.clone() {
2960 app.close_model_picker();
2961 match crate::provider::cloud_tier_provider(&tier) {
2962 Some(provider) => {
2963 let system_prompt = crate::with_launch_context(
2964 &crate::system_prompt_for_model(true),
2965 );
2966 app.backend = Arc::new(OpenAiBackend::new(
2967 provider.base_url,
2968 provider.api_key,
2969 provider.model,
2970 Some(system_prompt),
2971 ));
2972 app.current_model_name = provider.display_name.clone();
2973 app.tool_calling = true;
2974 // Selecting a cloud tier puts us in cloud mode.
2975 let _ = crate::settings::set_local_inference(false);
2976 app.messages.push(ChatMessage::system(format!(
2977 "Switched to {}.",
2978 provider.display_name
2979 )));
2980 }
2981 None => {
2982 app.messages.push(ChatMessage::system(
2983 "siGit Code Cloud needs an account. Use \
2984 `/login <email> <password>`, or create one at sigit.si.",
2985 ));
2986 }
2987 }
2988 return;
2989 }
2990
2991 app.close_model_picker();
2992 start_local_model_load(app, model, Arc::clone(&engine), terminal);
2993 }
2994 }
2995 }
2996 },
2997 SlashCommand::Local(value) => {
2998 let enabled = value.unwrap_or(!crate::settings::local_inference_enabled());
2999 match crate::settings::set_local_inference(enabled) {
3000 Ok(()) => {
3001 let state = if enabled { "on" } else { "off" };
3002 let hint = if enabled {
3003 "On-device models are highlighted. Type /models to pick one."
3004 } else {
3005 "siGit Code Cloud tiers are highlighted. Type /models to pick one."
3006 };
3007 app.messages.push(ChatMessage::system(format!(
3008 "Local inference is {state}. {hint}"
3009 )));
3010 // Refresh the picker so emphasis/order reflects the new mode.
3011 if app.show_model_picker {
3012 app.open_model_picker(&engine);
3013 }
3014 }
3015 Err(error) => {
3016 app.messages.push(ChatMessage::system(format!(
3017 "error: could not save local inference setting: {error}"
3018 )));
3019 }
3020 }
3021 }
3022 SlashCommand::Load => match default_local_model_item(app) {
3023 None => {
3024 app.messages.push(ChatMessage::system(
3025 "No local model available to load. Use /models to see the list.",
3026 ));
3027 }
3028 Some(model) => {
3029 start_local_model_load(app, model, Arc::clone(&engine), terminal);
3030 }
3031 },
3032 SlashCommand::Login(arg) => {
3033 let message = match arg.as_deref().and_then(crate::account::parse_login_args) {
3034 Some((email, password)) => {
3035 match crate::account::authenticate(&email, &password).await {
3036 Ok(email) => format!(
3037 "Signed in as {email}. siGit Code Cloud applies to your next session."
3038 ),
3039 Err(error) => format!("Login failed: {error}"),
3040 }
3041 }
3042 // A bare `/login` opens the browser instead of asking for
3043 // a password in the chat box.
3044 None => {
3045 app.messages.push(ChatMessage::system(
3046 "Opening your browser to sign in to siGit Code Cloud...",
3047 ));
3048 crate::browser_login_message().await
3049 }
3050 };
3051 app.messages.push(ChatMessage::system(message));
3052 }
3053 SlashCommand::Logout => {
3054 let message = crate::account::end_session().await;
3055 app.messages.push(ChatMessage::system(message));
3056 }
3057 SlashCommand::Whoami => {
3058 let message = crate::account::status_line().await;
3059 app.messages.push(ChatMessage::system(message));
3060 }
3061 SlashCommand::Init => {
3062 // Handled in the input loop (it runs a real turn); reaching
3063 // this arm means a caller forgot that special case.
3064 app.messages
3065 .push(ChatMessage::system("Type /init in the chat to run it."));
3066 }
3067 SlashCommand::Exit => {
3068 app.quit = true;
3069 }
3070 SlashCommand::Unknown(cmd, _) => {
3071 app.messages.push(ChatMessage::system(format!(
3072 "unknown command: {cmd}. Type /commands to list user-defined commands."
3073 )));
3074 }
3075 }
3076 }
3077
3078 // ── Background inference task ─────────────────────────────────────────────
3079
3080 /// cap tool rounds so a confused model can't loop forever; auto-compaction
3081 /// keeps long runs inside the context window, so the cap can be generous
3082 const MAX_TOOL_ROUNDS: usize = 24;
3083
3084 /// The TUI is a single conversation, so it persists under one fixed
3085 /// session-store id (ACP sessions use their protocol-assigned ids).
3086 const TUI_STORE_SESSION: &str = "tui";
3087
3088 fn build_tool_specs() -> Vec<ToolSpec> {
3089 let mut specs: Vec<ToolSpec> = crate::tools::all_tools()
3090 .into_iter()
3091 .map(|t| ToolSpec {
3092 name: t.name.to_string(),
3093 description: t.description.to_string(),
3094 parameters_schema: t.parameters_schema.to_string(),
3095 })
3096 .collect();
3097
3098 // Advertise the Agent Skills `skill` tool only when skills exist on disk
3099 // (https://agentskills.io). The tool description carries the discovery
3100 // list (name + description) for progressive disclosure.
3101 let discovered = crate::skills::discover_skills();
3102 if !discovered.is_empty() {
3103 specs.push(ToolSpec {
3104 name: crate::skills::SKILL_TOOL_NAME.to_string(),
3105 description: crate::skills::skill_tool_description(&discovered),
3106 parameters_schema: crate::skills::skill_tool_schema().to_string(),
3107 });
3108 }
3109
3110 // Delegated research (`task`) is offered only when a subagent backend
3111 // can actually be built — same conditional pattern as `skill` above.
3112 if crate::tools::subagent_available() {
3113 specs.push(crate::tools::task_tool_spec());
3114 }
3115
3116 // `web_search` is a native wrapper around the official MCP server's
3117 // `web_search` tool, offered only when that MCP tool was actually
3118 // discovered (i.e. the user is signed in to siGit Code Cloud).
3119 let web_search_offered = crate::tools::web_search_available();
3120 if web_search_offered {
3121 specs.push(crate::tools::web_search_tool_spec());
3122 }
3123
3124 // Tools discovered from configured MCP servers (incl. the official
3125 // one). Exclude the raw web_search delegate when the native wrapper
3126 // above is already offered, so the model sees one clean option, not
3127 // two names for the same tool.
3128 specs.extend(crate::mcp::tool_specs().into_iter().filter(|spec| {
3129 !(web_search_offered && crate::tools::is_web_search_delegate(&spec.name))
3130 }));
3131
3132 specs
3133 }
3134
3135 /// Close out a cancelled round in backend history: the results of tools
3136 /// that already ran this round, plus cancellation notes for `unreached`
3137 /// calls. Leaving a round's tool calls unanswered breaks strict
3138 /// OpenAI-compatible endpoints on the session's next request.
3139 async fn abandon_round(
3140 backend: &dyn InferenceBackend,
3141 mut tool_results: Vec<ToolResult>,
3142 unreached: &[crate::backend::ToolCall],
3143 ) {
3144 for pending in unreached {
3145 tool_results.push(ToolResult {
3146 tool_call_id: pending.id.clone(),
3147 content: format!(
3148 "`{}` was not executed: the user cancelled the turn.",
3149 pending.name
3150 ),
3151 });
3152 }
3153 backend.record_cancelled_tool_results(tool_results).await;
3154 }
3155
3156 /// run the tool-calling loop off the main thread, posting updates via `tx`.
3157 /// dropping `tx` signals completion to the event loop.
3158 async fn run_inference_task(
3159 backend: Arc<dyn InferenceBackend>,
3160 text: String,
3161 tx: mpsc::Sender<InferenceUpdate>,
3162 tools_enabled: bool,
3163 ) {
3164 let tools = if tools_enabled {
3165 build_tool_specs()
3166 } else {
3167 vec![]
3168 };
3169
3170 // Bridge the backend's token sink (plain strings) onto the UI update
3171 // channel as `Delta` messages. The forwarder lives for the whole turn.
3172 let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
3173 let forward_tx = tx.clone();
3174 let forwarder = tokio::spawn(async move {
3175 while let Some(piece) = delta_rx.recv().await {
3176 if forward_tx
3177 .send(InferenceUpdate::Delta(piece))
3178 .await
3179 .is_err()
3180 {
3181 break;
3182 }
3183 }
3184 });
3185
3186 // The first round offers tools, so on-device inference can't stream it
3187 // (it must buffer to detect tool calls). With tools disabled there are
3188 // none to offer, so it streams directly.
3189 let first_sink = if tools.is_empty() {
3190 Some(&delta_tx)
3191 } else {
3192 None
3193 };
3194 let mut streamed = first_sink.is_some();
3195
3196 let mut result = match backend
3197 .send_message_with_tools(&text, &tools, first_sink)
3198 .await
3199 {
3200 Ok(r) => r,
3201 Err(err) => {
3202 let _ = tx.send(InferenceUpdate::Error(err)).await;
3203 return;
3204 }
3205 };
3206
3207 let mut round = 0;
3208
3209 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
3210 // any tool call means the first round didn't produce a final answer
3211 streamed = false;
3212 round += 1;
3213 log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
3214
3215 // Auto-compaction: long tool runs grow history fast; fold it into
3216 // a summary before the next round rather than blowing the window.
3217 let estimate = crate::backend::estimate_tokens(&backend.history_snapshot().await);
3218 if estimate > crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
3219 log::info!(
3220 "history ≈{estimate} tokens exceeds budget {} — compacting",
3221 crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET
3222 );
3223 match backend
3224 .compact_history(crate::backend::COMPACT_KEEP_LAST)
3225 .await
3226 {
3227 Ok(()) => {
3228 let after =
3229 crate::backend::estimate_tokens(&backend.history_snapshot().await);
3230 log::info!("compacted history to ≈{after} tokens");
3231 let _ = tx.send(InferenceUpdate::ContextUsage(after)).await;
3232 }
3233 Err(error) => log::warn!("history compaction failed: {error}"),
3234 }
3235 }
3236
3237 let mut tool_results = Vec::new();
3238
3239 for (call_index, tc) in result.tool_calls.iter().enumerate() {
3240 // The UI drops the receiver on Ctrl+C or quit. Stop the turn
3241 // at the next boundary instead of burning model rounds (and
3242 // possibly running granted tools) in the background.
3243 if tx.is_closed() {
3244 log::info!("turn cancelled by the user — stopping the tool loop");
3245 abandon_round(&*backend, tool_results, &result.tool_calls[call_index..]).await;
3246 return;
3247 }
3248
3249 log::info!(
3250 " → {}({})",
3251 tc.name,
3252 tc.arguments.chars().take(120).collect::<String>()
3253 );
3254
3255 let _ = tx
3256 .send(InferenceUpdate::ToolUse {
3257 name: tc.name.clone(),
3258 arguments: tc.arguments.clone(),
3259 })
3260 .await;
3261
3262 // Permission gate: read-only tools pass straight through; a
3263 // mutating tool consults policy and may pause on the user's
3264 // y/a/n answer (delivered over a oneshot from the event loop).
3265 use crate::permissions::{self, Decision, TUI_SESSION};
3266 let output = match permissions::decision_for(TUI_SESSION, &tc.name, &tc.arguments) {
3267 Decision::Allow => crate::tools::execute_tool(&tc.name, &tc.arguments).await,
3268 Decision::Deny(reason) => {
3269 log::info!(" ✗ {} denied by policy", tc.name);
3270 reason
3271 }
3272 Decision::Ask => {
3273 let (reply_tx, reply_rx) = oneshot::channel();
3274 let _ = tx
3275 .send(InferenceUpdate::ApprovalRequest {
3276 tool: tc.name.clone(),
3277 args: permissions::approval_preview(&tc.arguments),
3278 reply: reply_tx,
3279 })
3280 .await;
3281 match reply_rx.await {
3282 Ok(ApprovalChoice::Once) => {
3283 crate::tools::execute_tool(&tc.name, &tc.arguments).await
3284 }
3285 Ok(ApprovalChoice::Session) => {
3286 permissions::grant_for_session(
3287 TUI_SESSION,
3288 &tc.name,
3289 &tc.arguments,
3290 );
3291 crate::tools::execute_tool(&tc.name, &tc.arguments).await
3292 }
3293 Ok(ApprovalChoice::Deny) => {
3294 log::info!(" ✗ {} denied by user", tc.name);
3295 permissions::user_denial(&tc.name)
3296 }
3297 // The UI dropped the reply channel (Ctrl+C or
3298 // quit): the whole turn is over, not just this
3299 // call. Close out the round and stop instead of
3300 // continuing rounds in the background.
3301 Err(_) => {
3302 log::info!(
3303 "turn cancelled at the approval prompt — stopping the tool loop"
3304 );
3305 abandon_round(
3306 &*backend,
3307 tool_results,
3308 &result.tool_calls[call_index..],
3309 )
3310 .await;
3311 return;
3312 }
3313 }
3314 }
3315 };
3316 log::info!(" ← {} chars", output.len());
3317
3318 // Display-only preview of the output tail; the model gets the
3319 // full output through `tool_results` below.
3320 let _ = tx
3321 .send(InferenceUpdate::ToolDone {
3322 output_preview: super::cap_output_preview(&output),
3323 })
3324 .await;
3325
3326 tool_results.push(ToolResult {
3327 tool_call_id: tc.id.clone(),
3328 content: output,
3329 });
3330 }
3331
3332 // Cancelled while the round's tools ran: record what executed and
3333 // stop before paying for another model round nobody will see.
3334 if tx.is_closed() {
3335 log::info!("turn cancelled by the user — skipping the next model round");
3336 abandon_round(&*backend, tool_results, &[]).await;
3337 return;
3338 }
3339
3340 // on the last round, pass no tools so the model must produce text —
3341 // that's also the round we can stream on-device.
3342 let allow_tool_calls = round < MAX_TOOL_ROUNDS;
3343 let sink = if !allow_tool_calls {
3344 streamed = true;
3345 Some(&delta_tx)
3346 } else {
3347 None
3348 };
3349
3350 match backend
3351 .send_tool_results(tool_results, &tools, allow_tool_calls, sink)
3352 .await
3353 {
3354 Ok(r) => result = r,
3355 Err(err) => {
3356 let _ = tx.send(InferenceUpdate::Error(err)).await;
3357 return;
3358 }
3359 }
3360 }
3361
3362 // Drop the sink so the forwarder finishes draining any buffered tokens
3363 // before we commit the reply.
3364 drop(delta_tx);
3365 let _ = forwarder.await;
3366
3367 if result.tool_calls.is_empty() {
3368 if result.text.is_empty() {
3369 log::warn!(
3370 "model returned empty reply — may have exhausted max_tokens on thinking"
3371 );
3372 let _ = tx
3373 .send(InferenceUpdate::Error(
3374 "(empty response — the model may have used all tokens on internal reasoning. \
3375 Try a shorter or simpler prompt.)"
3376 .to_string(),
3377 ))
3378 .await;
3379 } else if streamed {
3380 // tokens already went out as deltas; just commit the buffer
3381 let _ = tx.send(InferenceUpdate::StreamEnd).await;
3382 } else {
3383 let _ = tx.send(InferenceUpdate::Response(result.text)).await;
3384 }
3385 }
3386
3387 // Persist the completed turn so /resume (or a restart) can pick the
3388 // conversation back up.
3389 let snapshot = backend.history_snapshot().await;
3390 let _ = tx
3391 .send(InferenceUpdate::ContextUsage(
3392 crate::backend::estimate_tokens(&snapshot),
3393 ))
3394 .await;
3395 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot) {
3396 log::warn!("session save failed: {error}");
3397 }
3398
3399 log::info!("inference complete — {} tool round(s)", round);
3400 // tx drops here — event loop gets None from rx.recv()
3401 }
3402
3403 // ── Main loop ─────────────────────────────────────────────────────────────
3404
3405 /// entry point — blocks until the user quits.
3406 /// caller owns terminal init/restore. `load_rx` delivers the model-load result
3407 /// from a dedicated OS thread; we poll it non-blocking each tick.
3408 pub async fn run_with<B: ratatui::backend::Backend>(
3409 terminal: &mut ratatui::Terminal<B>,
3410 engine: Arc<ChatEngine>,
3411 backend: Arc<dyn InferenceBackend>,
3412 load_rx: std_mpsc::Receiver<Result<(), String>>,
3413 load_model_name: String,
3414 ) -> Result<()> {
3415 event_loop(terminal, engine, backend, load_rx, load_model_name).await
3416 }
3417
3418 async fn event_loop<B: ratatui::backend::Backend>(
3419 terminal: &mut ratatui::Terminal<B>,
3420 engine: Arc<ChatEngine>,
3421 backend: Arc<dyn InferenceBackend>,
3422 load_rx: std_mpsc::Receiver<Result<(), String>>,
3423 load_model_name: String,
3424 ) -> Result<()> {
3425 let mut app = App::new(load_model_name, backend);
3426 let mut event_stream = EventStream::new();
3427
3428 // 10 fps is plenty for spinners
3429 let mut ticker = interval(Duration::from_millis(100));
3430
3431 loop {
3432 // ── Poll the loader channel (non-blocking) ────────────────────────
3433 if app.is_loading {
3434 match load_rx.try_recv() {
3435 Ok(Ok(())) => app.finish_loading(),
3436 Ok(Err(e)) => app.set_load_error(e),
3437 Err(std_mpsc::TryRecvError::Empty) => {}
3438 Err(std_mpsc::TryRecvError::Disconnected) => {
3439 app.set_load_error("Model loader thread crashed.".to_string());
3440 }
3441 }
3442 }
3443
3444 // redraw every iteration
3445 terminal.draw(|frame| render(frame, &mut app))?;
3446
3447 if let Some(rx) = app.model_load_rx.as_mut() {
3448 match rx.try_recv() {
3449 Ok(ModelLoadUpdate::Loaded(model_name)) => {
3450 engine.clear_history().await;
3451 app.context_tokens_used = None;
3452 if let Some(tc) = app.pending_tool_calling.take() {
3453 app.tool_calling = tc;
3454 }
3455 app.switching_model = false;
3456 app.switching_model_id = None;
3457 app.download_progress = None;
3458 app.model_load_cancelled = false;
3459 app.model_load_rx = None;
3460 app.current_model_name = model_name.clone();
3461
3462 let save_result = app
3463 .model_picker_items
3464 .iter()
3465 .find(|item| item.display_name == model_name)
3466 .map(|item| crate::setup::SelectedModel {
3467 model_id: item.config.model_id.clone(),
3468 gguf_file: item
3469 .config
3470 .files
3471 .first()
3472 .cloned()
3473 .unwrap_or_else(String::new),
3474 })
3475 .filter(|selected| !selected.gguf_file.is_empty())
3476 .map(|selected| crate::setup::save_selected_model(&selected))
3477 .unwrap_or_else(|| {
3478 Err(format!(
3479 "could not determine a stable identifier for {}",
3480 model_name
3481 ))
3482 });
3483
3484 if let Err(error) = save_result {
3485 app.messages.push(ChatMessage::system(format!(
3486 "warning: switched to {} but could not save the selection: {}",
3487 model_name, error
3488 )));
3489 } else {
3490 app.messages
3491 .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
3492 }
3493 }
3494 Ok(ModelLoadUpdate::Error(error)) => {
3495 app.switching_model = false;
3496 app.switching_model_id = None;
3497 app.download_progress = None;
3498 app.model_load_cancelled = false;
3499 app.model_load_rx = None;
3500 app.messages
3501 .push(ChatMessage::system(format!("error loading model: {error}")));
3502 }
3503 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
3504 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
3505 let was_cancelled = app.model_load_cancelled;
3506 app.switching_model = false;
3507 app.switching_model_id = None;
3508 app.download_progress = None;
3509 app.model_load_cancelled = false;
3510 app.model_load_rx = None;
3511 if !was_cancelled {
3512 app.messages.push(ChatMessage::system(
3513 "error loading model: loader task disconnected".to_string(),
3514 ));
3515 }
3516 }
3517 }
3518 }
3519
3520 if app.quit {
3521 break;
3522 }
3523
3524 // multiplex terminal events, streaming tokens, inference updates,
3525 // and the thinking-spinner timer.
3526 tokio::select! {
3527 biased;
3528
3529 // ── Spinner tick (loading phase only) ─────────────────────────
3530 _ = ticker.tick(), if app.is_loading => {
3531 app.tick();
3532 }
3533
3534 // ── inference updates from background task ───────────────────
3535 update = async {
3536 match app.inference_rx.as_mut() {
3537 Some(rx) => rx.recv().await,
3538 None => pending().await,
3539 }
3540 } => {
3541 match update {
3542 Some(InferenceUpdate::ToolUse { name, arguments }) => {
3543 let title = super::tool_title(&name, &arguments);
3544 let detail = super::pretty_tool_arguments(&arguments);
3545 app.messages.push(ChatMessage::tool_call(title, detail));
3546 }
3547 Some(InferenceUpdate::ToolDone { output_preview }) => {
3548 // Attach to the most recent tool entry still
3549 // waiting for its result.
3550 if let Some(entry) = app
3551 .messages
3552 .iter_mut()
3553 .rev()
3554 .find(|m| m.role == Role::Tool && m.tool_result.is_none())
3555 {
3556 entry.tool_result = Some(output_preview);
3557 }
3558 }
3559 Some(InferenceUpdate::Delta(delta)) => {
3560 app.push_stream_delta(&delta);
3561 }
3562 Some(InferenceUpdate::StreamEnd) => {
3563 app.finalize_stream();
3564 }
3565 Some(InferenceUpdate::Response(text)) => {
3566 app.stop_thinking();
3567 app.messages.push(ChatMessage::assistant(text));
3568 }
3569 Some(InferenceUpdate::ContextUsage(tokens)) => {
3570 app.context_tokens_used = Some(tokens);
3571 }
3572 Some(InferenceUpdate::Error(msg)) => {
3573 app.finalize_stream();
3574 app.stop_thinking();
3575 app.messages.push(ChatMessage::system(format!("error: {msg}")));
3576 }
3577 Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
3578 // The y/a/n prompt lives on the Session tab; make
3579 // sure the user can see what they're answering.
3580 app.active_tab = Tab::Session;
3581 let call = if args.is_empty() {
3582 tool.clone()
3583 } else {
3584 format!("{tool}({args})")
3585 };
3586 app.messages.push(ChatMessage::system(format!(
3587 "⚠ permission — allow {call}? [y]es · [a]lways this session · [n]o"
3588 )));
3589 app.pending_approval = Some((tool, reply));
3590 }
3591 None => {
3592 // task finished, possibly with no text to show
3593 app.finalize_stream();
3594 app.stop_thinking();
3595 }
3596 }
3597 }
3598
3599 // ── Cloud tab status fetch resolving ─────────────────────────
3600 status = async {
3601 match app.cloud_rx.as_mut() {
3602 Some(rx) => rx.await,
3603 None => pending().await,
3604 }
3605 } => {
3606 app.cloud_rx = None;
3607 app.cloud_lines = Some(status.unwrap_or_else(|_| {
3608 vec!["error: the status fetch task died — press r to retry".to_string()]
3609 }));
3610 }
3611
3612 // ── Repo tab fetches resolving ────────────────────────────────
3613 update = async {
3614 match app.repo_rx.as_mut() {
3615 Some(rx) => rx.recv().await,
3616 None => pending().await,
3617 }
3618 } => {
3619 match update {
3620 Some(RepoUpdate::Issues(result)) => {
3621 app.repo_issues = match result {
3622 Ok(items) => RepoData::Ready(items),
3623 Err(error) => RepoData::Failed(error),
3624 };
3625 app.repo_issue_index = app
3626 .repo_issue_index
3627 .min(app.repo_issues.len().saturating_sub(1));
3628 }
3629 Some(RepoUpdate::PullRequests(result)) => {
3630 app.repo_prs = match result {
3631 Ok(items) => RepoData::Ready(items),
3632 Err(error) => RepoData::Failed(error),
3633 };
3634 app.repo_pr_index = app
3635 .repo_pr_index
3636 .min(app.repo_prs.len().saturating_sub(1));
3637 }
3638 Some(RepoUpdate::Detail { target, text }) => {
3639 // Apply only to the matching open view: a reply for
3640 // a detail the user has since closed or navigated
3641 // away from is dropped, never shown under the wrong
3642 // title.
3643 if let Some(detail) = app.repo_detail.as_mut()
3644 && detail.target == target
3645 {
3646 detail.text = Some(text);
3647 }
3648 }
3649 // All senders gone (can't happen while app.repo_tx is
3650 // held) — stop polling this channel.
3651 None => app.repo_rx = None,
3652 }
3653 }
3654
3655 // ── thinking / switching spinner tick (100ms) ────────────────
3656 _ = async {
3657 if app.thinking || app.switching_model {
3658 tokio::time::sleep(Duration::from_millis(100)).await
3659 } else {
3660 pending().await
3661 }
3662 } => {
3663 app.tick_thinking();
3664 // keep the progress display fresh
3665 if app.switching_model {
3666 app.poll_download_progress();
3667 }
3668 }
3669
3670 // ── Terminal events ───────────────────────────────────────────
3671 maybe_event = event_stream.next() => {
3672 let Some(Ok(event)) = maybe_event else {
3673 break;
3674 };
3675
3676 if let Event::Key(key) = event {
3677 // loading phase — only quit keys work
3678 if app.is_loading {
3679 if key.kind == KeyEventKind::Press {
3680 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3681 if ctrl
3682 && (key.code == KeyCode::Char('c')
3683 || key.code == KeyCode::Char('d'))
3684 {
3685 app.quit = true;
3686 }
3687 }
3688 continue;
3689 }
3690
3691 // pending tool approval — y/a/n answer the prompt; the
3692 // inference task is paused on the reply channel. Checked
3693 // before the busy gate because the app *is* busy here.
3694 if app.pending_approval.is_some() {
3695 if key.kind == KeyEventKind::Press {
3696 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3697 let choice = if ctrl
3698 && (key.code == KeyCode::Char('c')
3699 || key.code == KeyCode::Char('d'))
3700 {
3701 // cancel the whole turn: denying is implicit
3702 // in dropping the reply channel
3703 app.pending_approval = None;
3704 app.stop_thinking();
3705 app.messages.push(ChatMessage::system("(cancelled)"));
3706 continue;
3707 } else {
3708 match key.code {
3709 KeyCode::Char('y') | KeyCode::Char('Y') => {
3710 Some(ApprovalChoice::Once)
3711 }
3712 KeyCode::Char('a') | KeyCode::Char('A') => {
3713 Some(ApprovalChoice::Session)
3714 }
3715 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
3716 Some(ApprovalChoice::Deny)
3717 }
3718 _ => None,
3719 }
3720 };
3721 if let Some(choice) = choice
3722 && let Some((tool, reply)) = app.pending_approval.take()
3723 {
3724 let verdict = match &choice {
3725 ApprovalChoice::Once => "allowed once",
3726 ApprovalChoice::Session => "allowed for this session",
3727 ApprovalChoice::Deny => "denied",
3728 };
3729 app.messages.push(ChatMessage::system(format!(
3730 "{tool}: {verdict}"
3731 )));
3732 let _ = reply.send(choice);
3733 }
3734 }
3735 continue;
3736 }
3737
3738 // ── Tab-bar navigation ────────────────────────────────
3739 // Handled before the busy gate so the user can look at
3740 // History/Cloud while inference runs (updates keep
3741 // landing in the Session tab's message list). The Tab
3742 // key only cycles when the input buffer is empty, so
3743 // pasted text containing tabs can't fight it; on
3744 // non-Session tabs the input is hidden, so it always
3745 // cycles there.
3746 if key.kind == KeyEventKind::Press && !app.show_model_picker {
3747 if key.code == KeyCode::Tab
3748 && (app.active_tab != Tab::Session || app.input.is_empty())
3749 {
3750 let next = app.active_tab.next(app.repo_visible());
3751 switch_tab(&mut app, next, &engine);
3752 continue;
3753 }
3754 if app.active_tab != Tab::Session && key.code == KeyCode::Esc {
3755 // On the Repo tab, Esc first closes an open
3756 // detail view (back to the list).
3757 if app.active_tab == Tab::Repo && app.repo_detail.is_some() {
3758 app.repo_detail = None;
3759 } else {
3760 app.active_tab = Tab::Session;
3761 }
3762 continue;
3763 }
3764 }
3765
3766 // busy — only cancel keys work
3767 if app.is_busy() {
3768 if key.kind == KeyEventKind::Press {
3769 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3770 if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
3771 if app.is_streaming() {
3772 app.finalize_stream();
3773 app.messages.push(ChatMessage::system("(cancelled)"));
3774 }
3775 if app.thinking {
3776 // dropping rx kills the background task
3777 app.stop_thinking();
3778 app.messages.push(ChatMessage::system("(cancelled)"));
3779 }
3780 if app.switching_model {
3781 // flag before drop so Disconnected handler stays quiet
3782 app.model_load_cancelled = true;
3783 app.switching_model = false;
3784 app.switching_model_id = None;
3785 app.download_progress = None;
3786 app.model_load_rx = None;
3787 app.messages
3788 .push(ChatMessage::system("(download cancelled — model switch aborted)"));
3789 }
3790 }
3791 }
3792 continue;
3793 }
3794
3795 // History / Cloud tabs have their own key handling; the
3796 // chat input is inactive there.
3797 if app.active_tab != Tab::Session {
3798 if key.kind == KeyEventKind::Press {
3799 handle_tab_key(&mut app, key, &engine).await;
3800 }
3801 continue;
3802 }
3803
3804 if let Some(text) = handle_key(&mut app, key) {
3805 // `/init` runs a real agent turn: the transcript
3806 // shows the command the user typed, but the model
3807 // receives the canned AGENTS.md-generation prompt.
3808 // User-defined commands (`.sigit/commands/*.md`)
3809 // get the same treatment once `parse_slash`
3810 // reports the word unrecognized.
3811 let mut inference_text = text.clone();
3812 match parse_slash(&text) {
3813 Some(SlashCommand::Init) => {
3814 inference_text =
3815 crate::instructions::INIT_PROMPT.to_string();
3816 }
3817 Some(SlashCommand::Unknown(command, argument)) => {
3818 match crate::commands::resolve_command(&command) {
3819 Some(custom) => {
3820 inference_text = crate::commands::render(
3821 &custom.body,
3822 argument.as_deref(),
3823 );
3824 }
3825 None => {
3826 exec_slash(
3827 &mut app,
3828 SlashCommand::Unknown(command, argument),
3829 Arc::clone(&engine),
3830 terminal,
3831 )
3832 .await;
3833 continue;
3834 }
3835 }
3836 }
3837 Some(cmd) => {
3838 exec_slash(&mut app, cmd, Arc::clone(&engine), terminal)
3839 .await;
3840 continue;
3841 }
3842 None => {}
3843 }
3844
3845 // On-device inference needs a model in memory, and we
3846 // never load one implicitly: the user loads it with
3847 // /load (or /models). Refuse rather than erroring out
3848 // deep in the backend.
3849 if !app.backend.is_remote()
3850 && engine.info().await.status == onde::inference::EngineStatus::Unloaded
3851 {
3852 app.messages.push(ChatMessage::user(&text));
3853 app.messages.push(ChatMessage::system(
3854 "No on-device model is loaded. Run /load to load the selected \
3855 model, or /models to choose one.",
3856 ));
3857 continue;
3858 }
3859
3860 // ── spawn inference ──────────────────────────────
3861 app.messages.push(ChatMessage::user(&text));
3862 app.start_thinking();
3863
3864 let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
3865 app.inference_rx = Some(rx);
3866
3867 let backend_handle = Arc::clone(&app.backend);
3868 let user_text = inference_text;
3869 let tools_enabled = app.tool_calling;
3870 tokio::spawn(async move {
3871 run_inference_task(backend_handle, user_text, tx, tools_enabled).await;
3872 });
3873 }
3874 }
3875 }
3876 }
3877 }
3878
3879 Ok(())
3880 }
3881
3882 // ── Download progress helpers (TUI) ──────────────────────────────────────
3883
3884 /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks)
3885 fn dir_size_recursive(path: &std::path::Path) -> u64 {
3886 let mut total: u64 = 0;
3887 let Ok(entries) = std::fs::read_dir(path) else {
3888 return 0;
3889 };
3890 for entry in entries.flatten() {
3891 let entry_path = entry.path();
3892 if entry_path.is_dir() {
3893 total += dir_size_recursive(&entry_path);
3894 } else if let Ok(meta) = entry_path.metadata() {
3895 total += meta.len();
3896 }
3897 }
3898 total
3899 }
3900
3901 fn format_size_human(bytes: u64) -> String {
3902 const GB: u64 = 1_073_741_824;
3903 const MB: u64 = 1_048_576;
3904 const KB: u64 = 1_024;
3905 if bytes >= GB {
3906 format!("{:.2} GB", bytes as f64 / GB as f64)
3907 } else if bytes >= MB {
3908 format!("{:.1} MB", bytes as f64 / MB as f64)
3909 } else if bytes >= KB {
3910 format!("{:.0} KB", bytes as f64 / KB as f64)
3911 } else {
3912 format!("{bytes} B")
3913 }
3914 }
3915 #[cfg(test)]
3916 mod tui_tests {
3917 use super::{compact_tokens, context_gauge_color, context_gauge_label};
3918 use ratatui::style::Color;
3919
3920 #[test]
3921 fn context_gauge_reports_share_of_the_window() {
3922 assert_eq!(context_gauge_label(168_000, 200_000), "84% · 168K/200K ctx");
3923 assert_eq!(context_gauge_label(0, 24_000), "0% · 0/24K ctx");
3924 // Over-full history (pre-compaction) clamps rather than reading 130%.
3925 assert_eq!(context_gauge_label(31_000, 24_000), "100% · 31K/24K ctx");
3926 }
3927
3928 #[test]
3929 fn context_gauge_handles_an_unknown_window() {
3930 assert_eq!(context_gauge_label(1_000, 0), "ctx —");
3931 assert_eq!(context_gauge_color(1_000, 0), Color::DarkGray);
3932 }
3933
3934 #[test]
3935 fn context_gauge_warms_up_as_the_window_fills() {
3936 assert_eq!(context_gauge_color(1_000, 24_000), Color::DarkGray);
3937 assert_eq!(context_gauge_color(18_000, 24_000), Color::Yellow);
3938 assert_eq!(context_gauge_color(23_000, 24_000), Color::Red);
3939 }
3940
3941 #[test]
3942 fn compact_tokens_abbreviates_thousands_only() {
3943 assert_eq!(compact_tokens(999), "999");
3944 assert_eq!(compact_tokens(1_500), "1K");
3945 }
3946 }
3947 } // end #[cfg(unix)] mod tui
3948
3949 // re-export so callers write `chat::run_with(...)` on all platforms
3950 #[cfg(unix)]
3951 pub use tui::run_with;
3952
3953 // ── Tests (platform-agnostic) ─────────────────────────────────────────────────
3954
3955 #[cfg(test)]
3956 mod tests {
3957 use std::time::Duration;
3958
3959 use super::{
3960 RepoItem, TOOL_RESULT_PREVIEW_MAX, Tab, cap_output_preview, format_age, format_repo_detail,
3961 history_row, host_from_api_url, parse_repo_items, parse_repo_remote,
3962 parse_rich_text_segments, pretty_tool_arguments, repo_row, streaming_think_tail,
3963 strip_think_blocks, think_indicator, tool_entry_lines, tool_title, url_host,
3964 };
3965
3966 // ── tool_title ────────────────────────────────────────────────────────────
3967
3968 #[test]
3969 fn tool_title_run_command_shows_the_command() {
3970 assert_eq!(
3971 tool_title("run_command", r#"{"command":"cargo test"}"#),
3972 "run_command · cargo test"
3973 );
3974 }
3975
3976 #[test]
3977 fn tool_title_file_tools_show_the_path() {
3978 for name in [
3979 "read_file",
3980 "create_file",
3981 "edit_file",
3982 "multi_edit",
3983 "delete_file",
3984 ] {
3985 assert_eq!(
3986 tool_title(name, r#"{"path":"src/main.rs","old_text":"a"}"#),
3987 format!("{name} · src/main.rs")
3988 );
3989 }
3990 }
3991
3992 #[test]
3993 fn tool_title_pattern_tools_show_the_pattern() {
3994 assert_eq!(
3995 tool_title("search_files", r#"{"pattern":"fn main","path":"src"}"#),
3996 "search_files · fn main"
3997 );
3998 assert_eq!(
3999 tool_title("glob", r#"{"pattern":"**/*.rs"}"#),
4000 "glob · **/*.rs"
4001 );
4002 }
4003
4004 #[test]
4005 fn tool_title_mcp_tools_show_server_and_bare_tool_name() {
4006 assert_eq!(
4007 tool_title("mcp__sigit__list_issues", r#"{"repo":"getsigit/sigit"}"#),
4008 "sigit · list_issues"
4009 );
4010 }
4011
4012 #[test]
4013 fn tool_title_unknown_tool_is_the_name_alone() {
4014 assert_eq!(
4015 tool_title("write_todos", r#"{"todos":[{"text":"x"}]}"#),
4016 "write_todos"
4017 );
4018 }
4019
4020 #[test]
4021 fn tool_title_malformed_json_falls_back_to_the_name() {
4022 assert_eq!(tool_title("run_command", "{not json"), "run_command");
4023 assert_eq!(tool_title("edit_file", ""), "edit_file");
4024 }
4025
4026 #[test]
4027 fn tool_title_missing_or_non_string_arg_falls_back_to_the_name() {
4028 assert_eq!(tool_title("run_command", r#"{"other":"x"}"#), "run_command");
4029 assert_eq!(
4030 tool_title("run_command", r#"{"command":42}"#),
4031 "run_command"
4032 );
4033 }
4034
4035 #[test]
4036 fn tool_title_truncates_long_summaries_with_an_ellipsis() {
4037 let long = "a".repeat(100);
4038 let title = tool_title("run_command", &format!(r#"{{"command":"{long}"}}"#));
4039
4040 let summary = title.strip_prefix("run_command · ").expect("summary");
4041 assert_eq!(summary.chars().count(), 60);
4042 assert!(summary.ends_with('…'));
4043 }
4044
4045 #[test]
4046 fn tool_title_collapses_multiline_commands_to_one_line() {
4047 assert_eq!(
4048 tool_title("run_command", "{\"command\":\"echo a &&\\n echo b\"}"),
4049 "run_command · echo a && echo b"
4050 );
4051 }
4052
4053 // ── cap_output_preview ────────────────────────────────────────────────────
4054
4055 #[test]
4056 fn cap_output_preview_passes_short_output_through() {
4057 assert_eq!(cap_output_preview("hello\nworld"), "hello\nworld");
4058 }
4059
4060 #[test]
4061 fn cap_output_preview_keeps_the_tail_and_notes_the_truncation() {
4062 let output = format!("{}{}", "x".repeat(3000), "the-very-end");
4063 let preview = cap_output_preview(&output);
4064
4065 assert!(preview.starts_with("… (truncated — showing the last 2000 of 3012 chars)\n"));
4066 assert!(preview.ends_with("the-very-end"));
4067 let (_note, tail) = preview.split_once('\n').expect("note line");
4068 assert_eq!(tail.chars().count(), TOOL_RESULT_PREVIEW_MAX);
4069 }
4070
4071 // ── pretty_tool_arguments ─────────────────────────────────────────────────
4072
4073 #[test]
4074 fn pretty_tool_arguments_pretty_prints_valid_json() {
4075 assert_eq!(
4076 pretty_tool_arguments(r#"{"command":"ls"}"#),
4077 "{\n \"command\": \"ls\"\n}"
4078 );
4079 }
4080
4081 #[test]
4082 fn pretty_tool_arguments_shows_malformed_json_raw() {
4083 assert_eq!(pretty_tool_arguments("{oops"), "{oops");
4084 }
4085
4086 // ── tool_entry_lines ──────────────────────────────────────────────────────
4087
4088 #[test]
4089 fn tool_entry_lines_collapsed_is_a_single_title_line() {
4090 let lines = tool_entry_lines(
4091 "run_command · cargo test",
4092 "{\n \"command\": \"cargo test\"\n}",
4093 Some("ok"),
4094 false,
4095 );
4096 assert_eq!(lines, vec!["▸ 🔧 run_command · cargo test"]);
4097 }
4098
4099 #[test]
4100 fn tool_entry_lines_expanded_shows_detail_and_result() {
4101 let lines = tool_entry_lines(
4102 "run_command · cargo test",
4103 "{\n \"command\": \"cargo test\"\n}",
4104 Some("test ok\ndone"),
4105 true,
4106 );
4107 assert_eq!(
4108 lines,
4109 vec![
4110 "▾ 🔧 run_command · cargo test",
4111 " {",
4112 " \"command\": \"cargo test\"",
4113 " }",
4114 " result:",
4115 " test ok",
4116 " done",
4117 ]
4118 );
4119 }
4120
4121 #[test]
4122 fn tool_entry_lines_expanded_without_result_omits_the_result_block() {
4123 let lines = tool_entry_lines("glob · **/*.rs", "{}", None, true);
4124 assert_eq!(lines, vec!["▾ 🔧 glob · **/*.rs", " {}"]);
4125 }
4126
4127 // ── Tab / format_age / history_row ────────────────────────────────────────
4128
4129 #[test]
4130 fn tab_next_without_repo_cycles_three_tabs() {
4131 assert_eq!(Tab::Session.next(false), Tab::History);
4132 assert_eq!(Tab::History.next(false), Tab::Cloud);
4133 assert_eq!(Tab::Cloud.next(false), Tab::Session);
4134 // Three hops return to the start — exactly the base three-tab cycle.
4135 assert_eq!(
4136 Tab::Session.next(false).next(false).next(false),
4137 Tab::Session
4138 );
4139 }
4140
4141 #[test]
4142 fn tab_next_with_repo_cycles_four_tabs() {
4143 assert_eq!(Tab::Session.next(true), Tab::History);
4144 assert_eq!(Tab::History.next(true), Tab::Repo);
4145 assert_eq!(Tab::Repo.next(true), Tab::Cloud);
4146 assert_eq!(Tab::Cloud.next(true), Tab::Session);
4147 }
4148
4149 #[test]
4150 fn tab_index_matches_titles_order() {
4151 for repo_visible in [false, true] {
4152 let titles = Tab::titles(repo_visible);
4153 assert_eq!(titles[Tab::Session.index(repo_visible)], "Session");
4154 assert_eq!(titles[Tab::History.index(repo_visible)], "History");
4155 assert_eq!(titles[Tab::Cloud.index(repo_visible)], "Cloud");
4156 }
4157 assert_eq!(Tab::titles(true)[Tab::Repo.index(true)], "Repo");
4158 }
4159
4160 #[test]
4161 fn tab_titles_hide_repo_when_undetected() {
4162 assert_eq!(Tab::titles(false), vec!["Session", "History", "Cloud"]);
4163 assert_eq!(
4164 Tab::titles(true),
4165 vec!["Session", "History", "Repo", "Cloud"]
4166 );
4167 }
4168
4169 // ── Repo remote detection ─────────────────────────────────────────────────
4170
4171 #[test]
4172 fn parse_repo_remote_accepts_ssh_forms() {
4173 assert_eq!(
4174 parse_repo_remote("git@sigit.si:acme/demo.git", "sigit.si"),
4175 Some("acme/demo".to_string())
4176 );
4177 // bare (no .git) works too
4178 assert_eq!(
4179 parse_repo_remote("git@sigit.si:acme/demo", "sigit.si"),
4180 Some("acme/demo".to_string())
4181 );
4182 }
4183
4184 #[test]
4185 fn parse_repo_remote_accepts_https_forms() {
4186 assert_eq!(
4187 parse_repo_remote("https://sigit.si/acme/demo.git", "sigit.si"),
4188 Some("acme/demo".to_string())
4189 );
4190 assert_eq!(
4191 parse_repo_remote("https://sigit.si/acme/demo", "sigit.si"),
4192 Some("acme/demo".to_string())
4193 );
4194 // trailing slash and http + port (dev instances) normalize too
4195 assert_eq!(
4196 parse_repo_remote("https://sigit.si/acme/demo/", "sigit.si"),
4197 Some("acme/demo".to_string())
4198 );
4199 assert_eq!(
4200 parse_repo_remote("http://127.0.0.1:8088/acme/demo.git", "127.0.0.1"),
4201 Some("acme/demo".to_string())
4202 );
4203 }
4204
4205 #[test]
4206 fn parse_repo_remote_rejects_other_hosts() {
4207 assert_eq!(
4208 parse_repo_remote("git@github.com:acme/demo.git", "sigit.si"),
4209 None
4210 );
4211 assert_eq!(
4212 parse_repo_remote("https://github.com/acme/demo.git", "sigit.si"),
4213 None
4214 );
4215 }
4216
4217 #[test]
4218 fn parse_repo_remote_rejects_garbage_and_bad_paths() {
4219 assert_eq!(parse_repo_remote("", "sigit.si"), None);
4220 assert_eq!(parse_repo_remote("not a url at all", "sigit.si"), None);
4221 assert_eq!(parse_repo_remote("/local/path/repo.git", "sigit.si"), None);
4222 // wrong number of path segments
4223 assert_eq!(
4224 parse_repo_remote("https://sigit.si/demo.git", "sigit.si"),
4225 None
4226 );
4227 assert_eq!(
4228 parse_repo_remote("https://sigit.si/a/b/c.git", "sigit.si"),
4229 None
4230 );
4231 assert_eq!(
4232 parse_repo_remote("git@sigit.si:/demo.git", "sigit.si"),
4233 None
4234 );
4235 }
4236
4237 #[test]
4238 fn url_host_extracts_the_hostname() {
4239 assert_eq!(url_host("https://sigit.si"), Some("sigit.si".to_string()));
4240 assert_eq!(
4241 url_host("http://localhost:8088/api/v1"),
4242 Some("localhost".to_string())
4243 );
4244 assert_eq!(
4245 url_host("https://SiGit.SI/path"),
4246 Some("sigit.si".to_string())
4247 );
4248 assert_eq!(url_host("ftp://sigit.si"), None);
4249 assert_eq!(url_host(""), None);
4250 }
4251
4252 #[test]
4253 fn host_from_api_url_defaults_to_production() {
4254 assert_eq!(host_from_api_url(None), "sigit.si");
4255 assert_eq!(host_from_api_url(Some("nonsense")), "sigit.si");
4256 assert_eq!(
4257 host_from_api_url(Some("http://127.0.0.1:8088")),
4258 "127.0.0.1"
4259 );
4260 }
4261
4262 // ── Repo tab data parsing ─────────────────────────────────────────────────
4263
4264 #[test]
4265 fn parse_repo_items_reads_a_top_level_array() {
4266 let text = r#"[
4267 {"number": 12, "title": "Fix the flux capacitor", "state": "open"},
4268 {"number": 7, "title": "Old bug", "state": "closed"}
4269 ]"#;
4270 let items = parse_repo_items(text).expect("items");
4271 assert_eq!(items.len(), 2);
4272 assert_eq!(items[0].number, 12);
4273 assert_eq!(items[0].title, "Fix the flux capacitor");
4274 assert_eq!(items[0].state, "open");
4275 }
4276
4277 #[test]
4278 fn parse_repo_items_reads_wrapped_arrays_and_skips_malformed_entries() {
4279 let text = r#"{"issues": [
4280 {"number": 1, "title": "ok", "state": "open"},
4281 {"title": "no number"},
4282 {"number": 2, "title": "stateless"}
4283 ]}"#;
4284 let items = parse_repo_items(text).expect("items");
4285 assert_eq!(items.len(), 2);
4286 assert_eq!(items[1].state, "");
4287 }
4288
4289 #[test]
4290 fn parse_repo_items_passes_error_text_through() {
4291 // `mcp::call_tool` returns error strings, not JSON — the tab shows them.
4292 let err = parse_repo_items("Error: MCP is not initialized.").unwrap_err();
4293 assert_eq!(err, "Error: MCP is not initialized.");
4294 // JSON that isn't a list shape is surfaced verbatim too.
4295 assert!(parse_repo_items("\"unexpected\"").is_err());
4296 }
4297
4298 #[test]
4299 fn repo_row_formats_number_state_title() {
4300 let item = RepoItem {
4301 number: 12,
4302 title: "Fix the flux capacitor".to_string(),
4303 state: "open".to_string(),
4304 };
4305 assert_eq!(repo_row(&item), "#12 [open] Fix the flux capacitor");
4306 let stateless = RepoItem {
4307 number: 3,
4308 title: "t".to_string(),
4309 state: String::new(),
4310 };
4311 assert_eq!(repo_row(&stateless), "#3 t");
4312 }
4313
4314 #[test]
4315 fn format_repo_detail_pretty_prints_json_and_passes_text_through() {
4316 let pretty = format_repo_detail(r#"{"number":1,"title":"x"}"#);
4317 assert!(
4318 pretty.contains("\n"),
4319 "expected pretty-printed JSON: {pretty}"
4320 );
4321 assert_eq!(
4322 format_repo_detail("Error: server 'sigit' returned 401"),
4323 "Error: server 'sigit' returned 401"
4324 );
4325 }
4326
4327 #[test]
4328 fn format_age_picks_the_coarsest_sensible_unit() {
4329 assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
4330 assert_eq!(format_age(Duration::from_secs(59)), "59s ago");
4331 assert_eq!(format_age(Duration::from_secs(60)), "1m ago");
4332 assert_eq!(format_age(Duration::from_secs(3_599)), "59m ago");
4333 assert_eq!(format_age(Duration::from_secs(3_600)), "1h ago");
4334 assert_eq!(format_age(Duration::from_secs(86_399)), "23h ago");
4335 assert_eq!(format_age(Duration::from_secs(86_400)), "1d ago");
4336 assert_eq!(format_age(Duration::from_secs(3 * 86_400)), "3d ago");
4337 }
4338
4339 #[test]
4340 fn history_row_formats_id_age_and_count() {
4341 assert_eq!(
4342 history_row("tui", Some(Duration::from_secs(120)), 7),
4343 "tui · 2m ago · 7 message(s)"
4344 );
4345 assert_eq!(
4346 history_row("sess-1", None, 0),
4347 "sess-1 · age unknown · 0 message(s)"
4348 );
4349 }
4350
4351 #[test]
4352 fn strip_think_blocks_separates_thinking_and_visible_reply() {
4353 let raw = "<think>I should inspect the code first.</think>Here is the fix.";
4354 let (thinking, visible) = strip_think_blocks(raw);
4355
4356 assert_eq!(thinking, "I should inspect the code first.");
4357 assert_eq!(visible, "Here is the fix.");
4358 }
4359
4360 #[test]
4361 fn strip_think_blocks_handles_unclosed_think_block() {
4362 let raw = "<think>I am still reasoning about the bug";
4363 let (thinking, visible) = strip_think_blocks(raw);
4364
4365 assert_eq!(thinking, "I am still reasoning about the bug");
4366 assert_eq!(visible, "");
4367 }
4368
4369 #[test]
4370 fn strip_think_blocks_leaves_plain_text_untouched() {
4371 let raw = "No hidden reasoning here.";
4372 let (thinking, visible) = strip_think_blocks(raw);
4373
4374 assert_eq!(thinking, "");
4375 assert_eq!(visible, "No hidden reasoning here.");
4376 }
4377
4378 #[test]
4379 fn streaming_think_tail_shows_last_lines_of_unclosed_think_buffer() {
4380 // the common mid-stream shape: `<think>` opened, not yet closed
4381 let raw = "<think>First thought.\nSecond thought.\nThird thought.\nFourth thought.";
4382 let tail = streaming_think_tail(raw, 40, 3);
4383
4384 assert_eq!(
4385 tail,
4386 vec!["Second thought.", "Third thought.", "Fourth thought."]
4387 );
4388 }
4389
4390 #[test]
4391 fn streaming_think_tail_wraps_long_lines_at_width() {
4392 let raw = "<think>one two three four five six seven";
4393 let tail = streaming_think_tail(raw, 10, 3);
4394
4395 // wrapped at 10 cols: ["one two", "three four", "five six", "seven"];
4396 // the tail keeps only the last 3
4397 assert_eq!(tail, vec!["three four", "five six", "seven"]);
4398 }
4399
4400 #[test]
4401 fn streaming_think_tail_is_empty_once_visible_text_arrives() {
4402 let raw = "<think>hidden reasoning</think>The answer is 42.";
4403 assert!(streaming_think_tail(raw, 40, 3).is_empty());
4404 }
4405
4406 #[test]
4407 fn streaming_think_tail_is_empty_for_only_think_closed_but_no_visible_yet() {
4408 // closed think, visible not started: still show the reasoning tail
4409 let raw = "<think>done reasoning</think>";
4410 assert_eq!(streaming_think_tail(raw, 40, 3), vec!["done reasoning"]);
4411 }
4412
4413 #[test]
4414 fn streaming_think_tail_is_empty_for_empty_or_plain_buffers() {
4415 assert!(streaming_think_tail("", 40, 3).is_empty());
4416 assert!(streaming_think_tail("plain visible text", 40, 3).is_empty());
4417 assert!(streaming_think_tail("<think>", 40, 3).is_empty());
4418 }
4419
4420 #[test]
4421 fn streaming_think_tail_hard_splits_words_wider_than_the_wrap_column() {
4422 let raw = "<think>abcdefghijkl";
4423 assert_eq!(
4424 streaming_think_tail(raw, 5, 3),
4425 vec!["abcde", "fghij", "kl"]
4426 );
4427 }
4428
4429 #[test]
4430 fn think_indicator_counts_lines_and_pluralizes() {
4431 assert_eq!(
4432 think_indicator("only one line of reasoning"),
4433 "· thought for a bit (1 line) — /thinking to show"
4434 );
4435 assert_eq!(
4436 think_indicator("a\nb\nc"),
4437 "· thought for a bit (3 lines) — /thinking to show"
4438 );
4439 }
4440
4441 #[test]
4442 fn parse_rich_text_segments_marks_bold_runs() {
4443 let segments = parse_rich_text_segments(
4444 "The current weather is **72°F** with **Partly Cloudy** conditions.",
4445 );
4446
4447 assert_eq!(
4448 segments,
4449 vec![
4450 ("The current weather is ".to_string(), false),
4451 ("72°F".to_string(), true),
4452 (" with ".to_string(), false),
4453 ("Partly Cloudy".to_string(), true),
4454 (" conditions.".to_string(), false),
4455 ]
4456 );
4457 }
4458
4459 #[test]
4460 fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
4461 let segments = parse_rich_text_segments("Prefix **bold");
4462
4463 assert_eq!(
4464 segments,
4465 vec![("Prefix ".to_string(), false), ("bold".to_string(), true),]
4466 );
4467 }
4468 }