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