I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

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