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