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