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 /mcp — list MCP servers and their tools\n\
2911 /load — load the selected on-device model\n\
2912 /login — sign in via your browser\n\
2913 /login E P — sign in with an email and password\n\
2914 /logout — sign out\n\
2915 /whoami — show the signed-in account\n\
2916 /plan [on|off] — plan mode: research only, no edits or commands\n\
2917 /permissions — show the tool permission policy\n\
2918 /thinking [on|off] — expand or collapse model reasoning\n\
2919 /tools [on|off]— expand or collapse tool-call details\n\
2920 /compact — summarize and shrink conversation history\n\
2921 /resume — restore the saved session from disk\n\
2922 /init — generate or improve the repo's AGENTS.md\n\
2923 /clear — wipe conversation history\n\
2924 /status — show engine status\n\
2925 /exit — quit chat",
2926 ));
2927 }
2928 SlashCommand::Clear => {
2929 let cleared = engine.clear_history().await;
2930 app.messages.clear();
2931 crate::permissions::reset_session(crate::permissions::TUI_SESSION);
2932 // The saved session must not resurrect what the user just wiped.
2933 crate::session_store::delete(TUI_STORE_SESSION);
2934 // The plan belongs to the conversation, so the wipe drops it too.
2935 crate::tools::clear_todos(crate::permissions::TUI_SESSION);
2936 app.context_tokens_used = None;
2937 app.messages.push(ChatMessage::system(format!(
2938 "Cleared {cleared} turn(s). History is empty.",
2939 )));
2940 }
2941 SlashCommand::Compact => {
2942 let before = crate::backend::estimate_tokens(&app.backend.history_snapshot().await);
2943 match app
2944 .backend
2945 .compact_history(crate::backend::COMPACT_KEEP_LAST)
2946 .await
2947 {
2948 Ok(()) => {
2949 let snapshot = app.backend.history_snapshot().await;
2950 let after = crate::backend::estimate_tokens(&snapshot);
2951 // Keep the saved session in step with the compacted state.
2952 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot)
2953 {
2954 log::warn!("session save after /compact failed: {error}");
2955 }
2956 app.context_tokens_used = Some(after);
2957 app.messages.push(ChatMessage::system(format!(
2958 "Compacted history: ~{before} → ~{after} tokens (estimated)."
2959 )));
2960 }
2961 Err(error) => {
2962 app.messages
2963 .push(ChatMessage::system(format!("Compaction failed: {error}")));
2964 }
2965 }
2966 }
2967 SlashCommand::Resume => match crate::session_store::load(TUI_STORE_SESSION) {
2968 Some(history) if !history.is_empty() => {
2969 let restored = history.len();
2970 app.context_tokens_used = Some(crate::backend::estimate_tokens(&history));
2971 app.backend.restore_history(history).await;
2972 app.messages.push(ChatMessage::system(format!(
2973 "Restored {restored} message(s) from the saved session. \
2974 The model remembers the conversation; the scrollback above does not \
2975 replay it."
2976 )));
2977 }
2978 _ => {
2979 app.messages.push(ChatMessage::system(
2980 "No saved session to resume. Sessions are saved after each turn.",
2981 ));
2982 }
2983 },
2984 SlashCommand::Plan(value) => {
2985 use crate::permissions::{self, TUI_SESSION};
2986 let enabled = value.unwrap_or_else(|| !permissions::plan_mode(TUI_SESSION));
2987 permissions::set_plan_mode(TUI_SESSION, enabled);
2988 app.messages.push(ChatMessage::system(if enabled {
2989 "Plan mode ON — research with read-only tools only; edits and commands \
2990 are blocked until /plan off."
2991 } else {
2992 "Plan mode OFF — tools may execute again (subject to the permission \
2993 policy)."
2994 }));
2995 }
2996 SlashCommand::Permissions => {
2997 app.messages
2998 .push(ChatMessage::system(crate::permissions::describe(
2999 crate::permissions::TUI_SESSION,
3000 )));
3001 }
3002 SlashCommand::Thinking(value) => {
3003 app.show_thinking = value.unwrap_or(!app.show_thinking);
3004 app.messages.push(ChatMessage::system(if app.show_thinking {
3005 "Thinking display ON — model reasoning is shown above each reply."
3006 } else {
3007 "Thinking display OFF — replies show a one-line indicator; \
3008 /thinking to expand."
3009 }));
3010 }
3011 SlashCommand::Tools(value) => {
3012 app.tools_expanded = value.unwrap_or(!app.tools_expanded);
3013 app.messages
3014 .push(ChatMessage::system(if app.tools_expanded {
3015 "Tool calls expanded — /tools off to collapse them."
3016 } else {
3017 "Tool calls collapsed — /tools on to expand them."
3018 }));
3019 }
3020 SlashCommand::Status => {
3021 let info = engine.as_ref().info().await;
3022 let model = info.model_name.as_deref().unwrap_or("(none)");
3023 let mem = info.approx_memory.as_deref().unwrap_or("unknown");
3024 app.messages.push(ChatMessage::system(format!(
3025 "status: {:?} model: {} memory: {} history: {} turns",
3026 info.status, model, mem, info.history_length,
3027 )));
3028 }
3029 SlashCommand::Skills => {
3030 app.messages
3031 .push(ChatMessage::system(crate::skills::format_skills_list()));
3032 }
3033 SlashCommand::Agents => {
3034 app.messages.push(ChatMessage::system(
3035 crate::subagents::format_agent_types_list(),
3036 ));
3037 }
3038 SlashCommand::Commands => {
3039 app.messages
3040 .push(ChatMessage::system(crate::commands::format_commands_list()));
3041 }
3042 SlashCommand::Mcp => {
3043 app.messages
3044 .push(ChatMessage::system(crate::mcp::status_summary()));
3045 }
3046 SlashCommand::Models(selection) => match selection {
3047 None => {
3048 app.open_model_picker(&engine);
3049 }
3050 Some(n) => {
3051 let idx = n.saturating_sub(1);
3052 match app.model_picker_items.get(idx).cloned() {
3053 None => {
3054 app.messages.push(ChatMessage::system(format!(
3055 "error: no model #{n} — type /models to see the list."
3056 )));
3057 }
3058 Some(model) => {
3059 // ── siGit Code Cloud tier: no local load; sign-in gated ──
3060 if let Some(tier) = model.cloud_tier.clone() {
3061 app.close_model_picker();
3062 match crate::provider::cloud_tier_provider(&tier) {
3063 Some(provider) => {
3064 let system_prompt = crate::with_launch_context(
3065 &crate::system_prompt_for_model(true),
3066 );
3067 app.backend = Arc::new(
3068 OpenAiBackend::new(
3069 provider.base_url,
3070 provider.api_key,
3071 provider.model,
3072 Some(system_prompt),
3073 )
3074 .with_session_token(provider.session_token),
3075 );
3076 app.current_model_name = provider.display_name.clone();
3077 app.tool_calling = true;
3078 // Selecting a cloud tier puts us in cloud mode.
3079 let _ = crate::settings::set_local_inference(false);
3080 app.messages.push(ChatMessage::system(format!(
3081 "Switched to {}.",
3082 provider.display_name
3083 )));
3084 }
3085 None => {
3086 app.outcome = Status::Auth;
3087 app.messages.push(ChatMessage::system(
3088 "siGit Code Cloud needs an account. Use \
3089 `/login <email> <password>`, or create one at sigit.si.",
3090 ));
3091 }
3092 }
3093 return;
3094 }
3095
3096 app.close_model_picker();
3097 start_local_model_load(app, model, Arc::clone(&engine), terminal);
3098 }
3099 }
3100 }
3101 },
3102 SlashCommand::Local(value) => {
3103 let enabled = value.unwrap_or(!crate::settings::local_inference_enabled());
3104 match crate::settings::set_local_inference(enabled) {
3105 Ok(()) => {
3106 let state = if enabled { "on" } else { "off" };
3107 let hint = if enabled {
3108 "On-device models are highlighted. Type /models to pick one."
3109 } else {
3110 "siGit Code Cloud tiers are highlighted. Type /models to pick one."
3111 };
3112 app.messages.push(ChatMessage::system(format!(
3113 "Local inference is {state}. {hint}"
3114 )));
3115 // Refresh the picker so emphasis/order reflects the new mode.
3116 if app.show_model_picker {
3117 app.open_model_picker(&engine);
3118 }
3119 }
3120 Err(error) => {
3121 app.messages.push(ChatMessage::system(format!(
3122 "error: could not save local inference setting: {error}"
3123 )));
3124 }
3125 }
3126 }
3127 SlashCommand::Load => match default_local_model_item(app) {
3128 None => {
3129 app.messages.push(ChatMessage::system(
3130 "No local model available to load. Use /models to see the list.",
3131 ));
3132 }
3133 Some(model) => {
3134 start_local_model_load(app, model, Arc::clone(&engine), terminal);
3135 }
3136 },
3137 SlashCommand::Login(arg) => {
3138 let password_login = arg
3139 .as_deref()
3140 .and_then(crate::account::parse_login_args)
3141 .is_some();
3142 app.program_status.report(if password_login {
3143 Status::Working
3144 } else {
3145 Status::Auth
3146 });
3147 let result = match arg.as_deref().and_then(crate::account::parse_login_args) {
3148 Some((email, password)) => {
3149 match crate::account::authenticate(&email, &password).await {
3150 Ok(email) => Ok(format!(
3151 "Signed in as {email}. siGit Code Cloud applies to your next session."
3152 )),
3153 Err(error) => Err(format!("Login failed: {error}")),
3154 }
3155 }
3156 // A bare `/login` opens the browser instead of asking for
3157 // a password in the chat box.
3158 None => {
3159 app.messages.push(ChatMessage::system(
3160 "Opening your browser to sign in to siGit Code Cloud...",
3161 ));
3162 crate::browser_login_message().await
3163 }
3164 };
3165 app.finish_login(result);
3166 }
3167 SlashCommand::Logout => {
3168 let message = crate::account::end_session().await;
3169 app.messages.push(ChatMessage::system(message));
3170 }
3171 SlashCommand::Whoami => {
3172 let message = crate::account::status_line().await;
3173 app.messages.push(ChatMessage::system(message));
3174 }
3175 SlashCommand::Init => {
3176 // Handled in the input loop (it runs a real turn); reaching
3177 // this arm means a caller forgot that special case.
3178 app.messages
3179 .push(ChatMessage::system("Type /init in the chat to run it."));
3180 }
3181 SlashCommand::Exit => {
3182 app.quit = true;
3183 }
3184 SlashCommand::Unknown(cmd, _) => {
3185 app.messages.push(ChatMessage::system(format!(
3186 "unknown command: {cmd}. Type /commands to list user-defined commands."
3187 )));
3188 }
3189 }
3190 }
3191
3192 // ── Background inference task ─────────────────────────────────────────────
3193
3194 /// cap tool rounds so a confused model can't loop forever; auto-compaction
3195 /// keeps long runs inside the context window, so the cap can be generous
3196 const MAX_TOOL_ROUNDS: usize = 24;
3197
3198 /// The TUI is a single conversation, so it persists under one fixed
3199 /// session-store id (ACP sessions use their protocol-assigned ids).
3200 const TUI_STORE_SESSION: &str = "tui";
3201
3202 fn build_tool_specs() -> Vec<ToolSpec> {
3203 let mut specs: Vec<ToolSpec> = crate::tools::all_tools()
3204 .into_iter()
3205 .map(|t| ToolSpec {
3206 name: t.name.to_string(),
3207 description: t.description.to_string(),
3208 parameters_schema: t.parameters_schema.to_string(),
3209 })
3210 .collect();
3211
3212 // Advertise the Agent Skills `skill` tool only when skills exist on disk
3213 // (https://agentskills.io). The tool description carries the discovery
3214 // list (name + description) for progressive disclosure.
3215 let discovered = crate::skills::discover_skills();
3216 if !discovered.is_empty() {
3217 specs.push(ToolSpec {
3218 name: crate::skills::SKILL_TOOL_NAME.to_string(),
3219 description: crate::skills::skill_tool_description(&discovered),
3220 parameters_schema: crate::skills::skill_tool_schema().to_string(),
3221 });
3222 }
3223
3224 // Delegated research (`task`) is offered only when a subagent backend
3225 // can actually be built — same conditional pattern as `skill` above.
3226 if crate::tools::subagent_available() {
3227 specs.push(crate::tools::task_tool_spec());
3228 }
3229
3230 // `web_search` is a native wrapper around the official MCP server's
3231 // `web_search` tool, offered only when that MCP tool was actually
3232 // discovered (i.e. the user is signed in to siGit Code Cloud).
3233 let web_search_offered = crate::tools::web_search_available();
3234 if web_search_offered {
3235 specs.push(crate::tools::web_search_tool_spec());
3236 }
3237
3238 // Tools discovered from configured MCP servers (incl. the official
3239 // one). Exclude the raw web_search delegate when the native wrapper
3240 // above is already offered, so the model sees one clean option, not
3241 // two names for the same tool.
3242 specs.extend(crate::mcp::tool_specs().into_iter().filter(|spec| {
3243 !(web_search_offered && crate::tools::is_web_search_delegate(&spec.name))
3244 }));
3245
3246 specs
3247 }
3248
3249 /// Close out a cancelled round in backend history: the results of tools
3250 /// that already ran this round, plus cancellation notes for `unreached`
3251 /// calls. Leaving a round's tool calls unanswered breaks strict
3252 /// OpenAI-compatible endpoints on the session's next request.
3253 async fn abandon_round(
3254 backend: &dyn InferenceBackend,
3255 mut tool_results: Vec<ToolResult>,
3256 unreached: &[crate::backend::ToolCall],
3257 ) {
3258 for pending in unreached {
3259 tool_results.push(ToolResult {
3260 tool_call_id: pending.id.clone(),
3261 content: format!(
3262 "`{}` was not executed: the user cancelled the turn.",
3263 pending.name
3264 ),
3265 });
3266 }
3267 backend.record_cancelled_tool_results(tool_results).await;
3268 }
3269
3270 /// run the tool-calling loop off the main thread, posting updates via `tx`.
3271 /// dropping `tx` signals completion to the event loop.
3272 async fn run_inference_task(
3273 backend: Arc<dyn InferenceBackend>,
3274 text: String,
3275 tx: mpsc::Sender<InferenceUpdate>,
3276 tools_enabled: bool,
3277 ) {
3278 let tools = if tools_enabled {
3279 build_tool_specs()
3280 } else {
3281 vec![]
3282 };
3283
3284 // Bridge the backend's token sink onto the UI update channel as `Delta`
3285 // messages. The forwarder lives for the whole turn. The TUI's
3286 // `push_stream_delta` runs `strip_think_blocks` on its own buffer, so
3287 // `Visible` chunks are forwarded raw (any inline `<think>` markers kept
3288 // intact) while `Reasoning` chunks — which come from a dedicated field
3289 // and carry no markers — are wrapped in `<think>…</think>` so the same
3290 // splitter treats them as reasoning rather than visible reply text.
3291 let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<TokenChunk>();
3292 let forward_tx = tx.clone();
3293 let forwarder = tokio::spawn(async move {
3294 while let Some(chunk) = delta_rx.recv().await {
3295 let piece = match chunk {
3296 TokenChunk::Visible(text) => text,
3297 TokenChunk::Reasoning(text) => format!("<think>{text}</think>"),
3298 };
3299 if forward_tx
3300 .send(InferenceUpdate::Delta(piece))
3301 .await
3302 .is_err()
3303 {
3304 break;
3305 }
3306 }
3307 });
3308
3309 // The first round offers tools, so on-device inference can't stream it
3310 // (it must buffer to detect tool calls). With tools disabled there are
3311 // none to offer, so it streams directly.
3312 let first_sink = if tools.is_empty() {
3313 Some(&delta_tx)
3314 } else {
3315 None
3316 };
3317 let mut streamed = first_sink.is_some();
3318
3319 let mut result = match backend
3320 .send_message_with_tools(&text, &tools, first_sink)
3321 .await
3322 {
3323 Ok(r) => r,
3324 Err(err) => {
3325 let _ = tx.send(InferenceUpdate::Error(err)).await;
3326 return;
3327 }
3328 };
3329
3330 let mut round = 0;
3331
3332 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
3333 // any tool call means the first round didn't produce a final answer
3334 streamed = false;
3335 round += 1;
3336 log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
3337
3338 // Auto-compaction: long tool runs grow history fast; fold it into
3339 // a summary before the next round rather than blowing the window.
3340 let estimate = crate::backend::estimate_tokens(&backend.history_snapshot().await);
3341 if estimate > crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
3342 log::info!(
3343 "history ≈{estimate} tokens exceeds budget {} — compacting",
3344 crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET
3345 );
3346 match backend
3347 .compact_history(crate::backend::COMPACT_KEEP_LAST)
3348 .await
3349 {
3350 Ok(()) => {
3351 let after =
3352 crate::backend::estimate_tokens(&backend.history_snapshot().await);
3353 log::info!("compacted history to ≈{after} tokens");
3354 let _ = tx.send(InferenceUpdate::ContextUsage(after)).await;
3355 }
3356 Err(error) => log::warn!("history compaction failed: {error}"),
3357 }
3358 }
3359
3360 let mut tool_results = Vec::new();
3361
3362 for (call_index, tc) in result.tool_calls.iter().enumerate() {
3363 // The UI drops the receiver on Ctrl+C or quit. Stop the turn
3364 // at the next boundary instead of burning model rounds (and
3365 // possibly running granted tools) in the background.
3366 if tx.is_closed() {
3367 log::info!("turn cancelled by the user — stopping the tool loop");
3368 abandon_round(&*backend, tool_results, &result.tool_calls[call_index..]).await;
3369 return;
3370 }
3371
3372 log::info!(
3373 " → {}({})",
3374 tc.name,
3375 tc.arguments.chars().take(120).collect::<String>()
3376 );
3377
3378 let _ = tx
3379 .send(InferenceUpdate::ToolUse {
3380 name: tc.name.clone(),
3381 arguments: tc.arguments.clone(),
3382 })
3383 .await;
3384
3385 // Permission gate: read-only tools pass straight through; a
3386 // mutating tool consults policy and may pause on the user's
3387 // y/a/n/d answer (delivered over a oneshot from the event loop).
3388 use crate::permissions::{self, Decision, TUI_SESSION};
3389 let output = match permissions::decision_for(TUI_SESSION, &tc.name, &tc.arguments) {
3390 Decision::Allow => crate::tools::execute_tool(&tc.name, &tc.arguments).await,
3391 Decision::Deny(reason) => {
3392 log::info!(" ✗ {} denied by policy", tc.name);
3393 reason
3394 }
3395 Decision::Ask => {
3396 let (reply_tx, reply_rx) = oneshot::channel();
3397 let _ = tx
3398 .send(InferenceUpdate::ApprovalRequest {
3399 tool: tc.name.clone(),
3400 args: permissions::approval_preview(&tc.arguments),
3401 reply: reply_tx,
3402 })
3403 .await;
3404 match reply_rx.await {
3405 Ok(ApprovalChoice::Once) => {
3406 crate::tools::execute_tool(&tc.name, &tc.arguments).await
3407 }
3408 Ok(ApprovalChoice::Session) => {
3409 permissions::grant_for_session(
3410 TUI_SESSION,
3411 &tc.name,
3412 &tc.arguments,
3413 );
3414 crate::tools::execute_tool(&tc.name, &tc.arguments).await
3415 }
3416 Ok(ApprovalChoice::Deny) => {
3417 log::info!(" ✗ {} denied by user", tc.name);
3418 permissions::user_denial(&tc.name)
3419 }
3420 Ok(ApprovalChoice::DenySession) => {
3421 log::info!(" ✗ {} denied by user for the session", tc.name);
3422 permissions::deny_for_session(TUI_SESSION, &tc.name, &tc.arguments);
3423 permissions::user_denial(&tc.name)
3424 }
3425 // The UI dropped the reply channel (Ctrl+C or
3426 // quit): the whole turn is over, not just this
3427 // call. Close out the round and stop instead of
3428 // continuing rounds in the background.
3429 Err(_) => {
3430 log::info!(
3431 "turn cancelled at the approval prompt — stopping the tool loop"
3432 );
3433 abandon_round(
3434 &*backend,
3435 tool_results,
3436 &result.tool_calls[call_index..],
3437 )
3438 .await;
3439 return;
3440 }
3441 }
3442 }
3443 };
3444 log::info!(" ← {} chars", output.len());
3445
3446 // Display-only preview of the output tail; the model gets the
3447 // full output through `tool_results` below.
3448 let _ = tx
3449 .send(InferenceUpdate::ToolDone {
3450 output_preview: super::cap_output_preview(&output),
3451 })
3452 .await;
3453
3454 tool_results.push(ToolResult {
3455 tool_call_id: tc.id.clone(),
3456 content: output,
3457 });
3458 }
3459
3460 // Cancelled while the round's tools ran: record what executed and
3461 // stop before paying for another model round nobody will see.
3462 if tx.is_closed() {
3463 log::info!("turn cancelled by the user — skipping the next model round");
3464 abandon_round(&*backend, tool_results, &[]).await;
3465 return;
3466 }
3467
3468 // on the last round, pass no tools so the model must produce text —
3469 // that's also the round we can stream on-device.
3470 let allow_tool_calls = round < MAX_TOOL_ROUNDS;
3471 let sink = if !allow_tool_calls {
3472 streamed = true;
3473 Some(&delta_tx)
3474 } else {
3475 None
3476 };
3477
3478 match backend
3479 .send_tool_results(tool_results, &tools, allow_tool_calls, sink)
3480 .await
3481 {
3482 Ok(r) => result = r,
3483 Err(err) => {
3484 let _ = tx.send(InferenceUpdate::Error(err)).await;
3485 return;
3486 }
3487 }
3488 }
3489
3490 // Drop the sink so the forwarder finishes draining any buffered tokens
3491 // before we commit the reply.
3492 drop(delta_tx);
3493 let _ = forwarder.await;
3494
3495 if result.tool_calls.is_empty() {
3496 if result.text.is_empty() {
3497 log::warn!(
3498 "model returned empty reply — may have exhausted max_tokens on thinking"
3499 );
3500 let _ = tx
3501 .send(InferenceUpdate::Error(
3502 "(empty response — the model may have used all tokens on internal reasoning. \
3503 Try a shorter or simpler prompt.)"
3504 .to_string(),
3505 ))
3506 .await;
3507 } else if streamed {
3508 // tokens already went out as deltas; just commit the buffer
3509 let _ = tx.send(InferenceUpdate::StreamEnd).await;
3510 } else {
3511 let _ = tx.send(InferenceUpdate::Response(result.text)).await;
3512 }
3513 }
3514
3515 // Persist the completed turn so /resume (or a restart) can pick the
3516 // conversation back up.
3517 let snapshot = backend.history_snapshot().await;
3518 let _ = tx
3519 .send(InferenceUpdate::ContextUsage(
3520 crate::backend::estimate_tokens(&snapshot),
3521 ))
3522 .await;
3523 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot) {
3524 log::warn!("session save failed: {error}");
3525 }
3526
3527 log::info!("inference complete — {} tool round(s)", round);
3528 // tx drops here — event loop gets None from rx.recv()
3529 }
3530
3531 // ── Main loop ─────────────────────────────────────────────────────────────
3532
3533 /// entry point — blocks until the user quits.
3534 /// caller owns terminal init/restore. `load_rx` delivers the model-load result
3535 /// from a dedicated OS thread; we poll it non-blocking each tick.
3536 pub async fn run_with<B: ratatui::backend::Backend>(
3537 terminal: &mut ratatui::Terminal<B>,
3538 engine: Arc<ChatEngine>,
3539 backend: Arc<dyn InferenceBackend>,
3540 load_rx: std_mpsc::Receiver<Result<(), String>>,
3541 load_model_name: String,
3542 program_status: Reporter,
3543 ) -> Result<()>
3544 where
3545 // ratatui 0.30 gives each backend its own error type; `?` into anyhow needs it
3546 // to be thread-safe.
3547 B::Error: Send + Sync + 'static,
3548 {
3549 event_loop(
3550 terminal,
3551 engine,
3552 backend,
3553 load_rx,
3554 load_model_name,
3555 program_status,
3556 )
3557 .await
3558 }
3559
3560 async fn event_loop<B: ratatui::backend::Backend>(
3561 terminal: &mut ratatui::Terminal<B>,
3562 engine: Arc<ChatEngine>,
3563 backend: Arc<dyn InferenceBackend>,
3564 load_rx: std_mpsc::Receiver<Result<(), String>>,
3565 load_model_name: String,
3566 program_status: Reporter,
3567 ) -> Result<()>
3568 where
3569 // ratatui 0.30 gives each backend its own error type; `?` into anyhow needs it
3570 // to be thread-safe.
3571 B::Error: Send + Sync + 'static,
3572 {
3573 let mut app = App::new(load_model_name, backend, program_status);
3574 let mut event_stream = EventStream::new();
3575 let mut startup_rx = startup::spawn();
3576
3577 // ~30 fps while the splash plays; the loading phase is the only user
3578 let mut ticker = interval(Duration::from_millis(33));
3579
3580 loop {
3581 // ── Poll the loader channel (non-blocking) ────────────────────────
3582 if app.is_loading {
3583 match load_rx.try_recv() {
3584 Ok(Ok(())) => app.load_ready = true,
3585 Ok(Err(e)) => app.set_load_error(e),
3586 Err(std_mpsc::TryRecvError::Empty) => {}
3587 Err(std_mpsc::TryRecvError::Disconnected) => {
3588 app.set_load_error("Model loader thread crashed.".to_string());
3589 }
3590 }
3591 }
3592
3593 // Startup checks report as they finish, and keep reporting after a
3594 // skip: the picker and the Repo tab fill in behind the chat.
3595 while let Ok(report) = startup_rx.try_recv() {
3596 app.apply_startup(report);
3597 }
3598 // The splash leaves once the backend is up and every check has
3599 // reported (and never before its minimum), or on a key press.
3600 if app.is_loading
3601 && app.load_ready
3602 && app.load_error.is_none()
3603 && app
3604 .splash
3605 .update(std::time::Instant::now(), app.checks.all_done())
3606 == Phase::Done
3607 {
3608 app.finish_loading();
3609 }
3610
3611 // redraw every iteration
3612 terminal.draw(|frame| render(frame, &mut app))?;
3613
3614 if let Some(rx) = app.model_load_rx.as_mut() {
3615 match rx.try_recv() {
3616 Ok(ModelLoadUpdate::Loaded(model_name)) => {
3617 app.outcome = Status::Done;
3618 engine.clear_history().await;
3619 app.context_tokens_used = None;
3620 if let Some(tc) = app.pending_tool_calling.take() {
3621 app.tool_calling = tc;
3622 }
3623 app.switching_model = false;
3624 app.switching_model_id = None;
3625 app.download_progress = None;
3626 app.model_load_cancelled = false;
3627 app.model_load_rx = None;
3628 app.current_model_name = model_name.clone();
3629
3630 let save_result = app
3631 .model_picker_items
3632 .iter()
3633 .find(|item| item.display_name == model_name)
3634 .map(|item| crate::setup::SelectedModel {
3635 model_id: item.config.model_id.clone(),
3636 gguf_file: item
3637 .config
3638 .files
3639 .first()
3640 .cloned()
3641 .unwrap_or_else(String::new),
3642 })
3643 .filter(|selected| !selected.gguf_file.is_empty())
3644 .map(|selected| crate::setup::save_selected_model(&selected))
3645 .unwrap_or_else(|| {
3646 Err(format!(
3647 "could not determine a stable identifier for {}",
3648 model_name
3649 ))
3650 });
3651
3652 if let Err(error) = save_result {
3653 app.messages.push(ChatMessage::system(format!(
3654 "warning: switched to {} but could not save the selection: {}",
3655 model_name, error
3656 )));
3657 } else {
3658 app.messages
3659 .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
3660 }
3661 }
3662 Ok(ModelLoadUpdate::Error(error)) => {
3663 app.outcome = Status::Error;
3664 app.switching_model = false;
3665 app.switching_model_id = None;
3666 app.download_progress = None;
3667 app.model_load_cancelled = false;
3668 app.model_load_rx = None;
3669 app.messages
3670 .push(ChatMessage::system(format!("error loading model: {error}")));
3671 }
3672 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
3673 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
3674 let was_cancelled = app.model_load_cancelled;
3675 app.switching_model = false;
3676 app.switching_model_id = None;
3677 app.download_progress = None;
3678 app.model_load_cancelled = false;
3679 app.model_load_rx = None;
3680 if !was_cancelled {
3681 app.outcome = Status::Error;
3682 app.messages.push(ChatMessage::system(
3683 "error loading model: loader task disconnected".to_string(),
3684 ));
3685 }
3686 }
3687 }
3688 }
3689
3690 // Polling model loads can change state immediately before select
3691 // goes idle, so report after applying those updates.
3692 app.program_status.report(app.status());
3693
3694 if app.quit {
3695 break;
3696 }
3697
3698 // multiplex terminal events, streaming tokens, inference updates,
3699 // and the thinking-spinner timer.
3700 tokio::select! {
3701 biased;
3702
3703 // ── Splash / spinner tick (loading phase only) ────────────────
3704 _ = ticker.tick(), if app.is_loading => {
3705 app.tick();
3706 }
3707
3708 // ── inference updates from background task ───────────────────
3709 update = async {
3710 match app.inference_rx.as_mut() {
3711 Some(rx) => rx.recv().await,
3712 None => pending().await,
3713 }
3714 } => {
3715 match update {
3716 Some(InferenceUpdate::ToolUse { name, arguments }) => {
3717 let title = super::tool_title(&name, &arguments);
3718 let detail = super::pretty_tool_arguments(&arguments);
3719 app.messages.push(ChatMessage::tool_call(title, detail));
3720 }
3721 Some(InferenceUpdate::ToolDone { output_preview }) => {
3722 // Attach to the most recent tool entry still
3723 // waiting for its result.
3724 if let Some(entry) = app
3725 .messages
3726 .iter_mut()
3727 .rev()
3728 .find(|m| m.role == Role::Tool && m.tool_result.is_none())
3729 {
3730 entry.tool_result = Some(output_preview);
3731 }
3732 }
3733 Some(InferenceUpdate::Delta(delta)) => {
3734 app.push_stream_delta(&delta);
3735 }
3736 Some(InferenceUpdate::StreamEnd) => {
3737 app.finalize_stream();
3738 }
3739 Some(InferenceUpdate::Response(text)) => {
3740 app.stop_thinking();
3741 app.outcome = Status::Done;
3742 app.messages.push(ChatMessage::assistant(text));
3743 }
3744 Some(InferenceUpdate::ContextUsage(tokens)) => {
3745 app.context_tokens_used = Some(tokens);
3746 }
3747 Some(InferenceUpdate::Error(msg)) => {
3748 app.finalize_stream();
3749 app.stop_thinking();
3750 app.outcome = Status::Error;
3751 app.messages.push(ChatMessage::system(format!("error: {msg}")));
3752 }
3753 Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
3754 // The y/a/n/d prompt lives on the Session tab; make
3755 // sure the user can see what they're answering.
3756 app.active_tab = Tab::Session;
3757 let call = if args.is_empty() {
3758 tool.clone()
3759 } else {
3760 format!("{tool}({args})")
3761 };
3762 app.messages.push(ChatMessage::system(format!(
3763 "⚠ permission — allow {call}? [y]es · [a]lways this session · [n]o · [d]eny this session"
3764 )));
3765 app.pending_approval = Some((tool, reply));
3766 }
3767 None => {
3768 // task finished, possibly with no text to show
3769 app.finalize_stream();
3770 app.stop_thinking();
3771 app.outcome = Status::Done;
3772 }
3773 }
3774 }
3775
3776 // ── Cloud tab status fetch resolving ─────────────────────────
3777 status = async {
3778 match app.cloud_rx.as_mut() {
3779 Some(rx) => rx.await,
3780 None => pending().await,
3781 }
3782 } => {
3783 app.cloud_rx = None;
3784 app.cloud_lines = Some(status.unwrap_or_else(|_| {
3785 vec!["error: the status fetch task died — press r to retry".to_string()]
3786 }));
3787 }
3788
3789 // ── Repo tab fetches resolving ────────────────────────────────
3790 update = async {
3791 match app.repo_rx.as_mut() {
3792 Some(rx) => rx.recv().await,
3793 None => pending().await,
3794 }
3795 } => {
3796 match update {
3797 Some(RepoUpdate::Issues(result)) => {
3798 app.repo_issues = match result {
3799 Ok(items) => RepoData::Ready(items),
3800 Err(error) => RepoData::Failed(error),
3801 };
3802 app.repo_issue_index = app
3803 .repo_issue_index
3804 .min(app.repo_issues.len().saturating_sub(1));
3805 }
3806 Some(RepoUpdate::PullRequests(result)) => {
3807 app.repo_prs = match result {
3808 Ok(items) => RepoData::Ready(items),
3809 Err(error) => RepoData::Failed(error),
3810 };
3811 app.repo_pr_index = app
3812 .repo_pr_index
3813 .min(app.repo_prs.len().saturating_sub(1));
3814 }
3815 Some(RepoUpdate::Detail { target, text }) => {
3816 // Apply only to the matching open view: a reply for
3817 // a detail the user has since closed or navigated
3818 // away from is dropped, never shown under the wrong
3819 // title.
3820 if let Some(detail) = app.repo_detail.as_mut()
3821 && detail.target == target
3822 {
3823 detail.text = Some(text);
3824 }
3825 }
3826 // All senders gone (can't happen while app.repo_tx is
3827 // held) — stop polling this channel.
3828 None => app.repo_rx = None,
3829 }
3830 }
3831
3832 // ── thinking / switching spinner tick (100ms) ────────────────
3833 _ = async {
3834 if app.thinking || app.switching_model {
3835 tokio::time::sleep(Duration::from_millis(100)).await
3836 } else {
3837 pending().await
3838 }
3839 } => {
3840 app.tick_thinking();
3841 // keep the progress display fresh
3842 if app.switching_model {
3843 app.poll_download_progress();
3844 }
3845 }
3846
3847 // ── Terminal events ───────────────────────────────────────────
3848 maybe_event = event_stream.next() => {
3849 let Some(Ok(event)) = maybe_event else {
3850 break;
3851 };
3852
3853 if let Event::Key(key) = event {
3854 if key.kind == KeyEventKind::Press {
3855 app.outcome = Status::Idle;
3856 }
3857 // loading phase — only quit keys work
3858 if app.is_loading {
3859 if key.kind == KeyEventKind::Press {
3860 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3861 if ctrl
3862 && (key.code == KeyCode::Char('c')
3863 || key.code == KeyCode::Char('d'))
3864 {
3865 app.quit = true;
3866 } else {
3867 // any other key skips the splash
3868 app.splash.skip();
3869 }
3870 }
3871 continue;
3872 }
3873
3874 // pending tool approval — y/a/n/d answer the prompt; the
3875 // inference task is paused on the reply channel. Checked
3876 // before the busy gate because the app *is* busy here.
3877 if app.pending_approval.is_some() {
3878 if key.kind == KeyEventKind::Press {
3879 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3880 let choice = if ctrl
3881 && (key.code == KeyCode::Char('c')
3882 || key.code == KeyCode::Char('d'))
3883 {
3884 // cancel the whole turn: denying is implicit
3885 // in dropping the reply channel
3886 app.pending_approval = None;
3887 app.stop_thinking();
3888 app.messages.push(ChatMessage::system("(cancelled)"));
3889 continue;
3890 } else {
3891 match key.code {
3892 KeyCode::Char('y') | KeyCode::Char('Y') => {
3893 Some(ApprovalChoice::Once)
3894 }
3895 KeyCode::Char('a') | KeyCode::Char('A') => {
3896 Some(ApprovalChoice::Session)
3897 }
3898 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
3899 Some(ApprovalChoice::Deny)
3900 }
3901 KeyCode::Char('d') | KeyCode::Char('D') => {
3902 Some(ApprovalChoice::DenySession)
3903 }
3904 _ => None,
3905 }
3906 };
3907 if let Some(choice) = choice
3908 && let Some((tool, reply)) = app.pending_approval.take()
3909 {
3910 let verdict = match &choice {
3911 ApprovalChoice::Once => "allowed once",
3912 ApprovalChoice::Session => "allowed for this session",
3913 ApprovalChoice::Deny => "denied",
3914 ApprovalChoice::DenySession => "denied for this session",
3915 };
3916 app.messages.push(ChatMessage::system(format!(
3917 "{tool}: {verdict}"
3918 )));
3919 let _ = reply.send(choice);
3920 }
3921 }
3922 continue;
3923 }
3924
3925 // ── Tab-bar navigation ────────────────────────────────
3926 // Handled before the busy gate so the user can look at
3927 // History/Cloud while inference runs (updates keep
3928 // landing in the Session tab's message list). The Tab
3929 // key only cycles when the input buffer is empty, so
3930 // pasted text containing tabs can't fight it; on
3931 // non-Session tabs the input is hidden, so it always
3932 // cycles there.
3933 if key.kind == KeyEventKind::Press && !app.show_model_picker {
3934 if key.code == KeyCode::Tab
3935 && (app.active_tab != Tab::Session || app.input.is_empty())
3936 {
3937 let next = app.active_tab.next(app.repo_visible());
3938 switch_tab(&mut app, next, &engine);
3939 continue;
3940 }
3941 if app.active_tab != Tab::Session && key.code == KeyCode::Esc {
3942 // On the Repo tab, Esc first closes an open
3943 // detail view (back to the list).
3944 if app.active_tab == Tab::Repo && app.repo_detail.is_some() {
3945 app.repo_detail = None;
3946 } else {
3947 app.active_tab = Tab::Session;
3948 }
3949 continue;
3950 }
3951 }
3952
3953 // busy — only cancel keys work
3954 if app.is_busy() {
3955 if key.kind == KeyEventKind::Press {
3956 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3957 if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
3958 if app.is_streaming() {
3959 app.finalize_stream();
3960 app.messages.push(ChatMessage::system("(cancelled)"));
3961 }
3962 if app.thinking {
3963 // dropping rx kills the background task
3964 app.stop_thinking();
3965 app.messages.push(ChatMessage::system("(cancelled)"));
3966 }
3967 // A visible stream can have cleared `thinking`;
3968 // cancellation must drop its receiver and clear
3969 // thinking so the tab cannot stay on working.
3970 app.stop_thinking();
3971 if app.switching_model {
3972 // flag before drop so Disconnected handler stays quiet
3973 app.model_load_cancelled = true;
3974 app.switching_model = false;
3975 app.switching_model_id = None;
3976 app.download_progress = None;
3977 app.model_load_rx = None;
3978 app.messages
3979 .push(ChatMessage::system("(download cancelled — model switch aborted)"));
3980 }
3981 }
3982 }
3983 continue;
3984 }
3985
3986 // History / Cloud tabs have their own key handling; the
3987 // chat input is inactive there.
3988 if app.active_tab != Tab::Session {
3989 if key.kind == KeyEventKind::Press {
3990 handle_tab_key(&mut app, key, &engine).await;
3991 }
3992 continue;
3993 }
3994
3995 if let Some(text) = handle_key(&mut app, key) {
3996 // `/init` runs a real agent turn: the transcript
3997 // shows the command the user typed, but the model
3998 // receives the canned AGENTS.md-generation prompt.
3999 // User-defined commands (`.sigit/commands/*.md`)
4000 // get the same treatment once `parse_slash`
4001 // reports the word unrecognized.
4002 let mut inference_text = text.clone();
4003 match parse_slash(&text) {
4004 Some(SlashCommand::Init) => {
4005 inference_text =
4006 crate::instructions::INIT_PROMPT.to_string();
4007 }
4008 Some(SlashCommand::Unknown(command, argument)) => {
4009 match crate::commands::resolve_command(&command) {
4010 Some(custom) => {
4011 inference_text = crate::commands::render(
4012 &custom.body,
4013 argument.as_deref(),
4014 );
4015 }
4016 None => {
4017 exec_slash(
4018 &mut app,
4019 SlashCommand::Unknown(command, argument),
4020 Arc::clone(&engine),
4021 terminal,
4022 )
4023 .await;
4024 continue;
4025 }
4026 }
4027 }
4028 Some(cmd) => {
4029 exec_slash(&mut app, cmd, Arc::clone(&engine), terminal)
4030 .await;
4031 continue;
4032 }
4033 None => {}
4034 }
4035
4036 // On-device inference needs a model in memory, and we
4037 // never load one implicitly: the user loads it with
4038 // /load (or /models). Refuse rather than erroring out
4039 // deep in the backend.
4040 if !app.backend.is_remote()
4041 && engine.info().await.status == onde::inference::EngineStatus::Unloaded
4042 {
4043 app.messages.push(ChatMessage::user(&text));
4044 app.messages.push(ChatMessage::system(
4045 "No on-device model is loaded. Run /load to load the selected \
4046 model, or /models to choose one.",
4047 ));
4048 continue;
4049 }
4050
4051 // ── spawn inference ──────────────────────────────
4052 app.messages.push(ChatMessage::user(&text));
4053 app.start_thinking();
4054
4055 let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
4056 app.inference_rx = Some(rx);
4057
4058 let backend_handle = Arc::clone(&app.backend);
4059 let user_text = inference_text;
4060 let tools_enabled = app.tool_calling;
4061 tokio::spawn(async move {
4062 run_inference_task(backend_handle, user_text, tx, tools_enabled).await;
4063 });
4064 }
4065 }
4066 }
4067 }
4068 }
4069
4070 Ok(())
4071 }
4072
4073 // ── Download progress helpers (TUI) ──────────────────────────────────────
4074
4075 /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks)
4076 fn dir_size_recursive(path: &std::path::Path) -> u64 {
4077 let mut total: u64 = 0;
4078 let Ok(entries) = std::fs::read_dir(path) else {
4079 return 0;
4080 };
4081 for entry in entries.flatten() {
4082 let entry_path = entry.path();
4083 if entry_path.is_dir() {
4084 total += dir_size_recursive(&entry_path);
4085 } else if let Ok(meta) = entry_path.metadata() {
4086 total += meta.len();
4087 }
4088 }
4089 total
4090 }
4091
4092 fn format_size_human(bytes: u64) -> String {
4093 const GB: u64 = 1_073_741_824;
4094 const MB: u64 = 1_048_576;
4095 const KB: u64 = 1_024;
4096 if bytes >= GB {
4097 format!("{:.2} GB", bytes as f64 / GB as f64)
4098 } else if bytes >= MB {
4099 format!("{:.1} MB", bytes as f64 / MB as f64)
4100 } else if bytes >= KB {
4101 format!("{:.0} KB", bytes as f64 / KB as f64)
4102 } else {
4103 format!("{bytes} B")
4104 }
4105 }
4106 #[cfg(test)]
4107 mod tui_tests {
4108 use super::{
4109 App, OpenAiBackend, Status, compact_tokens, context_gauge_color, context_gauge_label,
4110 };
4111 use ratatui::style::Color;
4112 use std::sync::Arc;
4113
4114 #[test]
4115 fn login_status_uses_the_result_instead_of_message_wording() {
4116 let backend = Arc::new(OpenAiBackend::new("http://localhost/v1", "", "test", None));
4117 let mut app = App::new(
4118 "test".into(),
4119 backend,
4120 super::Reporter::new(std::io::sink()),
4121 );
4122 app.finish_loading();
4123 app.finish_login(Ok("Welcome back.".into()));
4124 assert_eq!(app.status(), Status::Done);
4125 app.finish_login(Err("Signed in could not be completed.".into()));
4126 assert_eq!(app.status(), Status::Error);
4127 }
4128
4129 #[test]
4130 fn program_status_tracks_loading_approval_completion_and_cancellation() {
4131 let backend = Arc::new(OpenAiBackend::new("http://localhost/v1", "", "test", None));
4132 let mut app = App::new(
4133 "test".into(),
4134 backend,
4135 super::Reporter::new(std::io::sink()),
4136 );
4137 assert_eq!(app.status(), Status::Working);
4138 app.finish_loading();
4139 assert_eq!(app.status(), Status::Idle);
4140 app.start_thinking();
4141 assert_eq!(app.status(), Status::Working);
4142 let (reply, _) = tokio::sync::oneshot::channel();
4143 app.pending_approval = Some(("edit_file".into(), reply));
4144 assert_eq!(app.status(), Status::Permission);
4145 app.pending_approval = None;
4146 assert_eq!(app.status(), Status::Working);
4147 app.stop_thinking();
4148 assert_eq!(app.status(), Status::Idle);
4149 app.outcome = Status::Done;
4150 assert_eq!(app.status(), Status::Done);
4151 app.outcome = Status::Error;
4152 assert_eq!(app.status(), Status::Error);
4153 app.start_thinking();
4154 assert_eq!(app.status(), Status::Working);
4155 app.stop_thinking();
4156 app.show_model_picker = true;
4157 assert_eq!(app.status(), Status::Question);
4158 app.show_model_picker = false;
4159 app.set_load_error("failed".into());
4160 assert_eq!(app.status(), Status::Error);
4161 app.quit = true;
4162 assert_eq!(app.status(), Status::Idle);
4163 }
4164
4165 #[test]
4166 fn context_gauge_reports_share_of_the_window() {
4167 assert_eq!(context_gauge_label(168_000, 200_000), "84% · 168K/200K ctx");
4168 assert_eq!(context_gauge_label(0, 24_000), "0% · 0/24K ctx");
4169 // Over-full history (pre-compaction) clamps rather than reading 130%.
4170 assert_eq!(context_gauge_label(31_000, 24_000), "100% · 31K/24K ctx");
4171 }
4172
4173 #[test]
4174 fn context_gauge_handles_an_unknown_window() {
4175 assert_eq!(context_gauge_label(1_000, 0), "ctx —");
4176 assert_eq!(context_gauge_color(1_000, 0), Color::DarkGray);
4177 }
4178
4179 #[test]
4180 fn context_gauge_warms_up_as_the_window_fills() {
4181 assert_eq!(context_gauge_color(1_000, 24_000), Color::DarkGray);
4182 assert_eq!(context_gauge_color(18_000, 24_000), Color::Yellow);
4183 assert_eq!(context_gauge_color(23_000, 24_000), Color::Red);
4184 }
4185
4186 #[test]
4187 fn compact_tokens_abbreviates_thousands_only() {
4188 assert_eq!(compact_tokens(999), "999");
4189 assert_eq!(compact_tokens(1_500), "1K");
4190 }
4191 }
4192 } // end #[cfg(unix)] mod tui
4193
4194 // re-export so callers write `chat::run_with(...)` on all platforms
4195 #[cfg(unix)]
4196 pub use tui::run_with;
4197
4198 // ── Tests (platform-agnostic) ─────────────────────────────────────────────────
4199
4200 #[cfg(test)]
4201 mod tests {
4202 use std::time::Duration;
4203
4204 use super::{
4205 RepoItem, TOOL_RESULT_PREVIEW_MAX, Tab, cap_output_preview, format_age, format_repo_detail,
4206 history_row, host_from_api_url, parse_repo_items, parse_repo_remote,
4207 parse_rich_text_segments, pretty_tool_arguments, repo_row, streaming_think_tail,
4208 strip_think_blocks, think_indicator, tool_entry_lines, tool_title, url_host,
4209 };
4210
4211 // ── tool_title ────────────────────────────────────────────────────────────
4212
4213 #[test]
4214 fn tool_title_run_command_shows_the_command() {
4215 assert_eq!(
4216 tool_title("run_command", r#"{"command":"cargo test"}"#),
4217 "run_command · cargo test"
4218 );
4219 }
4220
4221 #[test]
4222 fn tool_title_task_tools_name_the_task() {
4223 for name in ["command_output", "monitor_command", "kill_command"] {
4224 assert_eq!(
4225 tool_title(name, r#"{"task_id":999999}"#),
4226 format!("{name} · task 999999")
4227 );
4228 }
4229 assert_eq!(tool_title("monitor_command", "{}"), "monitor_command");
4230 }
4231
4232 #[test]
4233 fn tool_title_file_tools_show_the_path() {
4234 for name in [
4235 "read_file",
4236 "create_file",
4237 "edit_file",
4238 "multi_edit",
4239 "delete_file",
4240 ] {
4241 assert_eq!(
4242 tool_title(name, r#"{"path":"src/main.rs","old_text":"a"}"#),
4243 format!("{name} · src/main.rs")
4244 );
4245 }
4246 }
4247
4248 #[test]
4249 fn tool_title_pattern_tools_show_the_pattern() {
4250 assert_eq!(
4251 tool_title("search_files", r#"{"pattern":"fn main","path":"src"}"#),
4252 "search_files · fn main"
4253 );
4254 assert_eq!(
4255 tool_title("glob", r#"{"pattern":"**/*.rs"}"#),
4256 "glob · **/*.rs"
4257 );
4258 }
4259
4260 #[test]
4261 fn tool_title_mcp_tools_show_server_and_bare_tool_name() {
4262 assert_eq!(
4263 tool_title("mcp__sigit__list_issues", r#"{"repo":"getsigit/sigit"}"#),
4264 "sigit · list_issues"
4265 );
4266 }
4267
4268 #[test]
4269 fn tool_title_unknown_tool_is_the_name_alone() {
4270 assert_eq!(
4271 tool_title("write_todos", r#"{"todos":[{"text":"x"}]}"#),
4272 "write_todos"
4273 );
4274 }
4275
4276 #[test]
4277 fn tool_title_malformed_json_falls_back_to_the_name() {
4278 assert_eq!(tool_title("run_command", "{not json"), "run_command");
4279 assert_eq!(tool_title("edit_file", ""), "edit_file");
4280 }
4281
4282 #[test]
4283 fn tool_title_missing_or_non_string_arg_falls_back_to_the_name() {
4284 assert_eq!(tool_title("run_command", r#"{"other":"x"}"#), "run_command");
4285 assert_eq!(
4286 tool_title("run_command", r#"{"command":42}"#),
4287 "run_command"
4288 );
4289 }
4290
4291 #[test]
4292 fn tool_title_truncates_long_summaries_with_an_ellipsis() {
4293 let long = "a".repeat(100);
4294 let title = tool_title("run_command", &format!(r#"{{"command":"{long}"}}"#));
4295
4296 let summary = title.strip_prefix("run_command · ").expect("summary");
4297 assert_eq!(summary.chars().count(), 60);
4298 assert!(summary.ends_with('…'));
4299 }
4300
4301 #[test]
4302 fn tool_title_collapses_multiline_commands_to_one_line() {
4303 assert_eq!(
4304 tool_title("run_command", "{\"command\":\"echo a &&\\n echo b\"}"),
4305 "run_command · echo a && echo b"
4306 );
4307 }
4308
4309 // ── cap_output_preview ────────────────────────────────────────────────────
4310
4311 #[test]
4312 fn cap_output_preview_passes_short_output_through() {
4313 assert_eq!(cap_output_preview("hello\nworld"), "hello\nworld");
4314 }
4315
4316 #[test]
4317 fn cap_output_preview_keeps_the_tail_and_notes_the_truncation() {
4318 let output = format!("{}{}", "x".repeat(3000), "the-very-end");
4319 let preview = cap_output_preview(&output);
4320
4321 assert!(preview.starts_with("… (truncated — showing the last 2000 of 3012 chars)\n"));
4322 assert!(preview.ends_with("the-very-end"));
4323 let (_note, tail) = preview.split_once('\n').expect("note line");
4324 assert_eq!(tail.chars().count(), TOOL_RESULT_PREVIEW_MAX);
4325 }
4326
4327 // ── pretty_tool_arguments ─────────────────────────────────────────────────
4328
4329 #[test]
4330 fn pretty_tool_arguments_pretty_prints_valid_json() {
4331 assert_eq!(
4332 pretty_tool_arguments(r#"{"command":"ls"}"#),
4333 "{\n \"command\": \"ls\"\n}"
4334 );
4335 }
4336
4337 #[test]
4338 fn pretty_tool_arguments_shows_malformed_json_raw() {
4339 assert_eq!(pretty_tool_arguments("{oops"), "{oops");
4340 }
4341
4342 // ── tool_entry_lines ──────────────────────────────────────────────────────
4343
4344 #[test]
4345 fn tool_entry_lines_collapsed_is_a_single_title_line() {
4346 let lines = tool_entry_lines(
4347 "run_command · cargo test",
4348 "{\n \"command\": \"cargo test\"\n}",
4349 Some("ok"),
4350 false,
4351 );
4352 assert_eq!(lines, vec!["▸ 🔧 run_command · cargo test"]);
4353 }
4354
4355 #[test]
4356 fn tool_entry_lines_expanded_shows_detail_and_result() {
4357 let lines = tool_entry_lines(
4358 "run_command · cargo test",
4359 "{\n \"command\": \"cargo test\"\n}",
4360 Some("test ok\ndone"),
4361 true,
4362 );
4363 assert_eq!(
4364 lines,
4365 vec![
4366 "▾ 🔧 run_command · cargo test",
4367 " {",
4368 " \"command\": \"cargo test\"",
4369 " }",
4370 " result:",
4371 " test ok",
4372 " done",
4373 ]
4374 );
4375 }
4376
4377 #[test]
4378 fn tool_entry_lines_expanded_without_result_omits_the_result_block() {
4379 let lines = tool_entry_lines("glob · **/*.rs", "{}", None, true);
4380 assert_eq!(lines, vec!["▾ 🔧 glob · **/*.rs", " {}"]);
4381 }
4382
4383 // ── Tab / format_age / history_row ────────────────────────────────────────
4384
4385 #[test]
4386 fn tab_next_without_repo_cycles_three_tabs() {
4387 assert_eq!(Tab::Session.next(false), Tab::History);
4388 assert_eq!(Tab::History.next(false), Tab::Cloud);
4389 assert_eq!(Tab::Cloud.next(false), Tab::Session);
4390 // Three hops return to the start — exactly the base three-tab cycle.
4391 assert_eq!(
4392 Tab::Session.next(false).next(false).next(false),
4393 Tab::Session
4394 );
4395 }
4396
4397 #[test]
4398 fn tab_next_with_repo_cycles_four_tabs() {
4399 assert_eq!(Tab::Session.next(true), Tab::History);
4400 assert_eq!(Tab::History.next(true), Tab::Repo);
4401 assert_eq!(Tab::Repo.next(true), Tab::Cloud);
4402 assert_eq!(Tab::Cloud.next(true), Tab::Session);
4403 }
4404
4405 #[test]
4406 fn tab_index_matches_titles_order() {
4407 for repo_visible in [false, true] {
4408 let titles = Tab::titles(repo_visible);
4409 assert_eq!(titles[Tab::Session.index(repo_visible)], "Session");
4410 assert_eq!(titles[Tab::History.index(repo_visible)], "History");
4411 assert_eq!(titles[Tab::Cloud.index(repo_visible)], "Cloud");
4412 }
4413 assert_eq!(Tab::titles(true)[Tab::Repo.index(true)], "Repo");
4414 }
4415
4416 #[test]
4417 fn tab_titles_hide_repo_when_undetected() {
4418 assert_eq!(Tab::titles(false), vec!["Session", "History", "Cloud"]);
4419 assert_eq!(
4420 Tab::titles(true),
4421 vec!["Session", "History", "Repo", "Cloud"]
4422 );
4423 }
4424
4425 // ── Repo remote detection ─────────────────────────────────────────────────
4426
4427 #[test]
4428 fn parse_repo_remote_accepts_ssh_forms() {
4429 assert_eq!(
4430 parse_repo_remote("git@sigit.si:acme/demo.git", "sigit.si"),
4431 Some("acme/demo".to_string())
4432 );
4433 // bare (no .git) works too
4434 assert_eq!(
4435 parse_repo_remote("git@sigit.si:acme/demo", "sigit.si"),
4436 Some("acme/demo".to_string())
4437 );
4438 }
4439
4440 #[test]
4441 fn parse_repo_remote_accepts_https_forms() {
4442 assert_eq!(
4443 parse_repo_remote("https://sigit.si/acme/demo.git", "sigit.si"),
4444 Some("acme/demo".to_string())
4445 );
4446 assert_eq!(
4447 parse_repo_remote("https://sigit.si/acme/demo", "sigit.si"),
4448 Some("acme/demo".to_string())
4449 );
4450 // trailing slash and http + port (dev instances) normalize too
4451 assert_eq!(
4452 parse_repo_remote("https://sigit.si/acme/demo/", "sigit.si"),
4453 Some("acme/demo".to_string())
4454 );
4455 assert_eq!(
4456 parse_repo_remote("http://127.0.0.1:8088/acme/demo.git", "127.0.0.1"),
4457 Some("acme/demo".to_string())
4458 );
4459 }
4460
4461 #[test]
4462 fn parse_repo_remote_rejects_other_hosts() {
4463 assert_eq!(
4464 parse_repo_remote("git@github.com:acme/demo.git", "sigit.si"),
4465 None
4466 );
4467 assert_eq!(
4468 parse_repo_remote("https://github.com/acme/demo.git", "sigit.si"),
4469 None
4470 );
4471 }
4472
4473 #[test]
4474 fn parse_repo_remote_rejects_garbage_and_bad_paths() {
4475 assert_eq!(parse_repo_remote("", "sigit.si"), None);
4476 assert_eq!(parse_repo_remote("not a url at all", "sigit.si"), None);
4477 assert_eq!(parse_repo_remote("/local/path/repo.git", "sigit.si"), None);
4478 // wrong number of path segments
4479 assert_eq!(
4480 parse_repo_remote("https://sigit.si/demo.git", "sigit.si"),
4481 None
4482 );
4483 assert_eq!(
4484 parse_repo_remote("https://sigit.si/a/b/c.git", "sigit.si"),
4485 None
4486 );
4487 assert_eq!(
4488 parse_repo_remote("git@sigit.si:/demo.git", "sigit.si"),
4489 None
4490 );
4491 }
4492
4493 #[test]
4494 fn url_host_extracts_the_hostname() {
4495 assert_eq!(url_host("https://sigit.si"), Some("sigit.si".to_string()));
4496 assert_eq!(
4497 url_host("http://localhost:8088/api/v1"),
4498 Some("localhost".to_string())
4499 );
4500 assert_eq!(
4501 url_host("https://SiGit.SI/path"),
4502 Some("sigit.si".to_string())
4503 );
4504 assert_eq!(url_host("ftp://sigit.si"), None);
4505 assert_eq!(url_host(""), None);
4506 }
4507
4508 #[test]
4509 fn host_from_api_url_defaults_to_production() {
4510 assert_eq!(host_from_api_url(None), "sigit.si");
4511 assert_eq!(host_from_api_url(Some("nonsense")), "sigit.si");
4512 assert_eq!(
4513 host_from_api_url(Some("http://127.0.0.1:8088")),
4514 "127.0.0.1"
4515 );
4516 }
4517
4518 // ── Repo tab data parsing ─────────────────────────────────────────────────
4519
4520 #[test]
4521 fn parse_repo_items_reads_a_top_level_array() {
4522 let text = r#"[
4523 {"number": 12, "title": "Fix the flux capacitor", "state": "open"},
4524 {"number": 7, "title": "Old bug", "state": "closed"}
4525 ]"#;
4526 let items = parse_repo_items(text).expect("items");
4527 assert_eq!(items.len(), 2);
4528 assert_eq!(items[0].number, 12);
4529 assert_eq!(items[0].title, "Fix the flux capacitor");
4530 assert_eq!(items[0].state, "open");
4531 }
4532
4533 #[test]
4534 fn parse_repo_items_reads_wrapped_arrays_and_skips_malformed_entries() {
4535 let text = r#"{"issues": [
4536 {"number": 1, "title": "ok", "state": "open"},
4537 {"title": "no number"},
4538 {"number": 2, "title": "stateless"}
4539 ]}"#;
4540 let items = parse_repo_items(text).expect("items");
4541 assert_eq!(items.len(), 2);
4542 assert_eq!(items[1].state, "");
4543 }
4544
4545 #[test]
4546 fn parse_repo_items_passes_error_text_through() {
4547 // `mcp::call_tool` returns error strings, not JSON — the tab shows them.
4548 let err = parse_repo_items("Error: MCP is not initialized.").unwrap_err();
4549 assert_eq!(err, "Error: MCP is not initialized.");
4550 // JSON that isn't a list shape is surfaced verbatim too.
4551 assert!(parse_repo_items("\"unexpected\"").is_err());
4552 }
4553
4554 #[test]
4555 fn repo_row_formats_number_state_title() {
4556 let item = RepoItem {
4557 number: 12,
4558 title: "Fix the flux capacitor".to_string(),
4559 state: "open".to_string(),
4560 };
4561 assert_eq!(repo_row(&item), "#12 [open] Fix the flux capacitor");
4562 let stateless = RepoItem {
4563 number: 3,
4564 title: "t".to_string(),
4565 state: String::new(),
4566 };
4567 assert_eq!(repo_row(&stateless), "#3 t");
4568 }
4569
4570 #[test]
4571 fn format_repo_detail_pretty_prints_json_and_passes_text_through() {
4572 let pretty = format_repo_detail(r#"{"number":1,"title":"x"}"#);
4573 assert!(
4574 pretty.contains("\n"),
4575 "expected pretty-printed JSON: {pretty}"
4576 );
4577 assert_eq!(
4578 format_repo_detail("Error: server 'sigit' returned 401"),
4579 "Error: server 'sigit' returned 401"
4580 );
4581 }
4582
4583 #[test]
4584 fn format_age_picks_the_coarsest_sensible_unit() {
4585 assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
4586 assert_eq!(format_age(Duration::from_secs(59)), "59s ago");
4587 assert_eq!(format_age(Duration::from_secs(60)), "1m ago");
4588 assert_eq!(format_age(Duration::from_secs(3_599)), "59m ago");
4589 assert_eq!(format_age(Duration::from_secs(3_600)), "1h ago");
4590 assert_eq!(format_age(Duration::from_secs(86_399)), "23h ago");
4591 assert_eq!(format_age(Duration::from_secs(86_400)), "1d ago");
4592 assert_eq!(format_age(Duration::from_secs(3 * 86_400)), "3d ago");
4593 }
4594
4595 #[test]
4596 fn history_row_formats_id_age_and_count() {
4597 assert_eq!(
4598 history_row("tui", Some(Duration::from_secs(120)), 7),
4599 "tui · 2m ago · 7 message(s)"
4600 );
4601 assert_eq!(
4602 history_row("sess-1", None, 0),
4603 "sess-1 · age unknown · 0 message(s)"
4604 );
4605 }
4606
4607 #[test]
4608 fn strip_think_blocks_separates_thinking_and_visible_reply() {
4609 let raw = "<think>I should inspect the code first.</think>Here is the fix.";
4610 let (thinking, visible) = strip_think_blocks(raw);
4611
4612 assert_eq!(thinking, "I should inspect the code first.");
4613 assert_eq!(visible, "Here is the fix.");
4614 }
4615
4616 #[test]
4617 fn strip_think_blocks_handles_unclosed_think_block() {
4618 let raw = "<think>I am still reasoning about the bug";
4619 let (thinking, visible) = strip_think_blocks(raw);
4620
4621 assert_eq!(thinking, "I am still reasoning about the bug");
4622 assert_eq!(visible, "");
4623 }
4624
4625 #[test]
4626 fn strip_think_blocks_leaves_plain_text_untouched() {
4627 let raw = "No hidden reasoning here.";
4628 let (thinking, visible) = strip_think_blocks(raw);
4629
4630 assert_eq!(thinking, "");
4631 assert_eq!(visible, "No hidden reasoning here.");
4632 }
4633
4634 #[test]
4635 fn streaming_think_tail_shows_last_lines_of_unclosed_think_buffer() {
4636 // the common mid-stream shape: `<think>` opened, not yet closed
4637 let raw = "<think>First thought.\nSecond thought.\nThird thought.\nFourth thought.";
4638 let tail = streaming_think_tail(raw, 40, 3);
4639
4640 assert_eq!(
4641 tail,
4642 vec!["Second thought.", "Third thought.", "Fourth thought."]
4643 );
4644 }
4645
4646 #[test]
4647 fn streaming_think_tail_wraps_long_lines_at_width() {
4648 let raw = "<think>one two three four five six seven";
4649 let tail = streaming_think_tail(raw, 10, 3);
4650
4651 // wrapped at 10 cols: ["one two", "three four", "five six", "seven"];
4652 // the tail keeps only the last 3
4653 assert_eq!(tail, vec!["three four", "five six", "seven"]);
4654 }
4655
4656 #[test]
4657 fn streaming_think_tail_is_empty_once_visible_text_arrives() {
4658 let raw = "<think>hidden reasoning</think>The answer is 42.";
4659 assert!(streaming_think_tail(raw, 40, 3).is_empty());
4660 }
4661
4662 #[test]
4663 fn streaming_think_tail_is_empty_for_only_think_closed_but_no_visible_yet() {
4664 // closed think, visible not started: still show the reasoning tail
4665 let raw = "<think>done reasoning</think>";
4666 assert_eq!(streaming_think_tail(raw, 40, 3), vec!["done reasoning"]);
4667 }
4668
4669 #[test]
4670 fn streaming_think_tail_is_empty_for_empty_or_plain_buffers() {
4671 assert!(streaming_think_tail("", 40, 3).is_empty());
4672 assert!(streaming_think_tail("plain visible text", 40, 3).is_empty());
4673 assert!(streaming_think_tail("<think>", 40, 3).is_empty());
4674 }
4675
4676 #[test]
4677 fn streaming_think_tail_hard_splits_words_wider_than_the_wrap_column() {
4678 let raw = "<think>abcdefghijkl";
4679 assert_eq!(
4680 streaming_think_tail(raw, 5, 3),
4681 vec!["abcde", "fghij", "kl"]
4682 );
4683 }
4684
4685 #[test]
4686 fn think_indicator_counts_lines_and_pluralizes() {
4687 assert_eq!(
4688 think_indicator("only one line of reasoning"),
4689 "· thought for a bit (1 line) — /thinking to show"
4690 );
4691 assert_eq!(
4692 think_indicator("a\nb\nc"),
4693 "· thought for a bit (3 lines) — /thinking to show"
4694 );
4695 }
4696
4697 #[test]
4698 fn parse_rich_text_segments_marks_bold_runs() {
4699 let segments = parse_rich_text_segments(
4700 "The current weather is **72°F** with **Partly Cloudy** conditions.",
4701 );
4702
4703 assert_eq!(
4704 segments,
4705 vec![
4706 ("The current weather is ".to_string(), false),
4707 ("72°F".to_string(), true),
4708 (" with ".to_string(), false),
4709 ("Partly Cloudy".to_string(), true),
4710 (" conditions.".to_string(), false),
4711 ]
4712 );
4713 }
4714
4715 #[test]
4716 fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
4717 let segments = parse_rich_text_segments("Prefix **bold");
4718
4719 assert_eq!(
4720 segments,
4721 vec![("Prefix ".to_string(), false), ("bold".to_string(), true),]
4722 );
4723 }
4724 }