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