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 project instruction files (AGENTS.md / CLAUDE.md) for the launch
2765 // directory so the on-device model gets the same always-on context the
2766 // cloud and ACP paths get.
2767 let system_prompt = {
2768 let base = crate::system_prompt_for_model(model.tool_calling);
2769 match std::env::current_dir()
2770 .ok()
2771 .and_then(|cwd| crate::instructions::load_project_instructions(&cwd))
2772 {
2773 Some(extra) => format!("{base}\n\n{extra}"),
2774 None => base,
2775 }
2776 };
2777 let engine_handle = Arc::clone(&engine);
2778 let tool_calling = model.tool_calling;
2779 std::thread::spawn(move || {
2780 let rt = tokio::runtime::Runtime::new().expect("failed to create model-loader runtime");
2781 let update = rt.block_on(async move {
2782 match engine_handle
2783 .load_gguf_model(
2784 model.config.clone(),
2785 Some(system_prompt.to_string()),
2786 Some(sampling),
2787 )
2788 .await
2789 {
2790 Ok(_) => ModelLoadUpdate::Loaded(model.display_name.clone()),
2791 Err(err) => ModelLoadUpdate::Error(err.to_string()),
2792 }
2793 });
2794 // capacity-1 channel, receiver alive while switching
2795 let _ = tx.blocking_send(update);
2796 });
2797 // applied on ModelLoadUpdate::Loaded
2798 app.pending_tool_calling = Some(tool_calling);
2799 }
2800
2801 // ── Slash command execution ───────────────────────────────────────────────
2802
2803 async fn exec_slash<B: ratatui::backend::Backend>(
2804 app: &mut App,
2805 cmd: SlashCommand,
2806 engine: Arc<ChatEngine>,
2807 terminal: &mut ratatui::Terminal<B>,
2808 ) {
2809 match cmd {
2810 SlashCommand::Help => {
2811 app.messages.push(ChatMessage::system(
2812 "/help — show this message\n\
2813 /models — open the model picker\n\
2814 /models N — switch to model N\n\
2815 /local [on|off]— toggle on-device inference mode\n\
2816 /skills — list available Agent Skills\n\
2817 /agents — list configured subagent types (.sigit/agents/*.md)\n\
2818 /commands — list user-defined commands (.sigit/commands/*.md)\n\
2819 /mcp — list MCP servers and their tools\n\
2820 /load — load the selected on-device model\n\
2821 /login — sign in via your browser\n\
2822 /login E P — sign in with an email and password\n\
2823 /logout — sign out\n\
2824 /whoami — show the signed-in account\n\
2825 /plan [on|off] — plan mode: research only, no edits or commands\n\
2826 /permissions — show the tool permission policy\n\
2827 /thinking [on|off] — expand or collapse model reasoning\n\
2828 /tools [on|off]— expand or collapse tool-call details\n\
2829 /compact — summarize and shrink conversation history\n\
2830 /resume — restore the saved session from disk\n\
2831 /init — generate or improve the repo's AGENTS.md\n\
2832 /clear — wipe conversation history\n\
2833 /status — show engine status\n\
2834 /exit — quit chat",
2835 ));
2836 }
2837 SlashCommand::Clear => {
2838 let cleared = engine.clear_history().await;
2839 app.messages.clear();
2840 crate::permissions::reset_session(crate::permissions::TUI_SESSION);
2841 // The saved session must not resurrect what the user just wiped.
2842 crate::session_store::delete(TUI_STORE_SESSION);
2843 app.context_tokens_used = None;
2844 app.messages.push(ChatMessage::system(format!(
2845 "Cleared {cleared} turn(s). History is empty.",
2846 )));
2847 }
2848 SlashCommand::Compact => {
2849 let before = crate::backend::estimate_tokens(&app.backend.history_snapshot().await);
2850 match app
2851 .backend
2852 .compact_history(crate::backend::COMPACT_KEEP_LAST)
2853 .await
2854 {
2855 Ok(()) => {
2856 let snapshot = app.backend.history_snapshot().await;
2857 let after = crate::backend::estimate_tokens(&snapshot);
2858 // Keep the saved session in step with the compacted state.
2859 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot)
2860 {
2861 log::warn!("session save after /compact failed: {error}");
2862 }
2863 app.context_tokens_used = Some(after);
2864 app.messages.push(ChatMessage::system(format!(
2865 "Compacted history: ~{before} → ~{after} tokens (estimated)."
2866 )));
2867 }
2868 Err(error) => {
2869 app.messages
2870 .push(ChatMessage::system(format!("Compaction failed: {error}")));
2871 }
2872 }
2873 }
2874 SlashCommand::Resume => match crate::session_store::load(TUI_STORE_SESSION) {
2875 Some(history) if !history.is_empty() => {
2876 let restored = history.len();
2877 app.context_tokens_used = Some(crate::backend::estimate_tokens(&history));
2878 app.backend.restore_history(history).await;
2879 app.messages.push(ChatMessage::system(format!(
2880 "Restored {restored} message(s) from the saved session. \
2881 The model remembers the conversation; the scrollback above does not \
2882 replay it."
2883 )));
2884 }
2885 _ => {
2886 app.messages.push(ChatMessage::system(
2887 "No saved session to resume. Sessions are saved after each turn.",
2888 ));
2889 }
2890 },
2891 SlashCommand::Plan(value) => {
2892 use crate::permissions::{self, TUI_SESSION};
2893 let enabled = value.unwrap_or_else(|| !permissions::plan_mode(TUI_SESSION));
2894 permissions::set_plan_mode(TUI_SESSION, enabled);
2895 app.messages.push(ChatMessage::system(if enabled {
2896 "Plan mode ON — research with read-only tools only; edits and commands \
2897 are blocked until /plan off."
2898 } else {
2899 "Plan mode OFF — tools may execute again (subject to the permission \
2900 policy)."
2901 }));
2902 }
2903 SlashCommand::Permissions => {
2904 app.messages
2905 .push(ChatMessage::system(crate::permissions::describe(
2906 crate::permissions::TUI_SESSION,
2907 )));
2908 }
2909 SlashCommand::Thinking(value) => {
2910 app.show_thinking = value.unwrap_or(!app.show_thinking);
2911 app.messages.push(ChatMessage::system(if app.show_thinking {
2912 "Thinking display ON — model reasoning is shown above each reply."
2913 } else {
2914 "Thinking display OFF — replies show a one-line indicator; \
2915 /thinking to expand."
2916 }));
2917 }
2918 SlashCommand::Tools(value) => {
2919 app.tools_expanded = value.unwrap_or(!app.tools_expanded);
2920 app.messages
2921 .push(ChatMessage::system(if app.tools_expanded {
2922 "Tool calls expanded — /tools off to collapse them."
2923 } else {
2924 "Tool calls collapsed — /tools on to expand them."
2925 }));
2926 }
2927 SlashCommand::Status => {
2928 let info = engine.as_ref().info().await;
2929 let model = info.model_name.as_deref().unwrap_or("(none)");
2930 let mem = info.approx_memory.as_deref().unwrap_or("unknown");
2931 app.messages.push(ChatMessage::system(format!(
2932 "status: {:?} model: {} memory: {} history: {} turns",
2933 info.status, model, mem, info.history_length,
2934 )));
2935 }
2936 SlashCommand::Skills => {
2937 app.messages
2938 .push(ChatMessage::system(crate::skills::format_skills_list()));
2939 }
2940 SlashCommand::Agents => {
2941 app.messages.push(ChatMessage::system(
2942 crate::subagents::format_agent_types_list(),
2943 ));
2944 }
2945 SlashCommand::Commands => {
2946 app.messages
2947 .push(ChatMessage::system(crate::commands::format_commands_list()));
2948 }
2949 SlashCommand::Mcp => {
2950 app.messages
2951 .push(ChatMessage::system(crate::mcp::status_summary()));
2952 }
2953 SlashCommand::Models(selection) => match selection {
2954 None => {
2955 app.open_model_picker(&engine);
2956 }
2957 Some(n) => {
2958 let idx = n.saturating_sub(1);
2959 match app.model_picker_items.get(idx).cloned() {
2960 None => {
2961 app.messages.push(ChatMessage::system(format!(
2962 "error: no model #{n} — type /models to see the list."
2963 )));
2964 }
2965 Some(model) => {
2966 // ── siGit Code Cloud tier: no local load; sign-in gated ──
2967 if let Some(tier) = model.cloud_tier.clone() {
2968 app.close_model_picker();
2969 match crate::provider::cloud_tier_provider(&tier) {
2970 Some(provider) => {
2971 let system_prompt = crate::system_prompt_for_model(true);
2972 app.backend = Arc::new(OpenAiBackend::new(
2973 provider.base_url,
2974 provider.api_key,
2975 provider.model,
2976 Some(system_prompt),
2977 ));
2978 app.current_model_name = provider.display_name.clone();
2979 app.tool_calling = true;
2980 // Selecting a cloud tier puts us in cloud mode.
2981 let _ = crate::settings::set_local_inference(false);
2982 app.messages.push(ChatMessage::system(format!(
2983 "Switched to {}.",
2984 provider.display_name
2985 )));
2986 }
2987 None => {
2988 app.messages.push(ChatMessage::system(
2989 "siGit Code Cloud needs an account. Use \
2990 `/login <email> <password>`, or create one at sigit.si.",
2991 ));
2992 }
2993 }
2994 return;
2995 }
2996
2997 app.close_model_picker();
2998 start_local_model_load(app, model, Arc::clone(&engine), terminal);
2999 }
3000 }
3001 }
3002 },
3003 SlashCommand::Local(value) => {
3004 let enabled = value.unwrap_or(!crate::settings::local_inference_enabled());
3005 match crate::settings::set_local_inference(enabled) {
3006 Ok(()) => {
3007 let state = if enabled { "on" } else { "off" };
3008 let hint = if enabled {
3009 "On-device models are highlighted. Type /models to pick one."
3010 } else {
3011 "siGit Code Cloud tiers are highlighted. Type /models to pick one."
3012 };
3013 app.messages.push(ChatMessage::system(format!(
3014 "Local inference is {state}. {hint}"
3015 )));
3016 // Refresh the picker so emphasis/order reflects the new mode.
3017 if app.show_model_picker {
3018 app.open_model_picker(&engine);
3019 }
3020 }
3021 Err(error) => {
3022 app.messages.push(ChatMessage::system(format!(
3023 "error: could not save local inference setting: {error}"
3024 )));
3025 }
3026 }
3027 }
3028 SlashCommand::Load => match default_local_model_item(app) {
3029 None => {
3030 app.messages.push(ChatMessage::system(
3031 "No local model available to load. Use /models to see the list.",
3032 ));
3033 }
3034 Some(model) => {
3035 start_local_model_load(app, model, Arc::clone(&engine), terminal);
3036 }
3037 },
3038 SlashCommand::Login(arg) => {
3039 let message = match arg.as_deref().and_then(crate::account::parse_login_args) {
3040 Some((email, password)) => {
3041 match crate::account::authenticate(&email, &password).await {
3042 Ok(email) => format!(
3043 "Signed in as {email}. siGit Code Cloud applies to your next session."
3044 ),
3045 Err(error) => format!("Login failed: {error}"),
3046 }
3047 }
3048 // A bare `/login` opens the browser instead of asking for
3049 // a password in the chat box.
3050 None => {
3051 app.messages.push(ChatMessage::system(
3052 "Opening your browser to sign in to siGit Code Cloud...",
3053 ));
3054 crate::browser_login_message().await
3055 }
3056 };
3057 app.messages.push(ChatMessage::system(message));
3058 }
3059 SlashCommand::Logout => {
3060 let message = crate::account::end_session().await;
3061 app.messages.push(ChatMessage::system(message));
3062 }
3063 SlashCommand::Whoami => {
3064 let message = crate::account::status_line().await;
3065 app.messages.push(ChatMessage::system(message));
3066 }
3067 SlashCommand::Init => {
3068 // Handled in the input loop (it runs a real turn); reaching
3069 // this arm means a caller forgot that special case.
3070 app.messages
3071 .push(ChatMessage::system("Type /init in the chat to run it."));
3072 }
3073 SlashCommand::Exit => {
3074 app.quit = true;
3075 }
3076 SlashCommand::Unknown(cmd, _) => {
3077 app.messages.push(ChatMessage::system(format!(
3078 "unknown command: {cmd}. Type /commands to list user-defined commands."
3079 )));
3080 }
3081 }
3082 }
3083
3084 // ── Background inference task ─────────────────────────────────────────────
3085
3086 /// cap tool rounds so a confused model can't loop forever; auto-compaction
3087 /// keeps long runs inside the context window, so the cap can be generous
3088 const MAX_TOOL_ROUNDS: usize = 24;
3089
3090 /// The TUI is a single conversation, so it persists under one fixed
3091 /// session-store id (ACP sessions use their protocol-assigned ids).
3092 const TUI_STORE_SESSION: &str = "tui";
3093
3094 fn build_tool_specs() -> Vec<ToolSpec> {
3095 let mut specs: Vec<ToolSpec> = crate::tools::all_tools()
3096 .into_iter()
3097 .map(|t| ToolSpec {
3098 name: t.name.to_string(),
3099 description: t.description.to_string(),
3100 parameters_schema: t.parameters_schema.to_string(),
3101 })
3102 .collect();
3103
3104 // Advertise the Agent Skills `skill` tool only when skills exist on disk
3105 // (https://agentskills.io). The tool description carries the discovery
3106 // list (name + description) for progressive disclosure.
3107 let discovered = crate::skills::discover_skills();
3108 if !discovered.is_empty() {
3109 specs.push(ToolSpec {
3110 name: crate::skills::SKILL_TOOL_NAME.to_string(),
3111 description: crate::skills::skill_tool_description(&discovered),
3112 parameters_schema: crate::skills::skill_tool_schema().to_string(),
3113 });
3114 }
3115
3116 // Delegated research (`task`) is offered only when a subagent backend
3117 // can actually be built — same conditional pattern as `skill` above.
3118 if crate::tools::subagent_available() {
3119 specs.push(crate::tools::task_tool_spec());
3120 }
3121
3122 // `web_search` is a native wrapper around the official MCP server's
3123 // `web_search` tool, offered only when that MCP tool was actually
3124 // discovered (i.e. the user is signed in to siGit Code Cloud).
3125 let web_search_offered = crate::tools::web_search_available();
3126 if web_search_offered {
3127 specs.push(crate::tools::web_search_tool_spec());
3128 }
3129
3130 // Tools discovered from configured MCP servers (incl. the official
3131 // one). Exclude the raw web_search delegate when the native wrapper
3132 // above is already offered, so the model sees one clean option, not
3133 // two names for the same tool.
3134 specs.extend(crate::mcp::tool_specs().into_iter().filter(|spec| {
3135 !(web_search_offered && crate::tools::is_web_search_delegate(&spec.name))
3136 }));
3137
3138 specs
3139 }
3140
3141 /// Close out a cancelled round in backend history: the results of tools
3142 /// that already ran this round, plus cancellation notes for `unreached`
3143 /// calls. Leaving a round's tool calls unanswered breaks strict
3144 /// OpenAI-compatible endpoints on the session's next request.
3145 async fn abandon_round(
3146 backend: &dyn InferenceBackend,
3147 mut tool_results: Vec<ToolResult>,
3148 unreached: &[crate::backend::ToolCall],
3149 ) {
3150 for pending in unreached {
3151 tool_results.push(ToolResult {
3152 tool_call_id: pending.id.clone(),
3153 content: format!(
3154 "`{}` was not executed: the user cancelled the turn.",
3155 pending.name
3156 ),
3157 });
3158 }
3159 backend.record_cancelled_tool_results(tool_results).await;
3160 }
3161
3162 /// run the tool-calling loop off the main thread, posting updates via `tx`.
3163 /// dropping `tx` signals completion to the event loop.
3164 async fn run_inference_task(
3165 backend: Arc<dyn InferenceBackend>,
3166 text: String,
3167 tx: mpsc::Sender<InferenceUpdate>,
3168 tools_enabled: bool,
3169 ) {
3170 let tools = if tools_enabled {
3171 build_tool_specs()
3172 } else {
3173 vec![]
3174 };
3175
3176 // Bridge the backend's token sink (plain strings) onto the UI update
3177 // channel as `Delta` messages. The forwarder lives for the whole turn.
3178 let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
3179 let forward_tx = tx.clone();
3180 let forwarder = tokio::spawn(async move {
3181 while let Some(piece) = delta_rx.recv().await {
3182 if forward_tx
3183 .send(InferenceUpdate::Delta(piece))
3184 .await
3185 .is_err()
3186 {
3187 break;
3188 }
3189 }
3190 });
3191
3192 // The first round offers tools, so on-device inference can't stream it
3193 // (it must buffer to detect tool calls). With tools disabled there are
3194 // none to offer, so it streams directly.
3195 let first_sink = if tools.is_empty() {
3196 Some(&delta_tx)
3197 } else {
3198 None
3199 };
3200 let mut streamed = first_sink.is_some();
3201
3202 let mut result = match backend
3203 .send_message_with_tools(&text, &tools, first_sink)
3204 .await
3205 {
3206 Ok(r) => r,
3207 Err(err) => {
3208 let _ = tx.send(InferenceUpdate::Error(err)).await;
3209 return;
3210 }
3211 };
3212
3213 let mut round = 0;
3214
3215 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
3216 // any tool call means the first round didn't produce a final answer
3217 streamed = false;
3218 round += 1;
3219 log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
3220
3221 // Auto-compaction: long tool runs grow history fast; fold it into
3222 // a summary before the next round rather than blowing the window.
3223 let estimate = crate::backend::estimate_tokens(&backend.history_snapshot().await);
3224 if estimate > crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
3225 log::info!(
3226 "history ≈{estimate} tokens exceeds budget {} — compacting",
3227 crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET
3228 );
3229 match backend
3230 .compact_history(crate::backend::COMPACT_KEEP_LAST)
3231 .await
3232 {
3233 Ok(()) => {
3234 let after =
3235 crate::backend::estimate_tokens(&backend.history_snapshot().await);
3236 log::info!("compacted history to ≈{after} tokens");
3237 let _ = tx.send(InferenceUpdate::ContextUsage(after)).await;
3238 }
3239 Err(error) => log::warn!("history compaction failed: {error}"),
3240 }
3241 }
3242
3243 let mut tool_results = Vec::new();
3244
3245 for (call_index, tc) in result.tool_calls.iter().enumerate() {
3246 // The UI drops the receiver on Ctrl+C or quit. Stop the turn
3247 // at the next boundary instead of burning model rounds (and
3248 // possibly running granted tools) in the background.
3249 if tx.is_closed() {
3250 log::info!("turn cancelled by the user — stopping the tool loop");
3251 abandon_round(&*backend, tool_results, &result.tool_calls[call_index..]).await;
3252 return;
3253 }
3254
3255 log::info!(
3256 " → {}({})",
3257 tc.name,
3258 tc.arguments.chars().take(120).collect::<String>()
3259 );
3260
3261 let _ = tx
3262 .send(InferenceUpdate::ToolUse {
3263 name: tc.name.clone(),
3264 arguments: tc.arguments.clone(),
3265 })
3266 .await;
3267
3268 // Permission gate: read-only tools pass straight through; a
3269 // mutating tool consults policy and may pause on the user's
3270 // y/a/n answer (delivered over a oneshot from the event loop).
3271 use crate::permissions::{self, Decision, TUI_SESSION};
3272 let output = match permissions::decision_for(TUI_SESSION, &tc.name, &tc.arguments) {
3273 Decision::Allow => crate::tools::execute_tool(&tc.name, &tc.arguments).await,
3274 Decision::Deny(reason) => {
3275 log::info!(" ✗ {} denied by policy", tc.name);
3276 reason
3277 }
3278 Decision::Ask => {
3279 let (reply_tx, reply_rx) = oneshot::channel();
3280 let _ = tx
3281 .send(InferenceUpdate::ApprovalRequest {
3282 tool: tc.name.clone(),
3283 args: permissions::approval_preview(&tc.arguments),
3284 reply: reply_tx,
3285 })
3286 .await;
3287 match reply_rx.await {
3288 Ok(ApprovalChoice::Once) => {
3289 crate::tools::execute_tool(&tc.name, &tc.arguments).await
3290 }
3291 Ok(ApprovalChoice::Session) => {
3292 permissions::grant_for_session(
3293 TUI_SESSION,
3294 &tc.name,
3295 &tc.arguments,
3296 );
3297 crate::tools::execute_tool(&tc.name, &tc.arguments).await
3298 }
3299 Ok(ApprovalChoice::Deny) => {
3300 log::info!(" ✗ {} denied by user", tc.name);
3301 permissions::user_denial(&tc.name)
3302 }
3303 // The UI dropped the reply channel (Ctrl+C or
3304 // quit): the whole turn is over, not just this
3305 // call. Close out the round and stop instead of
3306 // continuing rounds in the background.
3307 Err(_) => {
3308 log::info!(
3309 "turn cancelled at the approval prompt — stopping the tool loop"
3310 );
3311 abandon_round(
3312 &*backend,
3313 tool_results,
3314 &result.tool_calls[call_index..],
3315 )
3316 .await;
3317 return;
3318 }
3319 }
3320 }
3321 };
3322 log::info!(" ← {} chars", output.len());
3323
3324 // Display-only preview of the output tail; the model gets the
3325 // full output through `tool_results` below.
3326 let _ = tx
3327 .send(InferenceUpdate::ToolDone {
3328 output_preview: super::cap_output_preview(&output),
3329 })
3330 .await;
3331
3332 tool_results.push(ToolResult {
3333 tool_call_id: tc.id.clone(),
3334 content: output,
3335 });
3336 }
3337
3338 // Cancelled while the round's tools ran: record what executed and
3339 // stop before paying for another model round nobody will see.
3340 if tx.is_closed() {
3341 log::info!("turn cancelled by the user — skipping the next model round");
3342 abandon_round(&*backend, tool_results, &[]).await;
3343 return;
3344 }
3345
3346 // on the last round, pass no tools so the model must produce text —
3347 // that's also the round we can stream on-device.
3348 let allow_tool_calls = round < MAX_TOOL_ROUNDS;
3349 let sink = if !allow_tool_calls {
3350 streamed = true;
3351 Some(&delta_tx)
3352 } else {
3353 None
3354 };
3355
3356 match backend
3357 .send_tool_results(tool_results, &tools, allow_tool_calls, sink)
3358 .await
3359 {
3360 Ok(r) => result = r,
3361 Err(err) => {
3362 let _ = tx.send(InferenceUpdate::Error(err)).await;
3363 return;
3364 }
3365 }
3366 }
3367
3368 // Drop the sink so the forwarder finishes draining any buffered tokens
3369 // before we commit the reply.
3370 drop(delta_tx);
3371 let _ = forwarder.await;
3372
3373 if result.tool_calls.is_empty() {
3374 if result.text.is_empty() {
3375 log::warn!(
3376 "model returned empty reply — may have exhausted max_tokens on thinking"
3377 );
3378 let _ = tx
3379 .send(InferenceUpdate::Error(
3380 "(empty response — the model may have used all tokens on internal reasoning. \
3381 Try a shorter or simpler prompt.)"
3382 .to_string(),
3383 ))
3384 .await;
3385 } else if streamed {
3386 // tokens already went out as deltas; just commit the buffer
3387 let _ = tx.send(InferenceUpdate::StreamEnd).await;
3388 } else {
3389 let _ = tx.send(InferenceUpdate::Response(result.text)).await;
3390 }
3391 }
3392
3393 // Persist the completed turn so /resume (or a restart) can pick the
3394 // conversation back up.
3395 let snapshot = backend.history_snapshot().await;
3396 let _ = tx
3397 .send(InferenceUpdate::ContextUsage(
3398 crate::backend::estimate_tokens(&snapshot),
3399 ))
3400 .await;
3401 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot) {
3402 log::warn!("session save failed: {error}");
3403 }
3404
3405 log::info!("inference complete — {} tool round(s)", round);
3406 // tx drops here — event loop gets None from rx.recv()
3407 }
3408
3409 // ── Main loop ─────────────────────────────────────────────────────────────
3410
3411 /// entry point — blocks until the user quits.
3412 /// caller owns terminal init/restore. `load_rx` delivers the model-load result
3413 /// from a dedicated OS thread; we poll it non-blocking each tick.
3414 pub async fn run_with<B: ratatui::backend::Backend>(
3415 terminal: &mut ratatui::Terminal<B>,
3416 engine: Arc<ChatEngine>,
3417 backend: Arc<dyn InferenceBackend>,
3418 load_rx: std_mpsc::Receiver<Result<(), String>>,
3419 load_model_name: String,
3420 ) -> Result<()> {
3421 event_loop(terminal, engine, backend, load_rx, load_model_name).await
3422 }
3423
3424 async fn event_loop<B: ratatui::backend::Backend>(
3425 terminal: &mut ratatui::Terminal<B>,
3426 engine: Arc<ChatEngine>,
3427 backend: Arc<dyn InferenceBackend>,
3428 load_rx: std_mpsc::Receiver<Result<(), String>>,
3429 load_model_name: String,
3430 ) -> Result<()> {
3431 let mut app = App::new(load_model_name, backend);
3432 let mut event_stream = EventStream::new();
3433
3434 // 10 fps is plenty for spinners
3435 let mut ticker = interval(Duration::from_millis(100));
3436
3437 loop {
3438 // ── Poll the loader channel (non-blocking) ────────────────────────
3439 if app.is_loading {
3440 match load_rx.try_recv() {
3441 Ok(Ok(())) => app.finish_loading(),
3442 Ok(Err(e)) => app.set_load_error(e),
3443 Err(std_mpsc::TryRecvError::Empty) => {}
3444 Err(std_mpsc::TryRecvError::Disconnected) => {
3445 app.set_load_error("Model loader thread crashed.".to_string());
3446 }
3447 }
3448 }
3449
3450 // redraw every iteration
3451 terminal.draw(|frame| render(frame, &mut app))?;
3452
3453 if let Some(rx) = app.model_load_rx.as_mut() {
3454 match rx.try_recv() {
3455 Ok(ModelLoadUpdate::Loaded(model_name)) => {
3456 engine.clear_history().await;
3457 app.context_tokens_used = None;
3458 if let Some(tc) = app.pending_tool_calling.take() {
3459 app.tool_calling = tc;
3460 }
3461 app.switching_model = false;
3462 app.switching_model_id = None;
3463 app.download_progress = None;
3464 app.model_load_cancelled = false;
3465 app.model_load_rx = None;
3466 app.current_model_name = model_name.clone();
3467
3468 let save_result = app
3469 .model_picker_items
3470 .iter()
3471 .find(|item| item.display_name == model_name)
3472 .map(|item| crate::setup::SelectedModel {
3473 model_id: item.config.model_id.clone(),
3474 gguf_file: item
3475 .config
3476 .files
3477 .first()
3478 .cloned()
3479 .unwrap_or_else(String::new),
3480 })
3481 .filter(|selected| !selected.gguf_file.is_empty())
3482 .map(|selected| crate::setup::save_selected_model(&selected))
3483 .unwrap_or_else(|| {
3484 Err(format!(
3485 "could not determine a stable identifier for {}",
3486 model_name
3487 ))
3488 });
3489
3490 if let Err(error) = save_result {
3491 app.messages.push(ChatMessage::system(format!(
3492 "warning: switched to {} but could not save the selection: {}",
3493 model_name, error
3494 )));
3495 } else {
3496 app.messages
3497 .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
3498 }
3499 }
3500 Ok(ModelLoadUpdate::Error(error)) => {
3501 app.switching_model = false;
3502 app.switching_model_id = None;
3503 app.download_progress = None;
3504 app.model_load_cancelled = false;
3505 app.model_load_rx = None;
3506 app.messages
3507 .push(ChatMessage::system(format!("error loading model: {error}")));
3508 }
3509 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
3510 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
3511 let was_cancelled = app.model_load_cancelled;
3512 app.switching_model = false;
3513 app.switching_model_id = None;
3514 app.download_progress = None;
3515 app.model_load_cancelled = false;
3516 app.model_load_rx = None;
3517 if !was_cancelled {
3518 app.messages.push(ChatMessage::system(
3519 "error loading model: loader task disconnected".to_string(),
3520 ));
3521 }
3522 }
3523 }
3524 }
3525
3526 if app.quit {
3527 break;
3528 }
3529
3530 // multiplex terminal events, streaming tokens, inference updates,
3531 // and the thinking-spinner timer.
3532 tokio::select! {
3533 biased;
3534
3535 // ── Spinner tick (loading phase only) ─────────────────────────
3536 _ = ticker.tick(), if app.is_loading => {
3537 app.tick();
3538 }
3539
3540 // ── inference updates from background task ───────────────────
3541 update = async {
3542 match app.inference_rx.as_mut() {
3543 Some(rx) => rx.recv().await,
3544 None => pending().await,
3545 }
3546 } => {
3547 match update {
3548 Some(InferenceUpdate::ToolUse { name, arguments }) => {
3549 let title = super::tool_title(&name, &arguments);
3550 let detail = super::pretty_tool_arguments(&arguments);
3551 app.messages.push(ChatMessage::tool_call(title, detail));
3552 }
3553 Some(InferenceUpdate::ToolDone { output_preview }) => {
3554 // Attach to the most recent tool entry still
3555 // waiting for its result.
3556 if let Some(entry) = app
3557 .messages
3558 .iter_mut()
3559 .rev()
3560 .find(|m| m.role == Role::Tool && m.tool_result.is_none())
3561 {
3562 entry.tool_result = Some(output_preview);
3563 }
3564 }
3565 Some(InferenceUpdate::Delta(delta)) => {
3566 app.push_stream_delta(&delta);
3567 }
3568 Some(InferenceUpdate::StreamEnd) => {
3569 app.finalize_stream();
3570 }
3571 Some(InferenceUpdate::Response(text)) => {
3572 app.stop_thinking();
3573 app.messages.push(ChatMessage::assistant(text));
3574 }
3575 Some(InferenceUpdate::ContextUsage(tokens)) => {
3576 app.context_tokens_used = Some(tokens);
3577 }
3578 Some(InferenceUpdate::Error(msg)) => {
3579 app.finalize_stream();
3580 app.stop_thinking();
3581 app.messages.push(ChatMessage::system(format!("error: {msg}")));
3582 }
3583 Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
3584 // The y/a/n prompt lives on the Session tab; make
3585 // sure the user can see what they're answering.
3586 app.active_tab = Tab::Session;
3587 let call = if args.is_empty() {
3588 tool.clone()
3589 } else {
3590 format!("{tool}({args})")
3591 };
3592 app.messages.push(ChatMessage::system(format!(
3593 "⚠ permission — allow {call}? [y]es · [a]lways this session · [n]o"
3594 )));
3595 app.pending_approval = Some((tool, reply));
3596 }
3597 None => {
3598 // task finished, possibly with no text to show
3599 app.finalize_stream();
3600 app.stop_thinking();
3601 }
3602 }
3603 }
3604
3605 // ── Cloud tab status fetch resolving ─────────────────────────
3606 status = async {
3607 match app.cloud_rx.as_mut() {
3608 Some(rx) => rx.await,
3609 None => pending().await,
3610 }
3611 } => {
3612 app.cloud_rx = None;
3613 app.cloud_lines = Some(status.unwrap_or_else(|_| {
3614 vec!["error: the status fetch task died — press r to retry".to_string()]
3615 }));
3616 }
3617
3618 // ── Repo tab fetches resolving ────────────────────────────────
3619 update = async {
3620 match app.repo_rx.as_mut() {
3621 Some(rx) => rx.recv().await,
3622 None => pending().await,
3623 }
3624 } => {
3625 match update {
3626 Some(RepoUpdate::Issues(result)) => {
3627 app.repo_issues = match result {
3628 Ok(items) => RepoData::Ready(items),
3629 Err(error) => RepoData::Failed(error),
3630 };
3631 app.repo_issue_index = app
3632 .repo_issue_index
3633 .min(app.repo_issues.len().saturating_sub(1));
3634 }
3635 Some(RepoUpdate::PullRequests(result)) => {
3636 app.repo_prs = match result {
3637 Ok(items) => RepoData::Ready(items),
3638 Err(error) => RepoData::Failed(error),
3639 };
3640 app.repo_pr_index = app
3641 .repo_pr_index
3642 .min(app.repo_prs.len().saturating_sub(1));
3643 }
3644 Some(RepoUpdate::Detail { target, text }) => {
3645 // Apply only to the matching open view: a reply for
3646 // a detail the user has since closed or navigated
3647 // away from is dropped, never shown under the wrong
3648 // title.
3649 if let Some(detail) = app.repo_detail.as_mut()
3650 && detail.target == target
3651 {
3652 detail.text = Some(text);
3653 }
3654 }
3655 // All senders gone (can't happen while app.repo_tx is
3656 // held) — stop polling this channel.
3657 None => app.repo_rx = None,
3658 }
3659 }
3660
3661 // ── thinking / switching spinner tick (100ms) ────────────────
3662 _ = async {
3663 if app.thinking || app.switching_model {
3664 tokio::time::sleep(Duration::from_millis(100)).await
3665 } else {
3666 pending().await
3667 }
3668 } => {
3669 app.tick_thinking();
3670 // keep the progress display fresh
3671 if app.switching_model {
3672 app.poll_download_progress();
3673 }
3674 }
3675
3676 // ── Terminal events ───────────────────────────────────────────
3677 maybe_event = event_stream.next() => {
3678 let Some(Ok(event)) = maybe_event else {
3679 break;
3680 };
3681
3682 if let Event::Key(key) = event {
3683 // loading phase — only quit keys work
3684 if app.is_loading {
3685 if key.kind == KeyEventKind::Press {
3686 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3687 if ctrl
3688 && (key.code == KeyCode::Char('c')
3689 || key.code == KeyCode::Char('d'))
3690 {
3691 app.quit = true;
3692 }
3693 }
3694 continue;
3695 }
3696
3697 // pending tool approval — y/a/n answer the prompt; the
3698 // inference task is paused on the reply channel. Checked
3699 // before the busy gate because the app *is* busy here.
3700 if app.pending_approval.is_some() {
3701 if key.kind == KeyEventKind::Press {
3702 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3703 let choice = if ctrl
3704 && (key.code == KeyCode::Char('c')
3705 || key.code == KeyCode::Char('d'))
3706 {
3707 // cancel the whole turn: denying is implicit
3708 // in dropping the reply channel
3709 app.pending_approval = None;
3710 app.stop_thinking();
3711 app.messages.push(ChatMessage::system("(cancelled)"));
3712 continue;
3713 } else {
3714 match key.code {
3715 KeyCode::Char('y') | KeyCode::Char('Y') => {
3716 Some(ApprovalChoice::Once)
3717 }
3718 KeyCode::Char('a') | KeyCode::Char('A') => {
3719 Some(ApprovalChoice::Session)
3720 }
3721 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
3722 Some(ApprovalChoice::Deny)
3723 }
3724 _ => None,
3725 }
3726 };
3727 if let Some(choice) = choice
3728 && let Some((tool, reply)) = app.pending_approval.take()
3729 {
3730 let verdict = match &choice {
3731 ApprovalChoice::Once => "allowed once",
3732 ApprovalChoice::Session => "allowed for this session",
3733 ApprovalChoice::Deny => "denied",
3734 };
3735 app.messages.push(ChatMessage::system(format!(
3736 "{tool}: {verdict}"
3737 )));
3738 let _ = reply.send(choice);
3739 }
3740 }
3741 continue;
3742 }
3743
3744 // ── Tab-bar navigation ────────────────────────────────
3745 // Handled before the busy gate so the user can look at
3746 // History/Cloud while inference runs (updates keep
3747 // landing in the Session tab's message list). The Tab
3748 // key only cycles when the input buffer is empty, so
3749 // pasted text containing tabs can't fight it; on
3750 // non-Session tabs the input is hidden, so it always
3751 // cycles there.
3752 if key.kind == KeyEventKind::Press && !app.show_model_picker {
3753 if key.code == KeyCode::Tab
3754 && (app.active_tab != Tab::Session || app.input.is_empty())
3755 {
3756 let next = app.active_tab.next(app.repo_visible());
3757 switch_tab(&mut app, next, &engine);
3758 continue;
3759 }
3760 if app.active_tab != Tab::Session && key.code == KeyCode::Esc {
3761 // On the Repo tab, Esc first closes an open
3762 // detail view (back to the list).
3763 if app.active_tab == Tab::Repo && app.repo_detail.is_some() {
3764 app.repo_detail = None;
3765 } else {
3766 app.active_tab = Tab::Session;
3767 }
3768 continue;
3769 }
3770 }
3771
3772 // busy — only cancel keys work
3773 if app.is_busy() {
3774 if key.kind == KeyEventKind::Press {
3775 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3776 if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
3777 if app.is_streaming() {
3778 app.finalize_stream();
3779 app.messages.push(ChatMessage::system("(cancelled)"));
3780 }
3781 if app.thinking {
3782 // dropping rx kills the background task
3783 app.stop_thinking();
3784 app.messages.push(ChatMessage::system("(cancelled)"));
3785 }
3786 if app.switching_model {
3787 // flag before drop so Disconnected handler stays quiet
3788 app.model_load_cancelled = true;
3789 app.switching_model = false;
3790 app.switching_model_id = None;
3791 app.download_progress = None;
3792 app.model_load_rx = None;
3793 app.messages
3794 .push(ChatMessage::system("(download cancelled — model switch aborted)"));
3795 }
3796 }
3797 }
3798 continue;
3799 }
3800
3801 // History / Cloud tabs have their own key handling; the
3802 // chat input is inactive there.
3803 if app.active_tab != Tab::Session {
3804 if key.kind == KeyEventKind::Press {
3805 handle_tab_key(&mut app, key, &engine).await;
3806 }
3807 continue;
3808 }
3809
3810 if let Some(text) = handle_key(&mut app, key) {
3811 // `/init` runs a real agent turn: the transcript
3812 // shows the command the user typed, but the model
3813 // receives the canned AGENTS.md-generation prompt.
3814 // User-defined commands (`.sigit/commands/*.md`)
3815 // get the same treatment once `parse_slash`
3816 // reports the word unrecognized.
3817 let mut inference_text = text.clone();
3818 match parse_slash(&text) {
3819 Some(SlashCommand::Init) => {
3820 inference_text =
3821 crate::instructions::INIT_PROMPT.to_string();
3822 }
3823 Some(SlashCommand::Unknown(command, argument)) => {
3824 match crate::commands::resolve_command(&command) {
3825 Some(custom) => {
3826 inference_text = crate::commands::render(
3827 &custom.body,
3828 argument.as_deref(),
3829 );
3830 }
3831 None => {
3832 exec_slash(
3833 &mut app,
3834 SlashCommand::Unknown(command, argument),
3835 Arc::clone(&engine),
3836 terminal,
3837 )
3838 .await;
3839 continue;
3840 }
3841 }
3842 }
3843 Some(cmd) => {
3844 exec_slash(&mut app, cmd, Arc::clone(&engine), terminal)
3845 .await;
3846 continue;
3847 }
3848 None => {}
3849 }
3850
3851 // On-device inference needs a model in memory, and we
3852 // never load one implicitly: the user loads it with
3853 // /load (or /models). Refuse rather than erroring out
3854 // deep in the backend.
3855 if !app.backend.is_remote()
3856 && engine.info().await.status == onde::inference::EngineStatus::Unloaded
3857 {
3858 app.messages.push(ChatMessage::user(&text));
3859 app.messages.push(ChatMessage::system(
3860 "No on-device model is loaded. Run /load to load the selected \
3861 model, or /models to choose one.",
3862 ));
3863 continue;
3864 }
3865
3866 // ── spawn inference ──────────────────────────────
3867 app.messages.push(ChatMessage::user(&text));
3868 app.start_thinking();
3869
3870 let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
3871 app.inference_rx = Some(rx);
3872
3873 let backend_handle = Arc::clone(&app.backend);
3874 let user_text = inference_text;
3875 let tools_enabled = app.tool_calling;
3876 tokio::spawn(async move {
3877 run_inference_task(backend_handle, user_text, tx, tools_enabled).await;
3878 });
3879 }
3880 }
3881 }
3882 }
3883 }
3884
3885 Ok(())
3886 }
3887
3888 // ── Download progress helpers (TUI) ──────────────────────────────────────
3889
3890 /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks)
3891 fn dir_size_recursive(path: &std::path::Path) -> u64 {
3892 let mut total: u64 = 0;
3893 let Ok(entries) = std::fs::read_dir(path) else {
3894 return 0;
3895 };
3896 for entry in entries.flatten() {
3897 let entry_path = entry.path();
3898 if entry_path.is_dir() {
3899 total += dir_size_recursive(&entry_path);
3900 } else if let Ok(meta) = entry_path.metadata() {
3901 total += meta.len();
3902 }
3903 }
3904 total
3905 }
3906
3907 fn format_size_human(bytes: u64) -> String {
3908 const GB: u64 = 1_073_741_824;
3909 const MB: u64 = 1_048_576;
3910 const KB: u64 = 1_024;
3911 if bytes >= GB {
3912 format!("{:.2} GB", bytes as f64 / GB as f64)
3913 } else if bytes >= MB {
3914 format!("{:.1} MB", bytes as f64 / MB as f64)
3915 } else if bytes >= KB {
3916 format!("{:.0} KB", bytes as f64 / KB as f64)
3917 } else {
3918 format!("{bytes} B")
3919 }
3920 }
3921 #[cfg(test)]
3922 mod tui_tests {
3923 use super::{compact_tokens, context_gauge_color, context_gauge_label};
3924 use ratatui::style::Color;
3925
3926 #[test]
3927 fn context_gauge_reports_share_of_the_window() {
3928 assert_eq!(context_gauge_label(168_000, 200_000), "84% · 168K/200K ctx");
3929 assert_eq!(context_gauge_label(0, 24_000), "0% · 0/24K ctx");
3930 // Over-full history (pre-compaction) clamps rather than reading 130%.
3931 assert_eq!(context_gauge_label(31_000, 24_000), "100% · 31K/24K ctx");
3932 }
3933
3934 #[test]
3935 fn context_gauge_handles_an_unknown_window() {
3936 assert_eq!(context_gauge_label(1_000, 0), "ctx —");
3937 assert_eq!(context_gauge_color(1_000, 0), Color::DarkGray);
3938 }
3939
3940 #[test]
3941 fn context_gauge_warms_up_as_the_window_fills() {
3942 assert_eq!(context_gauge_color(1_000, 24_000), Color::DarkGray);
3943 assert_eq!(context_gauge_color(18_000, 24_000), Color::Yellow);
3944 assert_eq!(context_gauge_color(23_000, 24_000), Color::Red);
3945 }
3946
3947 #[test]
3948 fn compact_tokens_abbreviates_thousands_only() {
3949 assert_eq!(compact_tokens(999), "999");
3950 assert_eq!(compact_tokens(1_500), "1K");
3951 }
3952 }
3953 } // end #[cfg(unix)] mod tui
3954
3955 // re-export so callers write `chat::run_with(...)` on all platforms
3956 #[cfg(unix)]
3957 pub use tui::run_with;
3958
3959 // ── Tests (platform-agnostic) ─────────────────────────────────────────────────
3960
3961 #[cfg(test)]
3962 mod tests {
3963 use std::time::Duration;
3964
3965 use super::{
3966 RepoItem, TOOL_RESULT_PREVIEW_MAX, Tab, cap_output_preview, format_age, format_repo_detail,
3967 history_row, host_from_api_url, parse_repo_items, parse_repo_remote,
3968 parse_rich_text_segments, pretty_tool_arguments, repo_row, streaming_think_tail,
3969 strip_think_blocks, think_indicator, tool_entry_lines, tool_title, url_host,
3970 };
3971
3972 // ── tool_title ────────────────────────────────────────────────────────────
3973
3974 #[test]
3975 fn tool_title_run_command_shows_the_command() {
3976 assert_eq!(
3977 tool_title("run_command", r#"{"command":"cargo test"}"#),
3978 "run_command · cargo test"
3979 );
3980 }
3981
3982 #[test]
3983 fn tool_title_file_tools_show_the_path() {
3984 for name in [
3985 "read_file",
3986 "create_file",
3987 "edit_file",
3988 "multi_edit",
3989 "delete_file",
3990 ] {
3991 assert_eq!(
3992 tool_title(name, r#"{"path":"src/main.rs","old_text":"a"}"#),
3993 format!("{name} · src/main.rs")
3994 );
3995 }
3996 }
3997
3998 #[test]
3999 fn tool_title_pattern_tools_show_the_pattern() {
4000 assert_eq!(
4001 tool_title("search_files", r#"{"pattern":"fn main","path":"src"}"#),
4002 "search_files · fn main"
4003 );
4004 assert_eq!(
4005 tool_title("glob", r#"{"pattern":"**/*.rs"}"#),
4006 "glob · **/*.rs"
4007 );
4008 }
4009
4010 #[test]
4011 fn tool_title_mcp_tools_show_server_and_bare_tool_name() {
4012 assert_eq!(
4013 tool_title("mcp__sigit__list_issues", r#"{"repo":"getsigit/sigit"}"#),
4014 "sigit · list_issues"
4015 );
4016 }
4017
4018 #[test]
4019 fn tool_title_unknown_tool_is_the_name_alone() {
4020 assert_eq!(
4021 tool_title("write_todos", r#"{"todos":[{"text":"x"}]}"#),
4022 "write_todos"
4023 );
4024 }
4025
4026 #[test]
4027 fn tool_title_malformed_json_falls_back_to_the_name() {
4028 assert_eq!(tool_title("run_command", "{not json"), "run_command");
4029 assert_eq!(tool_title("edit_file", ""), "edit_file");
4030 }
4031
4032 #[test]
4033 fn tool_title_missing_or_non_string_arg_falls_back_to_the_name() {
4034 assert_eq!(tool_title("run_command", r#"{"other":"x"}"#), "run_command");
4035 assert_eq!(
4036 tool_title("run_command", r#"{"command":42}"#),
4037 "run_command"
4038 );
4039 }
4040
4041 #[test]
4042 fn tool_title_truncates_long_summaries_with_an_ellipsis() {
4043 let long = "a".repeat(100);
4044 let title = tool_title("run_command", &format!(r#"{{"command":"{long}"}}"#));
4045
4046 let summary = title.strip_prefix("run_command · ").expect("summary");
4047 assert_eq!(summary.chars().count(), 60);
4048 assert!(summary.ends_with('…'));
4049 }
4050
4051 #[test]
4052 fn tool_title_collapses_multiline_commands_to_one_line() {
4053 assert_eq!(
4054 tool_title("run_command", "{\"command\":\"echo a &&\\n echo b\"}"),
4055 "run_command · echo a && echo b"
4056 );
4057 }
4058
4059 // ── cap_output_preview ────────────────────────────────────────────────────
4060
4061 #[test]
4062 fn cap_output_preview_passes_short_output_through() {
4063 assert_eq!(cap_output_preview("hello\nworld"), "hello\nworld");
4064 }
4065
4066 #[test]
4067 fn cap_output_preview_keeps_the_tail_and_notes_the_truncation() {
4068 let output = format!("{}{}", "x".repeat(3000), "the-very-end");
4069 let preview = cap_output_preview(&output);
4070
4071 assert!(preview.starts_with("… (truncated — showing the last 2000 of 3012 chars)\n"));
4072 assert!(preview.ends_with("the-very-end"));
4073 let (_note, tail) = preview.split_once('\n').expect("note line");
4074 assert_eq!(tail.chars().count(), TOOL_RESULT_PREVIEW_MAX);
4075 }
4076
4077 // ── pretty_tool_arguments ─────────────────────────────────────────────────
4078
4079 #[test]
4080 fn pretty_tool_arguments_pretty_prints_valid_json() {
4081 assert_eq!(
4082 pretty_tool_arguments(r#"{"command":"ls"}"#),
4083 "{\n \"command\": \"ls\"\n}"
4084 );
4085 }
4086
4087 #[test]
4088 fn pretty_tool_arguments_shows_malformed_json_raw() {
4089 assert_eq!(pretty_tool_arguments("{oops"), "{oops");
4090 }
4091
4092 // ── tool_entry_lines ──────────────────────────────────────────────────────
4093
4094 #[test]
4095 fn tool_entry_lines_collapsed_is_a_single_title_line() {
4096 let lines = tool_entry_lines(
4097 "run_command · cargo test",
4098 "{\n \"command\": \"cargo test\"\n}",
4099 Some("ok"),
4100 false,
4101 );
4102 assert_eq!(lines, vec!["▸ 🔧 run_command · cargo test"]);
4103 }
4104
4105 #[test]
4106 fn tool_entry_lines_expanded_shows_detail_and_result() {
4107 let lines = tool_entry_lines(
4108 "run_command · cargo test",
4109 "{\n \"command\": \"cargo test\"\n}",
4110 Some("test ok\ndone"),
4111 true,
4112 );
4113 assert_eq!(
4114 lines,
4115 vec![
4116 "▾ 🔧 run_command · cargo test",
4117 " {",
4118 " \"command\": \"cargo test\"",
4119 " }",
4120 " result:",
4121 " test ok",
4122 " done",
4123 ]
4124 );
4125 }
4126
4127 #[test]
4128 fn tool_entry_lines_expanded_without_result_omits_the_result_block() {
4129 let lines = tool_entry_lines("glob · **/*.rs", "{}", None, true);
4130 assert_eq!(lines, vec!["▾ 🔧 glob · **/*.rs", " {}"]);
4131 }
4132
4133 // ── Tab / format_age / history_row ────────────────────────────────────────
4134
4135 #[test]
4136 fn tab_next_without_repo_cycles_three_tabs() {
4137 assert_eq!(Tab::Session.next(false), Tab::History);
4138 assert_eq!(Tab::History.next(false), Tab::Cloud);
4139 assert_eq!(Tab::Cloud.next(false), Tab::Session);
4140 // Three hops return to the start — exactly the base three-tab cycle.
4141 assert_eq!(
4142 Tab::Session.next(false).next(false).next(false),
4143 Tab::Session
4144 );
4145 }
4146
4147 #[test]
4148 fn tab_next_with_repo_cycles_four_tabs() {
4149 assert_eq!(Tab::Session.next(true), Tab::History);
4150 assert_eq!(Tab::History.next(true), Tab::Repo);
4151 assert_eq!(Tab::Repo.next(true), Tab::Cloud);
4152 assert_eq!(Tab::Cloud.next(true), Tab::Session);
4153 }
4154
4155 #[test]
4156 fn tab_index_matches_titles_order() {
4157 for repo_visible in [false, true] {
4158 let titles = Tab::titles(repo_visible);
4159 assert_eq!(titles[Tab::Session.index(repo_visible)], "Session");
4160 assert_eq!(titles[Tab::History.index(repo_visible)], "History");
4161 assert_eq!(titles[Tab::Cloud.index(repo_visible)], "Cloud");
4162 }
4163 assert_eq!(Tab::titles(true)[Tab::Repo.index(true)], "Repo");
4164 }
4165
4166 #[test]
4167 fn tab_titles_hide_repo_when_undetected() {
4168 assert_eq!(Tab::titles(false), vec!["Session", "History", "Cloud"]);
4169 assert_eq!(
4170 Tab::titles(true),
4171 vec!["Session", "History", "Repo", "Cloud"]
4172 );
4173 }
4174
4175 // ── Repo remote detection ─────────────────────────────────────────────────
4176
4177 #[test]
4178 fn parse_repo_remote_accepts_ssh_forms() {
4179 assert_eq!(
4180 parse_repo_remote("git@sigit.si:acme/demo.git", "sigit.si"),
4181 Some("acme/demo".to_string())
4182 );
4183 // bare (no .git) works too
4184 assert_eq!(
4185 parse_repo_remote("git@sigit.si:acme/demo", "sigit.si"),
4186 Some("acme/demo".to_string())
4187 );
4188 }
4189
4190 #[test]
4191 fn parse_repo_remote_accepts_https_forms() {
4192 assert_eq!(
4193 parse_repo_remote("https://sigit.si/acme/demo.git", "sigit.si"),
4194 Some("acme/demo".to_string())
4195 );
4196 assert_eq!(
4197 parse_repo_remote("https://sigit.si/acme/demo", "sigit.si"),
4198 Some("acme/demo".to_string())
4199 );
4200 // trailing slash and http + port (dev instances) normalize too
4201 assert_eq!(
4202 parse_repo_remote("https://sigit.si/acme/demo/", "sigit.si"),
4203 Some("acme/demo".to_string())
4204 );
4205 assert_eq!(
4206 parse_repo_remote("http://127.0.0.1:8088/acme/demo.git", "127.0.0.1"),
4207 Some("acme/demo".to_string())
4208 );
4209 }
4210
4211 #[test]
4212 fn parse_repo_remote_rejects_other_hosts() {
4213 assert_eq!(
4214 parse_repo_remote("git@github.com:acme/demo.git", "sigit.si"),
4215 None
4216 );
4217 assert_eq!(
4218 parse_repo_remote("https://github.com/acme/demo.git", "sigit.si"),
4219 None
4220 );
4221 }
4222
4223 #[test]
4224 fn parse_repo_remote_rejects_garbage_and_bad_paths() {
4225 assert_eq!(parse_repo_remote("", "sigit.si"), None);
4226 assert_eq!(parse_repo_remote("not a url at all", "sigit.si"), None);
4227 assert_eq!(parse_repo_remote("/local/path/repo.git", "sigit.si"), None);
4228 // wrong number of path segments
4229 assert_eq!(
4230 parse_repo_remote("https://sigit.si/demo.git", "sigit.si"),
4231 None
4232 );
4233 assert_eq!(
4234 parse_repo_remote("https://sigit.si/a/b/c.git", "sigit.si"),
4235 None
4236 );
4237 assert_eq!(
4238 parse_repo_remote("git@sigit.si:/demo.git", "sigit.si"),
4239 None
4240 );
4241 }
4242
4243 #[test]
4244 fn url_host_extracts_the_hostname() {
4245 assert_eq!(url_host("https://sigit.si"), Some("sigit.si".to_string()));
4246 assert_eq!(
4247 url_host("http://localhost:8088/api/v1"),
4248 Some("localhost".to_string())
4249 );
4250 assert_eq!(
4251 url_host("https://SiGit.SI/path"),
4252 Some("sigit.si".to_string())
4253 );
4254 assert_eq!(url_host("ftp://sigit.si"), None);
4255 assert_eq!(url_host(""), None);
4256 }
4257
4258 #[test]
4259 fn host_from_api_url_defaults_to_production() {
4260 assert_eq!(host_from_api_url(None), "sigit.si");
4261 assert_eq!(host_from_api_url(Some("nonsense")), "sigit.si");
4262 assert_eq!(
4263 host_from_api_url(Some("http://127.0.0.1:8088")),
4264 "127.0.0.1"
4265 );
4266 }
4267
4268 // ── Repo tab data parsing ─────────────────────────────────────────────────
4269
4270 #[test]
4271 fn parse_repo_items_reads_a_top_level_array() {
4272 let text = r#"[
4273 {"number": 12, "title": "Fix the flux capacitor", "state": "open"},
4274 {"number": 7, "title": "Old bug", "state": "closed"}
4275 ]"#;
4276 let items = parse_repo_items(text).expect("items");
4277 assert_eq!(items.len(), 2);
4278 assert_eq!(items[0].number, 12);
4279 assert_eq!(items[0].title, "Fix the flux capacitor");
4280 assert_eq!(items[0].state, "open");
4281 }
4282
4283 #[test]
4284 fn parse_repo_items_reads_wrapped_arrays_and_skips_malformed_entries() {
4285 let text = r#"{"issues": [
4286 {"number": 1, "title": "ok", "state": "open"},
4287 {"title": "no number"},
4288 {"number": 2, "title": "stateless"}
4289 ]}"#;
4290 let items = parse_repo_items(text).expect("items");
4291 assert_eq!(items.len(), 2);
4292 assert_eq!(items[1].state, "");
4293 }
4294
4295 #[test]
4296 fn parse_repo_items_passes_error_text_through() {
4297 // `mcp::call_tool` returns error strings, not JSON — the tab shows them.
4298 let err = parse_repo_items("Error: MCP is not initialized.").unwrap_err();
4299 assert_eq!(err, "Error: MCP is not initialized.");
4300 // JSON that isn't a list shape is surfaced verbatim too.
4301 assert!(parse_repo_items("\"unexpected\"").is_err());
4302 }
4303
4304 #[test]
4305 fn repo_row_formats_number_state_title() {
4306 let item = RepoItem {
4307 number: 12,
4308 title: "Fix the flux capacitor".to_string(),
4309 state: "open".to_string(),
4310 };
4311 assert_eq!(repo_row(&item), "#12 [open] Fix the flux capacitor");
4312 let stateless = RepoItem {
4313 number: 3,
4314 title: "t".to_string(),
4315 state: String::new(),
4316 };
4317 assert_eq!(repo_row(&stateless), "#3 t");
4318 }
4319
4320 #[test]
4321 fn format_repo_detail_pretty_prints_json_and_passes_text_through() {
4322 let pretty = format_repo_detail(r#"{"number":1,"title":"x"}"#);
4323 assert!(
4324 pretty.contains("\n"),
4325 "expected pretty-printed JSON: {pretty}"
4326 );
4327 assert_eq!(
4328 format_repo_detail("Error: server 'sigit' returned 401"),
4329 "Error: server 'sigit' returned 401"
4330 );
4331 }
4332
4333 #[test]
4334 fn format_age_picks_the_coarsest_sensible_unit() {
4335 assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
4336 assert_eq!(format_age(Duration::from_secs(59)), "59s ago");
4337 assert_eq!(format_age(Duration::from_secs(60)), "1m ago");
4338 assert_eq!(format_age(Duration::from_secs(3_599)), "59m ago");
4339 assert_eq!(format_age(Duration::from_secs(3_600)), "1h ago");
4340 assert_eq!(format_age(Duration::from_secs(86_399)), "23h ago");
4341 assert_eq!(format_age(Duration::from_secs(86_400)), "1d ago");
4342 assert_eq!(format_age(Duration::from_secs(3 * 86_400)), "3d ago");
4343 }
4344
4345 #[test]
4346 fn history_row_formats_id_age_and_count() {
4347 assert_eq!(
4348 history_row("tui", Some(Duration::from_secs(120)), 7),
4349 "tui · 2m ago · 7 message(s)"
4350 );
4351 assert_eq!(
4352 history_row("sess-1", None, 0),
4353 "sess-1 · age unknown · 0 message(s)"
4354 );
4355 }
4356
4357 #[test]
4358 fn strip_think_blocks_separates_thinking_and_visible_reply() {
4359 let raw = "<think>I should inspect the code first.</think>Here is the fix.";
4360 let (thinking, visible) = strip_think_blocks(raw);
4361
4362 assert_eq!(thinking, "I should inspect the code first.");
4363 assert_eq!(visible, "Here is the fix.");
4364 }
4365
4366 #[test]
4367 fn strip_think_blocks_handles_unclosed_think_block() {
4368 let raw = "<think>I am still reasoning about the bug";
4369 let (thinking, visible) = strip_think_blocks(raw);
4370
4371 assert_eq!(thinking, "I am still reasoning about the bug");
4372 assert_eq!(visible, "");
4373 }
4374
4375 #[test]
4376 fn strip_think_blocks_leaves_plain_text_untouched() {
4377 let raw = "No hidden reasoning here.";
4378 let (thinking, visible) = strip_think_blocks(raw);
4379
4380 assert_eq!(thinking, "");
4381 assert_eq!(visible, "No hidden reasoning here.");
4382 }
4383
4384 #[test]
4385 fn streaming_think_tail_shows_last_lines_of_unclosed_think_buffer() {
4386 // the common mid-stream shape: `<think>` opened, not yet closed
4387 let raw = "<think>First thought.\nSecond thought.\nThird thought.\nFourth thought.";
4388 let tail = streaming_think_tail(raw, 40, 3);
4389
4390 assert_eq!(
4391 tail,
4392 vec!["Second thought.", "Third thought.", "Fourth thought."]
4393 );
4394 }
4395
4396 #[test]
4397 fn streaming_think_tail_wraps_long_lines_at_width() {
4398 let raw = "<think>one two three four five six seven";
4399 let tail = streaming_think_tail(raw, 10, 3);
4400
4401 // wrapped at 10 cols: ["one two", "three four", "five six", "seven"];
4402 // the tail keeps only the last 3
4403 assert_eq!(tail, vec!["three four", "five six", "seven"]);
4404 }
4405
4406 #[test]
4407 fn streaming_think_tail_is_empty_once_visible_text_arrives() {
4408 let raw = "<think>hidden reasoning</think>The answer is 42.";
4409 assert!(streaming_think_tail(raw, 40, 3).is_empty());
4410 }
4411
4412 #[test]
4413 fn streaming_think_tail_is_empty_for_only_think_closed_but_no_visible_yet() {
4414 // closed think, visible not started: still show the reasoning tail
4415 let raw = "<think>done reasoning</think>";
4416 assert_eq!(streaming_think_tail(raw, 40, 3), vec!["done reasoning"]);
4417 }
4418
4419 #[test]
4420 fn streaming_think_tail_is_empty_for_empty_or_plain_buffers() {
4421 assert!(streaming_think_tail("", 40, 3).is_empty());
4422 assert!(streaming_think_tail("plain visible text", 40, 3).is_empty());
4423 assert!(streaming_think_tail("<think>", 40, 3).is_empty());
4424 }
4425
4426 #[test]
4427 fn streaming_think_tail_hard_splits_words_wider_than_the_wrap_column() {
4428 let raw = "<think>abcdefghijkl";
4429 assert_eq!(
4430 streaming_think_tail(raw, 5, 3),
4431 vec!["abcde", "fghij", "kl"]
4432 );
4433 }
4434
4435 #[test]
4436 fn think_indicator_counts_lines_and_pluralizes() {
4437 assert_eq!(
4438 think_indicator("only one line of reasoning"),
4439 "· thought for a bit (1 line) — /thinking to show"
4440 );
4441 assert_eq!(
4442 think_indicator("a\nb\nc"),
4443 "· thought for a bit (3 lines) — /thinking to show"
4444 );
4445 }
4446
4447 #[test]
4448 fn parse_rich_text_segments_marks_bold_runs() {
4449 let segments = parse_rich_text_segments(
4450 "The current weather is **72°F** with **Partly Cloudy** conditions.",
4451 );
4452
4453 assert_eq!(
4454 segments,
4455 vec![
4456 ("The current weather is ".to_string(), false),
4457 ("72°F".to_string(), true),
4458 (" with ".to_string(), false),
4459 ("Partly Cloudy".to_string(), true),
4460 (" conditions.".to_string(), false),
4461 ]
4462 );
4463 }
4464
4465 #[test]
4466 fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
4467 let segments = parse_rich_text_segments("Prefix **bold");
4468
4469 assert_eq!(
4470 segments,
4471 vec![("Prefix ".to_string(), false), ("bold".to_string(), true),]
4472 );
4473 }
4474 }