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 allow_tool_calls = round < MAX_TOOL_ROUNDS;
3350 let sink = if !allow_tool_calls {
3351 streamed = true;
3352 Some(&delta_tx)
3353 } else {
3354 None
3355 };
3356
3357 match backend
3358 .send_tool_results(tool_results, &tools, allow_tool_calls, sink)
3359 .await
3360 {
3361 Ok(r) => result = r,
3362 Err(err) => {
3363 let _ = tx.send(InferenceUpdate::Error(err)).await;
3364 return;
3365 }
3366 }
3367 }
3368
3369 // Drop the sink so the forwarder finishes draining any buffered tokens
3370 // before we commit the reply.
3371 drop(delta_tx);
3372 let _ = forwarder.await;
3373
3374 if result.tool_calls.is_empty() {
3375 if result.text.is_empty() {
3376 log::warn!(
3377 "model returned empty reply — may have exhausted max_tokens on thinking"
3378 );
3379 let _ = tx
3380 .send(InferenceUpdate::Error(
3381 "(empty response — the model may have used all tokens on internal reasoning. \
3382 Try a shorter or simpler prompt.)"
3383 .to_string(),
3384 ))
3385 .await;
3386 } else if streamed {
3387 // tokens already went out as deltas; just commit the buffer
3388 let _ = tx.send(InferenceUpdate::StreamEnd).await;
3389 } else {
3390 let _ = tx.send(InferenceUpdate::Response(result.text)).await;
3391 }
3392 }
3393
3394 // Persist the completed turn so /resume (or a restart) can pick the
3395 // conversation back up.
3396 let snapshot = backend.history_snapshot().await;
3397 let _ = tx
3398 .send(InferenceUpdate::ContextUsage(
3399 crate::backend::estimate_tokens(&snapshot),
3400 ))
3401 .await;
3402 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot) {
3403 log::warn!("session save failed: {error}");
3404 }
3405
3406 log::info!("inference complete — {} tool round(s)", round);
3407 // tx drops here — event loop gets None from rx.recv()
3408 }
3409
3410 // ── Main loop ─────────────────────────────────────────────────────────────
3411
3412 /// entry point — blocks until the user quits.
3413 /// caller owns terminal init/restore. `load_rx` delivers the model-load result
3414 /// from a dedicated OS thread; we poll it non-blocking each tick.
3415 pub async fn run_with<B: ratatui::backend::Backend>(
3416 terminal: &mut ratatui::Terminal<B>,
3417 engine: Arc<ChatEngine>,
3418 backend: Arc<dyn InferenceBackend>,
3419 load_rx: std_mpsc::Receiver<Result<(), String>>,
3420 load_model_name: String,
3421 ) -> Result<()> {
3422 event_loop(terminal, engine, backend, load_rx, load_model_name).await
3423 }
3424
3425 async fn event_loop<B: ratatui::backend::Backend>(
3426 terminal: &mut ratatui::Terminal<B>,
3427 engine: Arc<ChatEngine>,
3428 backend: Arc<dyn InferenceBackend>,
3429 load_rx: std_mpsc::Receiver<Result<(), String>>,
3430 load_model_name: String,
3431 ) -> Result<()> {
3432 let mut app = App::new(load_model_name, backend);
3433 let mut event_stream = EventStream::new();
3434
3435 // 10 fps is plenty for spinners
3436 let mut ticker = interval(Duration::from_millis(100));
3437
3438 loop {
3439 // ── Poll the loader channel (non-blocking) ────────────────────────
3440 if app.is_loading {
3441 match load_rx.try_recv() {
3442 Ok(Ok(())) => app.finish_loading(),
3443 Ok(Err(e)) => app.set_load_error(e),
3444 Err(std_mpsc::TryRecvError::Empty) => {}
3445 Err(std_mpsc::TryRecvError::Disconnected) => {
3446 app.set_load_error("Model loader thread crashed.".to_string());
3447 }
3448 }
3449 }
3450
3451 // redraw every iteration
3452 terminal.draw(|frame| render(frame, &mut app))?;
3453
3454 if let Some(rx) = app.model_load_rx.as_mut() {
3455 match rx.try_recv() {
3456 Ok(ModelLoadUpdate::Loaded(model_name)) => {
3457 engine.clear_history().await;
3458 app.context_tokens_used = None;
3459 if let Some(tc) = app.pending_tool_calling.take() {
3460 app.tool_calling = tc;
3461 }
3462 app.switching_model = false;
3463 app.switching_model_id = None;
3464 app.download_progress = None;
3465 app.model_load_cancelled = false;
3466 app.model_load_rx = None;
3467 app.current_model_name = model_name.clone();
3468
3469 let save_result = app
3470 .model_picker_items
3471 .iter()
3472 .find(|item| item.display_name == model_name)
3473 .map(|item| crate::setup::SelectedModel {
3474 model_id: item.config.model_id.clone(),
3475 gguf_file: item
3476 .config
3477 .files
3478 .first()
3479 .cloned()
3480 .unwrap_or_else(String::new),
3481 })
3482 .filter(|selected| !selected.gguf_file.is_empty())
3483 .map(|selected| crate::setup::save_selected_model(&selected))
3484 .unwrap_or_else(|| {
3485 Err(format!(
3486 "could not determine a stable identifier for {}",
3487 model_name
3488 ))
3489 });
3490
3491 if let Err(error) = save_result {
3492 app.messages.push(ChatMessage::system(format!(
3493 "warning: switched to {} but could not save the selection: {}",
3494 model_name, error
3495 )));
3496 } else {
3497 app.messages
3498 .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
3499 }
3500 }
3501 Ok(ModelLoadUpdate::Error(error)) => {
3502 app.switching_model = false;
3503 app.switching_model_id = None;
3504 app.download_progress = None;
3505 app.model_load_cancelled = false;
3506 app.model_load_rx = None;
3507 app.messages
3508 .push(ChatMessage::system(format!("error loading model: {error}")));
3509 }
3510 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
3511 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
3512 let was_cancelled = app.model_load_cancelled;
3513 app.switching_model = false;
3514 app.switching_model_id = None;
3515 app.download_progress = None;
3516 app.model_load_cancelled = false;
3517 app.model_load_rx = None;
3518 if !was_cancelled {
3519 app.messages.push(ChatMessage::system(
3520 "error loading model: loader task disconnected".to_string(),
3521 ));
3522 }
3523 }
3524 }
3525 }
3526
3527 if app.quit {
3528 break;
3529 }
3530
3531 // multiplex terminal events, streaming tokens, inference updates,
3532 // and the thinking-spinner timer.
3533 tokio::select! {
3534 biased;
3535
3536 // ── Spinner tick (loading phase only) ─────────────────────────
3537 _ = ticker.tick(), if app.is_loading => {
3538 app.tick();
3539 }
3540
3541 // ── inference updates from background task ───────────────────
3542 update = async {
3543 match app.inference_rx.as_mut() {
3544 Some(rx) => rx.recv().await,
3545 None => pending().await,
3546 }
3547 } => {
3548 match update {
3549 Some(InferenceUpdate::ToolUse { name, arguments }) => {
3550 let title = super::tool_title(&name, &arguments);
3551 let detail = super::pretty_tool_arguments(&arguments);
3552 app.messages.push(ChatMessage::tool_call(title, detail));
3553 }
3554 Some(InferenceUpdate::ToolDone { output_preview }) => {
3555 // Attach to the most recent tool entry still
3556 // waiting for its result.
3557 if let Some(entry) = app
3558 .messages
3559 .iter_mut()
3560 .rev()
3561 .find(|m| m.role == Role::Tool && m.tool_result.is_none())
3562 {
3563 entry.tool_result = Some(output_preview);
3564 }
3565 }
3566 Some(InferenceUpdate::Delta(delta)) => {
3567 app.push_stream_delta(&delta);
3568 }
3569 Some(InferenceUpdate::StreamEnd) => {
3570 app.finalize_stream();
3571 }
3572 Some(InferenceUpdate::Response(text)) => {
3573 app.stop_thinking();
3574 app.messages.push(ChatMessage::assistant(text));
3575 }
3576 Some(InferenceUpdate::ContextUsage(tokens)) => {
3577 app.context_tokens_used = Some(tokens);
3578 }
3579 Some(InferenceUpdate::Error(msg)) => {
3580 app.finalize_stream();
3581 app.stop_thinking();
3582 app.messages.push(ChatMessage::system(format!("error: {msg}")));
3583 }
3584 Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
3585 // The y/a/n prompt lives on the Session tab; make
3586 // sure the user can see what they're answering.
3587 app.active_tab = Tab::Session;
3588 let call = if args.is_empty() {
3589 tool.clone()
3590 } else {
3591 format!("{tool}({args})")
3592 };
3593 app.messages.push(ChatMessage::system(format!(
3594 "⚠ permission — allow {call}? [y]es · [a]lways this session · [n]o"
3595 )));
3596 app.pending_approval = Some((tool, reply));
3597 }
3598 None => {
3599 // task finished, possibly with no text to show
3600 app.finalize_stream();
3601 app.stop_thinking();
3602 }
3603 }
3604 }
3605
3606 // ── Cloud tab status fetch resolving ─────────────────────────
3607 status = async {
3608 match app.cloud_rx.as_mut() {
3609 Some(rx) => rx.await,
3610 None => pending().await,
3611 }
3612 } => {
3613 app.cloud_rx = None;
3614 app.cloud_lines = Some(status.unwrap_or_else(|_| {
3615 vec!["error: the status fetch task died — press r to retry".to_string()]
3616 }));
3617 }
3618
3619 // ── Repo tab fetches resolving ────────────────────────────────
3620 update = async {
3621 match app.repo_rx.as_mut() {
3622 Some(rx) => rx.recv().await,
3623 None => pending().await,
3624 }
3625 } => {
3626 match update {
3627 Some(RepoUpdate::Issues(result)) => {
3628 app.repo_issues = match result {
3629 Ok(items) => RepoData::Ready(items),
3630 Err(error) => RepoData::Failed(error),
3631 };
3632 app.repo_issue_index = app
3633 .repo_issue_index
3634 .min(app.repo_issues.len().saturating_sub(1));
3635 }
3636 Some(RepoUpdate::PullRequests(result)) => {
3637 app.repo_prs = match result {
3638 Ok(items) => RepoData::Ready(items),
3639 Err(error) => RepoData::Failed(error),
3640 };
3641 app.repo_pr_index = app
3642 .repo_pr_index
3643 .min(app.repo_prs.len().saturating_sub(1));
3644 }
3645 Some(RepoUpdate::Detail { target, text }) => {
3646 // Apply only to the matching open view: a reply for
3647 // a detail the user has since closed or navigated
3648 // away from is dropped, never shown under the wrong
3649 // title.
3650 if let Some(detail) = app.repo_detail.as_mut()
3651 && detail.target == target
3652 {
3653 detail.text = Some(text);
3654 }
3655 }
3656 // All senders gone (can't happen while app.repo_tx is
3657 // held) — stop polling this channel.
3658 None => app.repo_rx = None,
3659 }
3660 }
3661
3662 // ── thinking / switching spinner tick (100ms) ────────────────
3663 _ = async {
3664 if app.thinking || app.switching_model {
3665 tokio::time::sleep(Duration::from_millis(100)).await
3666 } else {
3667 pending().await
3668 }
3669 } => {
3670 app.tick_thinking();
3671 // keep the progress display fresh
3672 if app.switching_model {
3673 app.poll_download_progress();
3674 }
3675 }
3676
3677 // ── Terminal events ───────────────────────────────────────────
3678 maybe_event = event_stream.next() => {
3679 let Some(Ok(event)) = maybe_event else {
3680 break;
3681 };
3682
3683 if let Event::Key(key) = event {
3684 // loading phase — only quit keys work
3685 if app.is_loading {
3686 if key.kind == KeyEventKind::Press {
3687 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3688 if ctrl
3689 && (key.code == KeyCode::Char('c')
3690 || key.code == KeyCode::Char('d'))
3691 {
3692 app.quit = true;
3693 }
3694 }
3695 continue;
3696 }
3697
3698 // pending tool approval — y/a/n answer the prompt; the
3699 // inference task is paused on the reply channel. Checked
3700 // before the busy gate because the app *is* busy here.
3701 if app.pending_approval.is_some() {
3702 if key.kind == KeyEventKind::Press {
3703 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3704 let choice = if ctrl
3705 && (key.code == KeyCode::Char('c')
3706 || key.code == KeyCode::Char('d'))
3707 {
3708 // cancel the whole turn: denying is implicit
3709 // in dropping the reply channel
3710 app.pending_approval = None;
3711 app.stop_thinking();
3712 app.messages.push(ChatMessage::system("(cancelled)"));
3713 continue;
3714 } else {
3715 match key.code {
3716 KeyCode::Char('y') | KeyCode::Char('Y') => {
3717 Some(ApprovalChoice::Once)
3718 }
3719 KeyCode::Char('a') | KeyCode::Char('A') => {
3720 Some(ApprovalChoice::Session)
3721 }
3722 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
3723 Some(ApprovalChoice::Deny)
3724 }
3725 _ => None,
3726 }
3727 };
3728 if let Some(choice) = choice
3729 && let Some((tool, reply)) = app.pending_approval.take()
3730 {
3731 let verdict = match &choice {
3732 ApprovalChoice::Once => "allowed once",
3733 ApprovalChoice::Session => "allowed for this session",
3734 ApprovalChoice::Deny => "denied",
3735 };
3736 app.messages.push(ChatMessage::system(format!(
3737 "{tool}: {verdict}"
3738 )));
3739 let _ = reply.send(choice);
3740 }
3741 }
3742 continue;
3743 }
3744
3745 // ── Tab-bar navigation ────────────────────────────────
3746 // Handled before the busy gate so the user can look at
3747 // History/Cloud while inference runs (updates keep
3748 // landing in the Session tab's message list). The Tab
3749 // key only cycles when the input buffer is empty, so
3750 // pasted text containing tabs can't fight it; on
3751 // non-Session tabs the input is hidden, so it always
3752 // cycles there.
3753 if key.kind == KeyEventKind::Press && !app.show_model_picker {
3754 if key.code == KeyCode::Tab
3755 && (app.active_tab != Tab::Session || app.input.is_empty())
3756 {
3757 let next = app.active_tab.next(app.repo_visible());
3758 switch_tab(&mut app, next, &engine);
3759 continue;
3760 }
3761 if app.active_tab != Tab::Session && key.code == KeyCode::Esc {
3762 // On the Repo tab, Esc first closes an open
3763 // detail view (back to the list).
3764 if app.active_tab == Tab::Repo && app.repo_detail.is_some() {
3765 app.repo_detail = None;
3766 } else {
3767 app.active_tab = Tab::Session;
3768 }
3769 continue;
3770 }
3771 }
3772
3773 // busy — only cancel keys work
3774 if app.is_busy() {
3775 if key.kind == KeyEventKind::Press {
3776 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3777 if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
3778 if app.is_streaming() {
3779 app.finalize_stream();
3780 app.messages.push(ChatMessage::system("(cancelled)"));
3781 }
3782 if app.thinking {
3783 // dropping rx kills the background task
3784 app.stop_thinking();
3785 app.messages.push(ChatMessage::system("(cancelled)"));
3786 }
3787 if app.switching_model {
3788 // flag before drop so Disconnected handler stays quiet
3789 app.model_load_cancelled = true;
3790 app.switching_model = false;
3791 app.switching_model_id = None;
3792 app.download_progress = None;
3793 app.model_load_rx = None;
3794 app.messages
3795 .push(ChatMessage::system("(download cancelled — model switch aborted)"));
3796 }
3797 }
3798 }
3799 continue;
3800 }
3801
3802 // History / Cloud tabs have their own key handling; the
3803 // chat input is inactive there.
3804 if app.active_tab != Tab::Session {
3805 if key.kind == KeyEventKind::Press {
3806 handle_tab_key(&mut app, key, &engine).await;
3807 }
3808 continue;
3809 }
3810
3811 if let Some(text) = handle_key(&mut app, key) {
3812 // `/init` runs a real agent turn: the transcript
3813 // shows the command the user typed, but the model
3814 // receives the canned AGENTS.md-generation prompt.
3815 // User-defined commands (`.sigit/commands/*.md`)
3816 // get the same treatment once `parse_slash`
3817 // reports the word unrecognized.
3818 let mut inference_text = text.clone();
3819 match parse_slash(&text) {
3820 Some(SlashCommand::Init) => {
3821 inference_text =
3822 crate::instructions::INIT_PROMPT.to_string();
3823 }
3824 Some(SlashCommand::Unknown(command, argument)) => {
3825 match crate::commands::resolve_command(&command) {
3826 Some(custom) => {
3827 inference_text = crate::commands::render(
3828 &custom.body,
3829 argument.as_deref(),
3830 );
3831 }
3832 None => {
3833 exec_slash(
3834 &mut app,
3835 SlashCommand::Unknown(command, argument),
3836 Arc::clone(&engine),
3837 terminal,
3838 )
3839 .await;
3840 continue;
3841 }
3842 }
3843 }
3844 Some(cmd) => {
3845 exec_slash(&mut app, cmd, Arc::clone(&engine), terminal)
3846 .await;
3847 continue;
3848 }
3849 None => {}
3850 }
3851
3852 // On-device inference needs a model in memory, and we
3853 // never load one implicitly: the user loads it with
3854 // /load (or /models). Refuse rather than erroring out
3855 // deep in the backend.
3856 if !app.backend.is_remote()
3857 && engine.info().await.status == onde::inference::EngineStatus::Unloaded
3858 {
3859 app.messages.push(ChatMessage::user(&text));
3860 app.messages.push(ChatMessage::system(
3861 "No on-device model is loaded. Run /load to load the selected \
3862 model, or /models to choose one.",
3863 ));
3864 continue;
3865 }
3866
3867 // ── spawn inference ──────────────────────────────
3868 app.messages.push(ChatMessage::user(&text));
3869 app.start_thinking();
3870
3871 let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
3872 app.inference_rx = Some(rx);
3873
3874 let backend_handle = Arc::clone(&app.backend);
3875 let user_text = inference_text;
3876 let tools_enabled = app.tool_calling;
3877 tokio::spawn(async move {
3878 run_inference_task(backend_handle, user_text, tx, tools_enabled).await;
3879 });
3880 }
3881 }
3882 }
3883 }
3884 }
3885
3886 Ok(())
3887 }
3888
3889 // ── Download progress helpers (TUI) ──────────────────────────────────────
3890
3891 /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks)
3892 fn dir_size_recursive(path: &std::path::Path) -> u64 {
3893 let mut total: u64 = 0;
3894 let Ok(entries) = std::fs::read_dir(path) else {
3895 return 0;
3896 };
3897 for entry in entries.flatten() {
3898 let entry_path = entry.path();
3899 if entry_path.is_dir() {
3900 total += dir_size_recursive(&entry_path);
3901 } else if let Ok(meta) = entry_path.metadata() {
3902 total += meta.len();
3903 }
3904 }
3905 total
3906 }
3907
3908 fn format_size_human(bytes: u64) -> String {
3909 const GB: u64 = 1_073_741_824;
3910 const MB: u64 = 1_048_576;
3911 const KB: u64 = 1_024;
3912 if bytes >= GB {
3913 format!("{:.2} GB", bytes as f64 / GB as f64)
3914 } else if bytes >= MB {
3915 format!("{:.1} MB", bytes as f64 / MB as f64)
3916 } else if bytes >= KB {
3917 format!("{:.0} KB", bytes as f64 / KB as f64)
3918 } else {
3919 format!("{bytes} B")
3920 }
3921 }
3922 #[cfg(test)]
3923 mod tui_tests {
3924 use super::{compact_tokens, context_gauge_color, context_gauge_label};
3925 use ratatui::style::Color;
3926
3927 #[test]
3928 fn context_gauge_reports_share_of_the_window() {
3929 assert_eq!(context_gauge_label(168_000, 200_000), "84% · 168K/200K ctx");
3930 assert_eq!(context_gauge_label(0, 24_000), "0% · 0/24K ctx");
3931 // Over-full history (pre-compaction) clamps rather than reading 130%.
3932 assert_eq!(context_gauge_label(31_000, 24_000), "100% · 31K/24K ctx");
3933 }
3934
3935 #[test]
3936 fn context_gauge_handles_an_unknown_window() {
3937 assert_eq!(context_gauge_label(1_000, 0), "ctx —");
3938 assert_eq!(context_gauge_color(1_000, 0), Color::DarkGray);
3939 }
3940
3941 #[test]
3942 fn context_gauge_warms_up_as_the_window_fills() {
3943 assert_eq!(context_gauge_color(1_000, 24_000), Color::DarkGray);
3944 assert_eq!(context_gauge_color(18_000, 24_000), Color::Yellow);
3945 assert_eq!(context_gauge_color(23_000, 24_000), Color::Red);
3946 }
3947
3948 #[test]
3949 fn compact_tokens_abbreviates_thousands_only() {
3950 assert_eq!(compact_tokens(999), "999");
3951 assert_eq!(compact_tokens(1_500), "1K");
3952 }
3953 }
3954 } // end #[cfg(unix)] mod tui
3955
3956 // re-export so callers write `chat::run_with(...)` on all platforms
3957 #[cfg(unix)]
3958 pub use tui::run_with;
3959
3960 // ── Tests (platform-agnostic) ─────────────────────────────────────────────────
3961
3962 #[cfg(test)]
3963 mod tests {
3964 use std::time::Duration;
3965
3966 use super::{
3967 RepoItem, TOOL_RESULT_PREVIEW_MAX, Tab, cap_output_preview, format_age, format_repo_detail,
3968 history_row, host_from_api_url, parse_repo_items, parse_repo_remote,
3969 parse_rich_text_segments, pretty_tool_arguments, repo_row, streaming_think_tail,
3970 strip_think_blocks, think_indicator, tool_entry_lines, tool_title, url_host,
3971 };
3972
3973 // ── tool_title ────────────────────────────────────────────────────────────
3974
3975 #[test]
3976 fn tool_title_run_command_shows_the_command() {
3977 assert_eq!(
3978 tool_title("run_command", r#"{"command":"cargo test"}"#),
3979 "run_command · cargo test"
3980 );
3981 }
3982
3983 #[test]
3984 fn tool_title_file_tools_show_the_path() {
3985 for name in [
3986 "read_file",
3987 "create_file",
3988 "edit_file",
3989 "multi_edit",
3990 "delete_file",
3991 ] {
3992 assert_eq!(
3993 tool_title(name, r#"{"path":"src/main.rs","old_text":"a"}"#),
3994 format!("{name} · src/main.rs")
3995 );
3996 }
3997 }
3998
3999 #[test]
4000 fn tool_title_pattern_tools_show_the_pattern() {
4001 assert_eq!(
4002 tool_title("search_files", r#"{"pattern":"fn main","path":"src"}"#),
4003 "search_files · fn main"
4004 );
4005 assert_eq!(
4006 tool_title("glob", r#"{"pattern":"**/*.rs"}"#),
4007 "glob · **/*.rs"
4008 );
4009 }
4010
4011 #[test]
4012 fn tool_title_mcp_tools_show_server_and_bare_tool_name() {
4013 assert_eq!(
4014 tool_title("mcp__sigit__list_issues", r#"{"repo":"getsigit/sigit"}"#),
4015 "sigit · list_issues"
4016 );
4017 }
4018
4019 #[test]
4020 fn tool_title_unknown_tool_is_the_name_alone() {
4021 assert_eq!(
4022 tool_title("write_todos", r#"{"todos":[{"text":"x"}]}"#),
4023 "write_todos"
4024 );
4025 }
4026
4027 #[test]
4028 fn tool_title_malformed_json_falls_back_to_the_name() {
4029 assert_eq!(tool_title("run_command", "{not json"), "run_command");
4030 assert_eq!(tool_title("edit_file", ""), "edit_file");
4031 }
4032
4033 #[test]
4034 fn tool_title_missing_or_non_string_arg_falls_back_to_the_name() {
4035 assert_eq!(tool_title("run_command", r#"{"other":"x"}"#), "run_command");
4036 assert_eq!(
4037 tool_title("run_command", r#"{"command":42}"#),
4038 "run_command"
4039 );
4040 }
4041
4042 #[test]
4043 fn tool_title_truncates_long_summaries_with_an_ellipsis() {
4044 let long = "a".repeat(100);
4045 let title = tool_title("run_command", &format!(r#"{{"command":"{long}"}}"#));
4046
4047 let summary = title.strip_prefix("run_command · ").expect("summary");
4048 assert_eq!(summary.chars().count(), 60);
4049 assert!(summary.ends_with('…'));
4050 }
4051
4052 #[test]
4053 fn tool_title_collapses_multiline_commands_to_one_line() {
4054 assert_eq!(
4055 tool_title("run_command", "{\"command\":\"echo a &&\\n echo b\"}"),
4056 "run_command · echo a && echo b"
4057 );
4058 }
4059
4060 // ── cap_output_preview ────────────────────────────────────────────────────
4061
4062 #[test]
4063 fn cap_output_preview_passes_short_output_through() {
4064 assert_eq!(cap_output_preview("hello\nworld"), "hello\nworld");
4065 }
4066
4067 #[test]
4068 fn cap_output_preview_keeps_the_tail_and_notes_the_truncation() {
4069 let output = format!("{}{}", "x".repeat(3000), "the-very-end");
4070 let preview = cap_output_preview(&output);
4071
4072 assert!(preview.starts_with("… (truncated — showing the last 2000 of 3012 chars)\n"));
4073 assert!(preview.ends_with("the-very-end"));
4074 let (_note, tail) = preview.split_once('\n').expect("note line");
4075 assert_eq!(tail.chars().count(), TOOL_RESULT_PREVIEW_MAX);
4076 }
4077
4078 // ── pretty_tool_arguments ─────────────────────────────────────────────────
4079
4080 #[test]
4081 fn pretty_tool_arguments_pretty_prints_valid_json() {
4082 assert_eq!(
4083 pretty_tool_arguments(r#"{"command":"ls"}"#),
4084 "{\n \"command\": \"ls\"\n}"
4085 );
4086 }
4087
4088 #[test]
4089 fn pretty_tool_arguments_shows_malformed_json_raw() {
4090 assert_eq!(pretty_tool_arguments("{oops"), "{oops");
4091 }
4092
4093 // ── tool_entry_lines ──────────────────────────────────────────────────────
4094
4095 #[test]
4096 fn tool_entry_lines_collapsed_is_a_single_title_line() {
4097 let lines = tool_entry_lines(
4098 "run_command · cargo test",
4099 "{\n \"command\": \"cargo test\"\n}",
4100 Some("ok"),
4101 false,
4102 );
4103 assert_eq!(lines, vec!["▸ 🔧 run_command · cargo test"]);
4104 }
4105
4106 #[test]
4107 fn tool_entry_lines_expanded_shows_detail_and_result() {
4108 let lines = tool_entry_lines(
4109 "run_command · cargo test",
4110 "{\n \"command\": \"cargo test\"\n}",
4111 Some("test ok\ndone"),
4112 true,
4113 );
4114 assert_eq!(
4115 lines,
4116 vec![
4117 "▾ 🔧 run_command · cargo test",
4118 " {",
4119 " \"command\": \"cargo test\"",
4120 " }",
4121 " result:",
4122 " test ok",
4123 " done",
4124 ]
4125 );
4126 }
4127
4128 #[test]
4129 fn tool_entry_lines_expanded_without_result_omits_the_result_block() {
4130 let lines = tool_entry_lines("glob · **/*.rs", "{}", None, true);
4131 assert_eq!(lines, vec!["▾ 🔧 glob · **/*.rs", " {}"]);
4132 }
4133
4134 // ── Tab / format_age / history_row ────────────────────────────────────────
4135
4136 #[test]
4137 fn tab_next_without_repo_cycles_three_tabs() {
4138 assert_eq!(Tab::Session.next(false), Tab::History);
4139 assert_eq!(Tab::History.next(false), Tab::Cloud);
4140 assert_eq!(Tab::Cloud.next(false), Tab::Session);
4141 // Three hops return to the start — exactly the base three-tab cycle.
4142 assert_eq!(
4143 Tab::Session.next(false).next(false).next(false),
4144 Tab::Session
4145 );
4146 }
4147
4148 #[test]
4149 fn tab_next_with_repo_cycles_four_tabs() {
4150 assert_eq!(Tab::Session.next(true), Tab::History);
4151 assert_eq!(Tab::History.next(true), Tab::Repo);
4152 assert_eq!(Tab::Repo.next(true), Tab::Cloud);
4153 assert_eq!(Tab::Cloud.next(true), Tab::Session);
4154 }
4155
4156 #[test]
4157 fn tab_index_matches_titles_order() {
4158 for repo_visible in [false, true] {
4159 let titles = Tab::titles(repo_visible);
4160 assert_eq!(titles[Tab::Session.index(repo_visible)], "Session");
4161 assert_eq!(titles[Tab::History.index(repo_visible)], "History");
4162 assert_eq!(titles[Tab::Cloud.index(repo_visible)], "Cloud");
4163 }
4164 assert_eq!(Tab::titles(true)[Tab::Repo.index(true)], "Repo");
4165 }
4166
4167 #[test]
4168 fn tab_titles_hide_repo_when_undetected() {
4169 assert_eq!(Tab::titles(false), vec!["Session", "History", "Cloud"]);
4170 assert_eq!(
4171 Tab::titles(true),
4172 vec!["Session", "History", "Repo", "Cloud"]
4173 );
4174 }
4175
4176 // ── Repo remote detection ─────────────────────────────────────────────────
4177
4178 #[test]
4179 fn parse_repo_remote_accepts_ssh_forms() {
4180 assert_eq!(
4181 parse_repo_remote("git@sigit.si:acme/demo.git", "sigit.si"),
4182 Some("acme/demo".to_string())
4183 );
4184 // bare (no .git) works too
4185 assert_eq!(
4186 parse_repo_remote("git@sigit.si:acme/demo", "sigit.si"),
4187 Some("acme/demo".to_string())
4188 );
4189 }
4190
4191 #[test]
4192 fn parse_repo_remote_accepts_https_forms() {
4193 assert_eq!(
4194 parse_repo_remote("https://sigit.si/acme/demo.git", "sigit.si"),
4195 Some("acme/demo".to_string())
4196 );
4197 assert_eq!(
4198 parse_repo_remote("https://sigit.si/acme/demo", "sigit.si"),
4199 Some("acme/demo".to_string())
4200 );
4201 // trailing slash and http + port (dev instances) normalize too
4202 assert_eq!(
4203 parse_repo_remote("https://sigit.si/acme/demo/", "sigit.si"),
4204 Some("acme/demo".to_string())
4205 );
4206 assert_eq!(
4207 parse_repo_remote("http://127.0.0.1:8088/acme/demo.git", "127.0.0.1"),
4208 Some("acme/demo".to_string())
4209 );
4210 }
4211
4212 #[test]
4213 fn parse_repo_remote_rejects_other_hosts() {
4214 assert_eq!(
4215 parse_repo_remote("git@github.com:acme/demo.git", "sigit.si"),
4216 None
4217 );
4218 assert_eq!(
4219 parse_repo_remote("https://github.com/acme/demo.git", "sigit.si"),
4220 None
4221 );
4222 }
4223
4224 #[test]
4225 fn parse_repo_remote_rejects_garbage_and_bad_paths() {
4226 assert_eq!(parse_repo_remote("", "sigit.si"), None);
4227 assert_eq!(parse_repo_remote("not a url at all", "sigit.si"), None);
4228 assert_eq!(parse_repo_remote("/local/path/repo.git", "sigit.si"), None);
4229 // wrong number of path segments
4230 assert_eq!(
4231 parse_repo_remote("https://sigit.si/demo.git", "sigit.si"),
4232 None
4233 );
4234 assert_eq!(
4235 parse_repo_remote("https://sigit.si/a/b/c.git", "sigit.si"),
4236 None
4237 );
4238 assert_eq!(
4239 parse_repo_remote("git@sigit.si:/demo.git", "sigit.si"),
4240 None
4241 );
4242 }
4243
4244 #[test]
4245 fn url_host_extracts_the_hostname() {
4246 assert_eq!(url_host("https://sigit.si"), Some("sigit.si".to_string()));
4247 assert_eq!(
4248 url_host("http://localhost:8088/api/v1"),
4249 Some("localhost".to_string())
4250 );
4251 assert_eq!(
4252 url_host("https://SiGit.SI/path"),
4253 Some("sigit.si".to_string())
4254 );
4255 assert_eq!(url_host("ftp://sigit.si"), None);
4256 assert_eq!(url_host(""), None);
4257 }
4258
4259 #[test]
4260 fn host_from_api_url_defaults_to_production() {
4261 assert_eq!(host_from_api_url(None), "sigit.si");
4262 assert_eq!(host_from_api_url(Some("nonsense")), "sigit.si");
4263 assert_eq!(
4264 host_from_api_url(Some("http://127.0.0.1:8088")),
4265 "127.0.0.1"
4266 );
4267 }
4268
4269 // ── Repo tab data parsing ─────────────────────────────────────────────────
4270
4271 #[test]
4272 fn parse_repo_items_reads_a_top_level_array() {
4273 let text = r#"[
4274 {"number": 12, "title": "Fix the flux capacitor", "state": "open"},
4275 {"number": 7, "title": "Old bug", "state": "closed"}
4276 ]"#;
4277 let items = parse_repo_items(text).expect("items");
4278 assert_eq!(items.len(), 2);
4279 assert_eq!(items[0].number, 12);
4280 assert_eq!(items[0].title, "Fix the flux capacitor");
4281 assert_eq!(items[0].state, "open");
4282 }
4283
4284 #[test]
4285 fn parse_repo_items_reads_wrapped_arrays_and_skips_malformed_entries() {
4286 let text = r#"{"issues": [
4287 {"number": 1, "title": "ok", "state": "open"},
4288 {"title": "no number"},
4289 {"number": 2, "title": "stateless"}
4290 ]}"#;
4291 let items = parse_repo_items(text).expect("items");
4292 assert_eq!(items.len(), 2);
4293 assert_eq!(items[1].state, "");
4294 }
4295
4296 #[test]
4297 fn parse_repo_items_passes_error_text_through() {
4298 // `mcp::call_tool` returns error strings, not JSON — the tab shows them.
4299 let err = parse_repo_items("Error: MCP is not initialized.").unwrap_err();
4300 assert_eq!(err, "Error: MCP is not initialized.");
4301 // JSON that isn't a list shape is surfaced verbatim too.
4302 assert!(parse_repo_items("\"unexpected\"").is_err());
4303 }
4304
4305 #[test]
4306 fn repo_row_formats_number_state_title() {
4307 let item = RepoItem {
4308 number: 12,
4309 title: "Fix the flux capacitor".to_string(),
4310 state: "open".to_string(),
4311 };
4312 assert_eq!(repo_row(&item), "#12 [open] Fix the flux capacitor");
4313 let stateless = RepoItem {
4314 number: 3,
4315 title: "t".to_string(),
4316 state: String::new(),
4317 };
4318 assert_eq!(repo_row(&stateless), "#3 t");
4319 }
4320
4321 #[test]
4322 fn format_repo_detail_pretty_prints_json_and_passes_text_through() {
4323 let pretty = format_repo_detail(r#"{"number":1,"title":"x"}"#);
4324 assert!(
4325 pretty.contains("\n"),
4326 "expected pretty-printed JSON: {pretty}"
4327 );
4328 assert_eq!(
4329 format_repo_detail("Error: server 'sigit' returned 401"),
4330 "Error: server 'sigit' returned 401"
4331 );
4332 }
4333
4334 #[test]
4335 fn format_age_picks_the_coarsest_sensible_unit() {
4336 assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
4337 assert_eq!(format_age(Duration::from_secs(59)), "59s ago");
4338 assert_eq!(format_age(Duration::from_secs(60)), "1m ago");
4339 assert_eq!(format_age(Duration::from_secs(3_599)), "59m ago");
4340 assert_eq!(format_age(Duration::from_secs(3_600)), "1h ago");
4341 assert_eq!(format_age(Duration::from_secs(86_399)), "23h ago");
4342 assert_eq!(format_age(Duration::from_secs(86_400)), "1d ago");
4343 assert_eq!(format_age(Duration::from_secs(3 * 86_400)), "3d ago");
4344 }
4345
4346 #[test]
4347 fn history_row_formats_id_age_and_count() {
4348 assert_eq!(
4349 history_row("tui", Some(Duration::from_secs(120)), 7),
4350 "tui · 2m ago · 7 message(s)"
4351 );
4352 assert_eq!(
4353 history_row("sess-1", None, 0),
4354 "sess-1 · age unknown · 0 message(s)"
4355 );
4356 }
4357
4358 #[test]
4359 fn strip_think_blocks_separates_thinking_and_visible_reply() {
4360 let raw = "<think>I should inspect the code first.</think>Here is the fix.";
4361 let (thinking, visible) = strip_think_blocks(raw);
4362
4363 assert_eq!(thinking, "I should inspect the code first.");
4364 assert_eq!(visible, "Here is the fix.");
4365 }
4366
4367 #[test]
4368 fn strip_think_blocks_handles_unclosed_think_block() {
4369 let raw = "<think>I am still reasoning about the bug";
4370 let (thinking, visible) = strip_think_blocks(raw);
4371
4372 assert_eq!(thinking, "I am still reasoning about the bug");
4373 assert_eq!(visible, "");
4374 }
4375
4376 #[test]
4377 fn strip_think_blocks_leaves_plain_text_untouched() {
4378 let raw = "No hidden reasoning here.";
4379 let (thinking, visible) = strip_think_blocks(raw);
4380
4381 assert_eq!(thinking, "");
4382 assert_eq!(visible, "No hidden reasoning here.");
4383 }
4384
4385 #[test]
4386 fn streaming_think_tail_shows_last_lines_of_unclosed_think_buffer() {
4387 // the common mid-stream shape: `<think>` opened, not yet closed
4388 let raw = "<think>First thought.\nSecond thought.\nThird thought.\nFourth thought.";
4389 let tail = streaming_think_tail(raw, 40, 3);
4390
4391 assert_eq!(
4392 tail,
4393 vec!["Second thought.", "Third thought.", "Fourth thought."]
4394 );
4395 }
4396
4397 #[test]
4398 fn streaming_think_tail_wraps_long_lines_at_width() {
4399 let raw = "<think>one two three four five six seven";
4400 let tail = streaming_think_tail(raw, 10, 3);
4401
4402 // wrapped at 10 cols: ["one two", "three four", "five six", "seven"];
4403 // the tail keeps only the last 3
4404 assert_eq!(tail, vec!["three four", "five six", "seven"]);
4405 }
4406
4407 #[test]
4408 fn streaming_think_tail_is_empty_once_visible_text_arrives() {
4409 let raw = "<think>hidden reasoning</think>The answer is 42.";
4410 assert!(streaming_think_tail(raw, 40, 3).is_empty());
4411 }
4412
4413 #[test]
4414 fn streaming_think_tail_is_empty_for_only_think_closed_but_no_visible_yet() {
4415 // closed think, visible not started: still show the reasoning tail
4416 let raw = "<think>done reasoning</think>";
4417 assert_eq!(streaming_think_tail(raw, 40, 3), vec!["done reasoning"]);
4418 }
4419
4420 #[test]
4421 fn streaming_think_tail_is_empty_for_empty_or_plain_buffers() {
4422 assert!(streaming_think_tail("", 40, 3).is_empty());
4423 assert!(streaming_think_tail("plain visible text", 40, 3).is_empty());
4424 assert!(streaming_think_tail("<think>", 40, 3).is_empty());
4425 }
4426
4427 #[test]
4428 fn streaming_think_tail_hard_splits_words_wider_than_the_wrap_column() {
4429 let raw = "<think>abcdefghijkl";
4430 assert_eq!(
4431 streaming_think_tail(raw, 5, 3),
4432 vec!["abcde", "fghij", "kl"]
4433 );
4434 }
4435
4436 #[test]
4437 fn think_indicator_counts_lines_and_pluralizes() {
4438 assert_eq!(
4439 think_indicator("only one line of reasoning"),
4440 "· thought for a bit (1 line) — /thinking to show"
4441 );
4442 assert_eq!(
4443 think_indicator("a\nb\nc"),
4444 "· thought for a bit (3 lines) — /thinking to show"
4445 );
4446 }
4447
4448 #[test]
4449 fn parse_rich_text_segments_marks_bold_runs() {
4450 let segments = parse_rich_text_segments(
4451 "The current weather is **72°F** with **Partly Cloudy** conditions.",
4452 );
4453
4454 assert_eq!(
4455 segments,
4456 vec![
4457 ("The current weather is ".to_string(), false),
4458 ("72°F".to_string(), true),
4459 (" with ".to_string(), false),
4460 ("Partly Cloudy".to_string(), true),
4461 (" conditions.".to_string(), false),
4462 ]
4463 );
4464 }
4465
4466 #[test]
4467 fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
4468 let segments = parse_rich_text_segments("Prefix **bold");
4469
4470 assert_eq!(
4471 segments,
4472 vec![("Prefix ".to_string(), false), ("bold".to_string(), true),]
4473 );
4474 }
4475 }