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