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