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