@sigitcode-old / sigit / commits / 3d8a1e8
Fork 1

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

Add a launch splash that runs the startup checks

Show the banner as an animated wordmark when the terminal UI starts: the letters are the blank cells, the digits are a flame that ignites from the right and trails off to the left. It matches the splash in sigit-app. The splash waits on real work. The account check (a bounded request to sigit.si), the model scan, the git remote lookup and MCP discovery each run on their own task and report as they finish, shown as a short checklist under the word. It leaves when all four are in and 1.4 s have passed, after a 350 ms exit. Any key skips it and 4 s is the hard cap. MCP discovery and the model scan used to run before the first frame, so a slow server meant a blank terminal. A turn started before MCP discovery finishes lacks those tools for that turn. Narrow terminals crop the trail and tiny ones show the checklist without the art. SIGIT_REDUCE_MOTION=1 draws the finished frame, and terminals without truecolor get the nearest 256-colour cell. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 28, 2026 at 08:10 UTC 3d8a1e88e0acb37111d3541339fbcbc084a67c9f
6 files changed +1055 -68
crates/cli/src/account.rs
+43 -11
@@ -129,14 +129,32 @@ pub async fn end_session() -> String {
129 }
130 }
131
132 -/// One-line description of the current session, verifying the token if reachable.
133 -pub async fn status_line() -> String {
132 +/// What the stored session amounts to right now.
133 +#[derive(Debug, Clone, PartialEq, Eq)]
134 +pub enum SessionState {
135 + /// No credentials are stored.
136 + SignedOut,
137 + /// The server accepted the token.
138 + Valid { email: String },
139 + /// The server rejected the token (HTTP status).
140 + Expired { status: u16 },
141 + /// Credentials are stored but the server could not be reached in time.
142 + Offline { email: String },
143 +}
144 +
145 +/// Look up the stored session and verify its token, giving up on the network
146 +/// after `timeout`.
147 +pub async fn check_session(timeout: std::time::Duration) -> SessionState {
148 let Some(creds) = credentials::load() else {
135 - return "Not signed in. Use `/login` to use siGit Code Cloud.".to_string();
149 + return SessionState::SignedOut;
150 };
151
152 let url = format!("{}/api/v1/user", api_base().trim_end_matches('/'));
139 - match reqwest::Client::new()
153 + let client = reqwest::Client::builder()
154 + .timeout(timeout)
155 + .build()
156 + .unwrap_or_default();
157 + match client
158 .get(&url)
159 .bearer_auth(&creds.access_token)
160 .send()
@@ -150,14 +168,28 @@ pub async fn status_line() -> String {
168 .and_then(|me| me.email)
169 .or(creds.email)
170 .unwrap_or_else(|| "(unknown)".to_string());
153 - format!("Signed in to siGit Code Cloud as {email}.")
171 + SessionState::Valid { email }
172 + }
173 + Ok(response) => SessionState::Expired {
174 + status: response.status().as_u16(),
175 + },
176 + Err(_) => SessionState::Offline {
177 + email: creds.email.unwrap_or_else(|| "(unknown)".to_string()),
178 + },
179 + }
180 +}
181 +
182 +/// One-line description of the current session, verifying the token if reachable.
183 +pub async fn status_line() -> String {
184 + match check_session(std::time::Duration::from_secs(10)).await {
185 + SessionState::SignedOut => {
186 + "Not signed in. Use `/login` to use siGit Code Cloud.".to_string()
187 + }
188 + SessionState::Valid { email } => format!("Signed in to siGit Code Cloud as {email}."),
189 + SessionState::Expired { status } => {
190 + format!("Session may be expired (HTTP {status}). Use `/login` again.")
191 }
155 - Ok(response) => format!(
156 - "Session may be expired (HTTP {}). Use `/login` again.",
157 - response.status().as_u16()
158 - ),
159 - Err(_) => {
160 - let email = creds.email.unwrap_or_else(|| "(unknown)".to_string());
192 + SessionState::Offline { email } => {
193 format!("Signed in as {email} (could not reach siGit Code Cloud to verify).")
194 }
195 }
crates/cli/src/chat.rs
+196 -54
@@ -520,6 +520,7 @@ mod tui {
520 use onde::inference::{ChatEngine, SamplingConfig};
521
522 use super::{RepoItem, Tab};
523 + use crate::account::SessionState;
524 use crate::backend::{
525 InferenceBackend, LocalBackend, OpenAiBackend, TokenChunk, ToolResult, ToolSpec,
526 };
@@ -528,6 +529,11 @@ mod tui {
529 };
530 use crate::program_status::{Reporter, Status};
531 use crate::session_store::SessionEntry;
532 + use crate::splash::{self, BANNER_ART, Phase, Splash, Wordmark};
533 + use crate::startup::{
534 + self, CHECK_ROWS, Check, Checks, Report, account_check, models_check, tools_check,
535 + workspace_check,
536 + };
537 use ratatui::{
538 Frame,
539 layout::{Constraint, Layout, Position},
@@ -771,6 +777,13 @@ mod tui {
777 load_error: Option<String>,
778 load_start: Instant,
779 load_model_name: String,
780 + /// The backend is up. The splash still decides when the loading view ends.
781 + load_ready: bool,
782 + /// The launch splash and the checks it waits on.
783 + splash: Splash,
784 + checks: Checks,
785 + /// What the account check found, for the welcome message.
786 + session: Option<SessionState>,
787
788 // ── Model picker state ────────────────────────────────────────────────
789 show_model_picker: bool,
@@ -832,21 +845,6 @@ mod tui {
845 repo_rx: Option<mpsc::UnboundedReceiver<RepoUpdate>>,
846 }
847
835 - const BANNER_ART: &str = "\
836 -77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777
837 -77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777
838 -1111111125555555555555555555555511113222311159 5002 088 3081771691111111111111
839 -1111111111111111111111111111131136841 1482853332007 05 9043332891 400811111111111
840 -1111111111111111111111111111111201 109 304 40 00 79 100041111111111
841 -333333255555555555555555555552392 102 503 90 7000000005 903 0000023333333333
842 -333333245454545454545454545433381 7600000 302 61 780 109 20009533333333333
843 -3333333333333333333333333333333402 7001 08 761 202 902 90003333333333333
844 -2222255555555555555555555555250899901 49 304 403 08 108 300042222222222222
845 -2222222222222222222222222222269 106 03 901 06 505 402 000052222222222222
846 -2222255555555555555555555555299 708 1002 80 00 90852222222222222
847 -55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555
848 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888";
849 -
848 const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
849
850 fn rich_text_spans(text: &str, base_style: Style, bold_style: Style) -> Vec<Span<'static>> {
@@ -870,21 +868,10 @@ mod tui {
868 backend: Arc<dyn InferenceBackend>,
869 program_status: Reporter,
870 ) -> Self {
873 - let is_remote = backend.is_remote();
874 - let items = build_model_picker_items();
875 - let tool_calling = items
876 - .iter()
877 - .find(|m| m.display_name == load_model_name)
878 - .map(|m| m.tool_calling)
879 - .unwrap_or(true);
880 - // For a remote provider the passed-in name is authoritative; the
881 - // persisted local selection must not override it (or the title would
882 - // show an on-device model while requests go to the cloud).
883 - let current_model_name = if is_remote {
884 - load_model_name.clone()
885 - } else {
886 - crate::setup::load_selected_model_name().unwrap_or_else(|| load_model_name.clone())
887 - };
871 + // The model scan, the saved selection and the repo lookup used to run
872 + // here, before the first frame. They run behind the splash now and
873 + // land in `apply_startup`; until then the picker is empty and the
874 + // passed-in name stands.
875 Self {
876 messages: Vec::new(),
877 input: String::new(),
@@ -911,11 +898,15 @@ mod tui {
898 load_error: None,
899 load_start: Instant::now(),
900 load_model_name: load_model_name.clone(),
901 + load_ready: false,
902 + splash: Splash::new(std::time::Instant::now()),
903 + checks: Checks::new(),
904 + session: None,
905 show_model_picker: false,
906 model_picker_index: 0,
916 - model_picker_items: items,
917 - current_model_name,
918 - tool_calling,
907 + model_picker_items: Vec::new(),
908 + current_model_name: load_model_name.clone(),
909 + tool_calling: true,
910 context_tokens_used: None,
911 show_thinking: false,
912 tools_expanded: false,
@@ -927,7 +918,7 @@ mod tui {
918 history_notice: None,
919 cloud_lines: None,
920 cloud_rx: None,
930 - repo: detect_sigit_repo(),
921 + repo: None,
922 repo_section: RepoSection::Issues,
923 repo_issues: RepoData::NotFetched,
924 repo_prs: RepoData::NotFetched,
@@ -1108,6 +1099,40 @@ mod tui {
1099 self.download_progress = Some((downloaded, expected));
1100 }
1101
1102 + /// Take one startup check's answer.
1103 + fn apply_startup(&mut self, report: Report) {
1104 + match report {
1105 + Report::Account(state) => {
1106 + self.checks.account = account_check(&state);
1107 + self.session = Some(state);
1108 + }
1109 + Report::Models { items, selected } => {
1110 + self.tool_calling = items
1111 + .iter()
1112 + .find(|m| m.display_name == self.load_model_name)
1113 + .map(|m| m.tool_calling)
1114 + .unwrap_or(true);
1115 + // For a remote provider the passed-in name is authoritative; the
1116 + // persisted local selection must not override it (or the title
1117 + // would show an on-device model while requests go to the cloud).
1118 + if !self.backend.is_remote()
1119 + && let Some(name) = selected
1120 + {
1121 + self.current_model_name = name;
1122 + }
1123 + self.checks.models = models_check(&items);
1124 + self.model_picker_items = items;
1125 + }
1126 + Report::Tools(overview) => {
1127 + self.checks.tools = tools_check(overview.as_ref());
1128 + }
1129 + Report::Workspace(repo) => {
1130 + self.checks.workspace = workspace_check(repo.as_deref());
1131 + self.repo = repo;
1132 + }
1133 + }
1134 + }
1135 +
1136 /// switch to chat phase and show the welcome banner
1137 fn finish_loading(&mut self) {
1138 self.is_loading = false;
@@ -1131,6 +1156,11 @@ mod tui {
1156 self.current_model_name
1157 )));
1158 }
1159 + if matches!(self.session, Some(SessionState::Expired { .. })) {
1160 + self.messages.push(ChatMessage::system(
1161 + "Your siGit Code Cloud session has expired. Use /login to sign in again.",
1162 + ));
1163 + }
1164 self.messages
1165 .push(ChatMessage::system("Type /help for commands."));
1166 }
@@ -1555,21 +1585,6 @@ mod tui {
1585
1586 // ── Repo tab (issues / pull requests via the official MCP server) ─────────
1587
1558 - /// `owner/name` when the current directory's `origin` remote lives on the
1559 - /// sigit.si host (per `SIGIT_API_URL`). Runs `git remote get-url origin`
1560 - /// once at startup; any failure (no git, no repo, no origin) → `None`.
1561 - fn detect_sigit_repo() -> Option<String> {
1562 - let output = std::process::Command::new("git")
1563 - .args(["remote", "get-url", "origin"])
1564 - .output()
1565 - .ok()?;
1566 - if !output.status.success() {
1567 - return None;
1568 - }
1569 - let url = String::from_utf8_lossy(&output.stdout);
1570 - super::parse_repo_remote(url.trim(), &super::sigit_host())
1571 - }
1572 -
1588 /// Fetch both Repo-tab lists on spawned tasks feeding `repo_rx` — the same
1589 /// spawned-fetch pattern as the Cloud tab, so the render loop never blocks
1590 /// on the network. Replacing the channel drops any in-flight fetch's
@@ -2125,6 +2140,11 @@ mod tui {
2140 fn render(frame: &mut Frame, app: &mut App) {
2141 let area = frame.area();
2142
2143 + if app.is_loading && app.load_error.is_none() {
2144 + render_splash(frame, app, area);
2145 + return;
2146 + }
2147 +
2148 if app.is_loading {
2149 let zones = Layout::vertical([
2150 Constraint::Length(1),
@@ -2247,9 +2267,109 @@ mod tui {
2267 }
2268 }
2269
2270 + /// The launch splash: the wordmark with the startup checks under it. Too
2271 + /// small a terminal gets the caption and the checks without the art.
2272 + fn render_splash(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2273 + const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
2274 + const CHECKS: u16 = CHECK_ROWS as u16;
2275 + let now = std::time::Instant::now();
2276 + let reduced = app.splash.reduced();
2277 + let tc = app.splash.truecolor;
2278 + let t = if reduced {
2279 + Duration::from_secs(2)
2280 + } else {
2281 + app.splash.elapsed(now)
2282 + };
2283 + let ms = t.as_millis() as f32;
2284 + let exit = app.splash.exit_progress(now);
2285 + let vis = 1.0 - exit;
2286 +
2287 + // caption, a gap, the checklist
2288 + let art = app
2289 + .splash
2290 + .banner
2291 + .size(area.width)
2292 + .filter(|(_, h)| area.height >= h + 2 + 1 + CHECKS);
2293 + let art_h = art.map_or(0, |(_, h)| h + 1);
2294 + let total = art_h + 1 + 1 + CHECKS;
2295 + let top = area.y + area.height.saturating_sub(total) / 2;
2296 + let rect = |y: u16, h: u16| ratatui::layout::Rect::new(area.x, y, area.width, h);
2297 +
2298 + if let Some((_, h)) = art {
2299 + frame.render_widget(
2300 + Wordmark {
2301 + banner: &app.splash.banner,
2302 + t,
2303 + exit,
2304 + still: reduced,
2305 + truecolor: tc,
2306 + },
2307 + rect(top, h),
2308 + );
2309 + }
2310 +
2311 + let caption_y = top + art_h;
2312 + let caption = Paragraph::new(Line::from(Span::styled(
2313 + "siGit Code & Deploy",
2314 + Style::default().fg(splash::fade(splash::MUTED, vis * (ms / 300.0).min(1.0), tc)),
2315 + )))
2316 + .alignment(ratatui::layout::Alignment::Center);
2317 + frame.render_widget(caption, rect(caption_y, 1));
2318 +
2319 + // The checklist fades in once the flame has caught.
2320 + let shown = vis * ((ms - 700.0) / 300.0).clamp(0.0, 1.0);
2321 + if shown <= 0.0 {
2322 + return;
2323 + }
2324 + let spin = SPINNER[(ms as usize / 100) % SPINNER.len()];
2325 + let rows: Vec<(String, Line)> = app
2326 + .checks
2327 + .rows()
2328 + .into_iter()
2329 + .map(|(label, check)| {
2330 + let (glyph, detail, glyph_rgb, detail_rgb) = match check {
2331 + Check::Pending => (spin, "…".to_string(), splash::EMBER, splash::MUTED),
2332 + Check::Ok(d) => ("✓", d.clone(), splash::INK, splash::INK),
2333 + Check::Note(d) => ("·", d.clone(), splash::MUTED, splash::MUTED),
2334 + Check::Warn(d) => ("!", d.clone(), splash::EMBER, splash::EMBER),
2335 + };
2336 + let line = Line::from(vec![
2337 + Span::styled(
2338 + format!("{glyph} "),
2339 + Style::default().fg(splash::fade(glyph_rgb, shown, tc)),
2340 + ),
2341 + Span::styled(
2342 + format!("{label:<9} "),
2343 + Style::default().fg(splash::fade(splash::MUTED, shown, tc)),
2344 + ),
2345 + Span::styled(
2346 + detail.clone(),
2347 + Style::default().fg(splash::fade(detail_rgb, shown, tc)),
2348 + ),
2349 + ]);
2350 + (format!("{glyph} {label:<9} {detail}"), line)
2351 + })
2352 + .collect();
2353 + // One left edge for every line, centred as a block.
2354 + let width = rows
2355 + .iter()
2356 + .map(|(plain, _)| plain.chars().count())
2357 + .max()
2358 + .unwrap_or(0) as u16;
2359 + let x = area.x + area.width.saturating_sub(width) / 2;
2360 + for (i, (_, line)) in rows.into_iter().enumerate() {
2361 + let y = caption_y + 2 + i as u16;
2362 + if y >= area.y + area.height {
2363 + break;
2364 + }
2365 + let r = ratatui::layout::Rect::new(x, y, area.width.saturating_sub(x - area.x), 1);
2366 + frame.render_widget(Paragraph::new(line), r);
2367 + }
2368 + }
2369 +
2370 fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2371 const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
2252 - let spin = SPINNER[(app.load_tick as usize) % SPINNER.len()];
2372 + let spin = SPINNER[(app.load_tick as usize / 3) % SPINNER.len()];
2373 let label = format!(" siGit {} loading {}… ", spin, app.load_model_name);
2374 let line = Line::from(Span::styled(
2375 label,
@@ -3528,15 +3648,16 @@ mod tui {
3648 {
3649 let mut app = App::new(load_model_name, backend, program_status);
3650 let mut event_stream = EventStream::new();
3651 + let mut startup_rx = startup::spawn();
3652
3532 - // 10 fps is plenty for spinners
3533 - let mut ticker = interval(Duration::from_millis(100));
3653 + // ~30 fps while the splash plays; the loading phase is the only user
3654 + let mut ticker = interval(Duration::from_millis(33));
3655
3656 loop {
3657 // ── Poll the loader channel (non-blocking) ────────────────────────
3658 if app.is_loading {
3659 match load_rx.try_recv() {
3539 - Ok(Ok(())) => app.finish_loading(),
3660 + Ok(Ok(())) => app.load_ready = true,
3661 Ok(Err(e)) => app.set_load_error(e),
3662 Err(std_mpsc::TryRecvError::Empty) => {}
3663 Err(std_mpsc::TryRecvError::Disconnected) => {
@@ -3545,6 +3666,24 @@ mod tui {
3666 }
3667 }
3668
3669 + // Startup checks report as they finish, and keep reporting after a
3670 + // skip: the picker and the Repo tab fill in behind the chat.
3671 + while let Ok(report) = startup_rx.try_recv() {
3672 + app.apply_startup(report);
3673 + }
3674 + // The splash leaves once the backend is up and every check has
3675 + // reported (and never before its minimum), or on a key press.
3676 + if app.is_loading
3677 + && app.load_ready
3678 + && app.load_error.is_none()
3679 + && app
3680 + .splash
3681 + .update(std::time::Instant::now(), app.checks.all_done())
3682 + == Phase::Done
3683 + {
3684 + app.finish_loading();
3685 + }
3686 +
3687 // redraw every iteration
3688 terminal.draw(|frame| render(frame, &mut app))?;
3689
@@ -3637,7 +3776,7 @@ mod tui {
3776 tokio::select! {
3777 biased;
3778
3640 - // ── Spinner tick (loading phase only) ─────────────────────────
3779 + // ── Splash / spinner tick (loading phase only) ────────────────
3780 _ = ticker.tick(), if app.is_loading => {
3781 app.tick();
3782 }
@@ -3800,6 +3939,9 @@ mod tui {
3939 || key.code == KeyCode::Char('d'))
3940 {
3941 app.quit = true;
3942 + } else {
3943 + // any other key skips the splash
3944 + app.splash.skip();
3945 }
3946 }
3947 continue;
crates/cli/src/main.rs
+7 -3
@@ -51,6 +51,10 @@ mod session_store;
51 mod settings;
52 mod setup;
53 mod skills;
54 +#[cfg(unix)]
55 +mod splash;
56 +#[cfg(unix)]
57 +mod startup;
58 mod subagents;
59 mod tools;
60 mod workspace;
@@ -6922,9 +6926,9 @@ async fn main() -> anyhow::Result<()> {
6926 let (tty, cleanup_tty) = redirect_output_to_log()?;
6927 init_logging(true);
6928 setup::setup_shared_model_cache();
6925 - // Best-effort: discover MCP servers (incl. the official one) before
6926 - // the first turn so their tools are offered to the model.
6927 - mcp::init().await;
6929 + // MCP discovery (incl. the official server) runs behind the launch
6930 + // splash (`startup.rs`), so a slow server never leaves the terminal
6931 + // blank before the first frame.
6932 run_interactive(tty, cleanup_tty).await
6933 }
6934 #[cfg(not(unix))]
crates/cli/src/mcp.rs
+19
@@ -979,6 +979,25 @@ pub fn status_summary() -> String {
979 summarize_servers(&mcp.servers, &session.0)
980 }
981
982 +/// Counts for the launch checklist: configured startup servers, the tools they
983 +/// expose, and how many could not be reached. `None` before [`init`] has run.
984 +#[cfg(unix)]
985 +pub struct Overview {
986 + pub servers: usize,
987 + pub tools: usize,
988 + pub unavailable: usize,
989 +}
990 +
991 +#[cfg(unix)]
992 +pub fn overview() -> Option<Overview> {
993 + let mcp = MCP.get()?;
994 + Some(Overview {
995 + servers: mcp.servers.len(),
996 + tools: mcp.servers.iter().map(|s| s.tools.len()).sum(),
997 + unavailable: mcp.servers.iter().filter(|s| s.error.is_some()).count(),
998 + })
999 +}
1000 +
1001 /// The `/mcp` listing for the startup servers followed by the ones the live
1002 /// session's client supplied, which are marked as such.
1003 fn summarize_servers(startup: &[ServerConn], session: &[ServerConn]) -> String {
crates/cli/src/splash.rs new
+552
@@ -0,0 +1,552 @@
1 +//! Launch splash for the interactive terminal: the banner as an "ember
2 +//! wordmark". The letters of "siGit" are the blank cells in the art and the
3 +//! digits are a flame streaming off behind them, so the word faces right and
4 +//! the trail runs to its left. This is the same animation the siGit app plays
5 +//! at launch (`src/components/splash` in sigit-app); keep the timeline and the
6 +//! art in sync with it.
7 +//!
8 +//! Everything here is a pure function of elapsed time, so the frames can be
9 +//! tested without a terminal. The splash is not decoration: it stays up until
10 +//! the startup checks in `startup.rs` have reported, and never less than
11 +//! [`MIN_SHOW`].
12 +
13 +use std::time::{Duration, Instant};
14 +
15 +use ratatui::buffer::Buffer;
16 +use ratatui::layout::Rect;
17 +use ratatui::style::{Color, Style};
18 +use ratatui::widgets::Widget;
19 +
20 +/// The siGit Code banner. The blank cells in rows 2-11 are the letters; the
21 +/// digits are the flame. Mirrored in sigit-app's `bannerArt.ts`.
22 +pub(crate) const BANNER_ART: &str = "\
23 +77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777
24 +77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777
25 +1111111125555555555555555555555511113222311159 5002 088 3081771691111111111111
26 +1111111111111111111111111111131136841 1482853332007 05 9043332891 400811111111111
27 +1111111111111111111111111111111201 109 304 40 00 79 100041111111111
28 +333333255555555555555555555552392 102 503 90 7000000005 903 0000023333333333
29 +333333245454545454545454545433381 7600000 302 61 780 109 20009533333333333
30 +3333333333333333333333333333333402 7001 08 761 202 902 90003333333333333
31 +2222255555555555555555555555250899901 49 304 403 08 108 300042222222222222
32 +2222222222222222222222222222269 106 03 901 06 505 402 000052222222222222
33 +2222255555555555555555555555299 708 1002 80 00 90852222222222222
34 +55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555
35 +88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888";
36 +
37 +/// The splash never leaves before this, even when every check is instant.
38 +pub(crate) const MIN_SHOW: Duration = Duration::from_millis(1400);
39 +/// Contract and fade at the end.
40 +const EXIT: Duration = Duration::from_millis(350);
41 +/// A check that never reports must not hold the terminal hostage.
42 +pub(crate) const HARD_CAP: Duration = Duration::from_millis(4000);
43 +
44 +// Graphite canvas and one ember ramp, the same stops as the app's design tokens
45 +// (`--surface-950`, `--surface-600`, `--brand-600`, `--accent`, `--brand-400`,
46 +// `--ink-strong`, `--ink-muted`).
47 +type Rgb = [u8; 3];
48 +const BG: Rgb = [0x12, 0x14, 0x17];
49 +const RAMP: [Rgb; 4] = [
50 + [0x2d, 0x32, 0x38],
51 + [0xe8, 0x5d, 0x28],
52 + [0xfe, 0x6d, 0x36],
53 + [0xff, 0x8f, 0x5a],
54 +];
55 +pub(crate) const INK: Rgb = [0xf3, 0xf4, 0xf6];
56 +pub(crate) const MUTED: Rgb = [0x6b, 0x72, 0x80];
57 +pub(crate) const EMBER: Rgb = RAMP[2];
58 +
59 +/// Heat per digit. 0 and 9 are the densest in the art, 7 and 1 the thinnest.
60 +const HEAT: [f32; 10] = [1.0, 0.2, 0.45, 0.55, 0.5, 0.4, 0.65, 0.15, 0.8, 1.0];
61 +
62 +#[derive(Clone, Copy)]
63 +struct Cell {
64 + x: u16,
65 + y: u16,
66 + letter: bool,
67 + heat: f32,
68 +}
69 +
70 +/// The banner parsed into cells.
71 +pub(crate) struct Banner {
72 + cols: u16,
73 + rows: u16,
74 + cells: Vec<Cell>,
75 + letter_min_x: u16,
76 + letter_max_x: u16,
77 +}
78 +
79 +impl Banner {
80 + pub(crate) fn parse() -> Self {
81 + let lines: Vec<&str> = BANNER_ART.lines().collect();
82 + let cols = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16;
83 + let mut cells = Vec::new();
84 + let (mut letter_min_x, mut letter_max_x) = (cols, 0);
85 + for (y, line) in lines.iter().enumerate() {
86 + let chars: Vec<char> = line.chars().collect();
87 + for x in 0..cols {
88 + match chars.get(x as usize).copied().unwrap_or(' ') {
89 + ' ' => {
90 + if !(2..=11).contains(&y) {
91 + continue;
92 + }
93 + cells.push(Cell {
94 + x,
95 + y: y as u16,
96 + letter: true,
97 + heat: 0.0,
98 + });
99 + letter_min_x = letter_min_x.min(x);
100 + letter_max_x = letter_max_x.max(x);
101 + }
102 + ch => {
103 + let heat = ch.to_digit(10).map_or(0.5, |d| HEAT[d as usize]);
104 + cells.push(Cell {
105 + x,
106 + y: y as u16,
107 + letter: false,
108 + heat,
109 + });
110 + }
111 + }
112 + }
113 + }
114 + Self {
115 + cols,
116 + rows: lines.len() as u16,
117 + cells,
118 + letter_min_x,
119 + letter_max_x,
120 + }
121 + }
122 +
123 + /// First column to draw for a terminal `width` cells wide. A narrow
124 + /// terminal keeps the word and a short tail and drops the rest of the
125 + /// trail. `None` when even the word does not fit.
126 + fn start_col(&self, width: u16) -> Option<u16> {
127 + let word = self.letter_max_x - self.letter_min_x + 1;
128 + if width < word + 2 {
129 + return None;
130 + }
131 + if width >= self.cols {
132 + return Some(0);
133 + }
134 + // Keep a margin of flame on the right of the word, then spend the rest
135 + // on the trail.
136 + let right_edge = (self.letter_max_x + 1 + 4).min(self.cols);
137 + Some(right_edge.saturating_sub(width).min(self.letter_min_x))
138 + }
139 +
140 + /// Cells the wordmark occupies in a terminal of `width`, or `None` when it
141 + /// is too narrow to draw it.
142 + pub(crate) fn size(&self, width: u16) -> Option<(u16, u16)> {
143 + let start = self.start_col(width)?;
144 + Some(((self.cols - start).min(width), self.rows))
145 + }
146 +}
147 +
148 +fn clamp01(v: f32) -> f32 {
149 + v.clamp(0.0, 1.0)
150 +}
151 +
152 +fn ease_out(v: f32) -> f32 {
153 + 1.0 - (1.0 - v).powi(3)
154 +}
155 +
156 +/// Cheap value noise: a sin-hash of the cell and time, in 0..1.
157 +fn noise(x: u16, y: u16, t_ms: f32) -> f32 {
158 + let s = (x as f32 * 12.9898 + y as f32 * 78.233 + t_ms * 0.009).sin() * 43758.547;
159 + s - s.floor()
160 +}
161 +
162 +fn mix(a: Rgb, b: Rgb, k: f32) -> Rgb {
163 + let k = clamp01(k);
164 + [0, 1, 2].map(|i| (a[i] as f32 + (b[i] as f32 - a[i] as f32) * k).round() as u8)
165 +}
166 +
167 +fn ramp(heat: f32) -> Rgb {
168 + let f = clamp01(heat) * (RAMP.len() - 1) as f32;
169 + let i = (f.floor() as usize).min(RAMP.len() - 2);
170 + mix(RAMP[i], RAMP[i + 1], f - i as f32)
171 +}
172 +
173 +/// Whether the terminal can show the ember ramp as 24-bit colour.
174 +pub(crate) fn truecolor_supported() -> bool {
175 + std::env::var("COLORTERM")
176 + .map(|v| v.contains("truecolor") || v.contains("24bit"))
177 + .unwrap_or(false)
178 +}
179 +
180 +/// An `Rgb` as a ratatui colour: exact on a truecolor terminal, the nearest
181 +/// cell of the 256-colour cube otherwise.
182 +pub(crate) fn color(rgb: Rgb, truecolor: bool) -> Color {
183 + if truecolor {
184 + return Color::Rgb(rgb[0], rgb[1], rgb[2]);
185 + }
186 + let level = |v: u8| ((v as u16 * 5 + 127) / 255) as u8;
187 + Color::Indexed(16 + 36 * level(rgb[0]) + 6 * level(rgb[1]) + level(rgb[2]))
188 +}
189 +
190 +/// `rgb` faded toward the canvas by `1 - k`, as a ratatui colour.
191 +pub(crate) fn fade(rgb: Rgb, k: f32, truecolor: bool) -> Color {
192 + color(mix(BG, rgb, k), truecolor)
193 +}
194 +
195 +/// One frame of the wordmark. `t` is time since the splash started and `exit`
196 +/// the exit progress, 0 to 1. `still` freezes the flicker for the static frame
197 +/// used under reduced motion.
198 +pub(crate) struct Wordmark<'a> {
199 + pub banner: &'a Banner,
200 + pub t: Duration,
201 + pub exit: f32,
202 + pub still: bool,
203 + pub truecolor: bool,
204 +}
205 +
206 +impl Widget for Wordmark<'_> {
207 + fn render(self, area: Rect, buf: &mut Buffer) {
208 + let b = self.banner;
209 + let Some(start) = b.start_col(area.width) else {
210 + return;
211 + };
212 + let visible = (b.cols - start).min(area.width);
213 + let ox = area.x + (area.width - visible) / 2;
214 + let t = self.t.as_secs_f32() * 1000.0;
215 + let e = clamp01(self.exit);
216 +
217 + // 1. Grid: every cell fades in as a faint dot.
218 + let grid = clamp01(t / 300.0);
219 + // 2. Ignition: the front sweeps from the right edge to the left.
220 + let sweep = clamp01((t - 250.0) / 750.0);
221 + // 3. Word: letters resolve left to right, then settle one cell right.
222 + let settle = (1.0 - ease_out(clamp01((t - 600.0) / 500.0))).round() as i32;
223 + // 4. Hold: the flame keeps breathing, quieter.
224 + let breathe = if t > 1100.0 { 0.4 } else { 1.0 };
225 + let span = (b.letter_max_x - b.letter_min_x).max(1) as f32;
226 + let mid = (b.letter_min_x + b.letter_max_x) as f32 / 2.0;
227 +
228 + let mut put = |x: i32, y: u16, glyph: &str, rgb: Rgb| {
229 + let (min, max) = (ox as i32, (ox + visible) as i32);
230 + if x < min || x >= max || y >= area.height {
231 + return;
232 + }
233 + if let Some(cell) = buf.cell_mut((x as u16, area.y + y)) {
234 + cell.set_symbol(glyph)
235 + .set_style(Style::default().fg(color(rgb, self.truecolor)));
236 + }
237 + };
238 +
239 + for c in &b.cells {
240 + if c.x < start || c.x - start >= visible {
241 + continue;
242 + }
243 + let gx = ox as i32 + (c.x - start) as i32;
244 +
245 + if c.letter {
246 + put(gx, c.y, "\u{b7}", mix(BG, RAMP[0], 0.9 * grid * (1.0 - e)));
247 + let reveal =
248 + clamp01((t - (600.0 + (c.x - b.letter_min_x) as f32 / span * 350.0)) / 150.0);
249 + if reveal > 0.0 {
250 + put(
251 + gx - settle,
252 + c.y,
253 + "\u{2588}",
254 + mix(BG, INK, reveal * (1.0 - e)),
255 + );
256 + }
257 + continue;
258 + }
259 +
260 + let d = (b.cols - 1 - c.x) as f32 / (b.cols - 1) as f32;
261 + let lit = clamp01((sweep * 1.25 - d) * 5.0);
262 + // Flicker is strongest on the trail, weakest beside the word.
263 + let away = clamp01((b.letter_min_x as f32 - c.x as f32) / 25.0);
264 + let amp = (0.12 + 0.38 * away) * breathe;
265 + let trail = if c.x < b.letter_min_x { 0.7 } else { 1.0 };
266 + let n = if self.still { 0.5 } else { noise(c.x, c.y, t) };
267 + let heat = clamp01(c.heat * trail * lit + (n - 0.5) * 2.0 * amp * lit);
268 +
269 + if lit <= 0.0 {
270 + put(gx, c.y, "\u{b7}", mix(BG, RAMP[0], 0.9 * grid * (1.0 - e)));
271 + continue;
272 + }
273 + // Exit: the flame contracts toward the word and dims.
274 + let shift = ((mid - c.x as f32) * 0.3 * ease_out(e)).round() as i32;
275 + let glyph = if heat < 0.35 {
276 + "\u{2591}"
277 + } else if heat < 0.65 {
278 + "\u{2592}"
279 + } else {
280 + "\u{2593}"
281 + };
282 + put(
283 + gx + shift,
284 + c.y,
285 + glyph,
286 + mix(BG, ramp(heat), lit * (0.55 + 0.45 * heat) * (1.0 - e)),
287 + );
288 + }
289 + }
290 +}
291 +
292 +/// Where the splash is in its life.
293 +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
294 +pub(crate) enum Phase {
295 + Running,
296 + Done,
297 +}
298 +
299 +/// The splash's clock: when it started, whether it has begun to leave, and
300 +/// whether the user asked it to go.
301 +pub(crate) struct Splash {
302 + start: Instant,
303 + exit_start: Option<Instant>,
304 + skipped: bool,
305 + reduced: bool,
306 + pub(crate) banner: Banner,
307 + pub(crate) truecolor: bool,
308 +}
309 +
310 +impl Splash {
311 + pub(crate) fn new(now: Instant) -> Self {
312 + Self {
313 + start: now,
314 + exit_start: None,
315 + skipped: false,
316 + reduced: reduced_motion(),
317 + banner: Banner::parse(),
318 + truecolor: truecolor_supported(),
319 + }
320 + }
321 +
322 + /// A key press: leave now, whether or not the checks are in.
323 + pub(crate) fn skip(&mut self) {
324 + self.skipped = true;
325 + }
326 +
327 + pub(crate) fn reduced(&self) -> bool {
328 + self.reduced
329 + }
330 +
331 + pub(crate) fn elapsed(&self, now: Instant) -> Duration {
332 + now.saturating_duration_since(self.start)
333 + }
334 +
335 + /// Exit progress, 0 while running and 1 once gone.
336 + pub(crate) fn exit_progress(&self, now: Instant) -> f32 {
337 + match self.exit_start {
338 + None => 0.0,
339 + Some(_) if self.reduced => 1.0,
340 + Some(at) => {
341 + clamp01(now.saturating_duration_since(at).as_secs_f32() / EXIT.as_secs_f32())
342 + }
343 + }
344 + }
345 +
346 + /// Advance the clock. `ready` is true once every startup check has
347 + /// reported. The splash starts leaving when it is ready and the minimum
348 + /// time has passed, when the user skips, or at the hard cap.
349 + pub(crate) fn update(&mut self, now: Instant, ready: bool) -> Phase {
350 + let elapsed = self.elapsed(now);
351 + let min = if self.reduced {
352 + Duration::ZERO
353 + } else {
354 + MIN_SHOW
355 + };
356 + if self.exit_start.is_none()
357 + && (self.skipped || (ready && elapsed >= min) || elapsed >= HARD_CAP)
358 + {
359 + self.exit_start = Some(now);
360 + }
361 + if self.exit_progress(now) >= 1.0 {
362 + Phase::Done
363 + } else {
364 + Phase::Running
365 + }
366 + }
367 +}
368 +
369 +/// `SIGIT_REDUCE_MOTION` set to anything but `0` or empty: draw the finished
370 +/// frame and leave as soon as the checks are in.
371 +fn reduced_motion() -> bool {
372 + std::env::var("SIGIT_REDUCE_MOTION").is_ok_and(|v| !v.is_empty() && v != "0")
373 +}
374 +
375 +#[cfg(test)]
376 +mod tests {
377 + use super::*;
378 +
379 + fn frame(banner: &Banner, width: u16, ms: u64, exit: f32) -> Buffer {
380 + let area = Rect::new(0, 0, width, banner.rows);
381 + let mut buf = Buffer::empty(area);
382 + Wordmark {
383 + banner,
384 + t: Duration::from_millis(ms),
385 + exit,
386 + still: false,
387 + truecolor: true,
388 + }
389 + .render(area, &mut buf);
390 + buf
391 + }
392 +
393 + fn lit(buf: &Buffer, x0: u16, x1: u16) -> usize {
394 + (0..buf.area.height)
395 + .flat_map(|y| (x0..x1).map(move |x| (x, y)))
396 + .filter(|&(x, y)| matches!(buf[(x, y)].symbol(), "\u{2591}" | "\u{2592}" | "\u{2593}"))
397 + .count()
398 + }
399 +
400 + #[test]
401 + fn banner_is_the_documented_grid() {
402 + let b = Banner::parse();
403 + assert_eq!((b.cols, b.rows), (92, 13));
404 + assert_eq!((b.letter_min_x, b.letter_max_x), (31, 76));
405 + assert!(
406 + b.cells
407 + .iter()
408 + .filter(|c| c.letter)
409 + .all(|c| (2..=11).contains(&c.y))
410 + );
411 + }
412 +
413 + #[test]
414 + fn ignition_runs_from_right_to_left() {
415 + let b = Banner::parse();
416 + let buf = frame(&b, 92, 500, 0.0);
417 + assert!(lit(&buf, 66, 92) > lit(&buf, 0, 26) * 3);
418 + }
419 +
420 + #[test]
421 + fn letters_are_ink_once_revealed_and_absent_before() {
422 + let b = Banner::parse();
423 + let ink = |buf: &Buffer| {
424 + (0..92)
425 + .flat_map(|x| (0..13).map(move |y| (x, y)))
426 + .filter(|&(x, y)| buf[(x, y)].symbol() == "\u{2588}")
427 + .count()
428 + };
429 + assert_eq!(ink(&frame(&b, 92, 400, 0.0)), 0);
430 + let letters = b.cells.iter().filter(|c| c.letter).count();
431 + let settled = ink(&frame(&b, 92, 1600, 0.0));
432 + assert!(settled >= letters - 13, "{settled} of {letters}");
433 + }
434 +
435 + #[test]
436 + fn exit_removes_everything() {
437 + let b = Banner::parse();
438 + let buf = frame(&b, 92, 1600, 1.0);
439 + let bg = color(BG, true);
440 + for y in 0..13 {
441 + for x in 0..92 {
442 + let c = &buf[(x, y)];
443 + if c.symbol() != " " {
444 + assert_eq!(c.fg, bg, "({x},{y}) {:?}", c.symbol());
445 + }
446 + }
447 + }
448 + }
449 +
450 + #[test]
451 + fn narrow_terminals_crop_the_trail_and_tiny_ones_drop_the_art() {
452 + let b = Banner::parse();
453 + assert_eq!(b.start_col(92), Some(0));
454 + assert_eq!(b.start_col(120), Some(0));
455 + let start = b.start_col(80).unwrap();
456 + assert!(start > 0 && start <= b.letter_min_x);
457 + // The word still fits.
458 + assert!(b.letter_max_x - start < 80);
459 + assert_eq!(b.size(50), Some((50, 13)));
460 + assert_eq!(b.start_col(40), None);
461 + }
462 +
463 + #[test]
464 + fn frames_do_not_write_outside_the_area() {
465 + let b = Banner::parse();
466 + for width in [48, 60, 80, 92, 140] {
467 + let area = Rect::new(3, 2, width, 13);
468 + let mut buf = Buffer::empty(Rect::new(0, 0, width + 10, 20));
469 + Wordmark {
470 + banner: &b,
471 + t: Duration::from_millis(1500),
472 + exit: 0.0,
473 + still: false,
474 + truecolor: false,
475 + }
476 + .render(area, &mut buf);
477 + for y in 0..20 {
478 + for x in 0..width + 10 {
479 + if !(area.x..area.x + area.width).contains(&x)
480 + || !(area.y..area.y + 13).contains(&y)
481 + {
482 + assert_eq!(buf[(x, y)].symbol(), " ", "({x},{y})");
483 + }
484 + }
485 + }
486 + }
487 + }
488 +
489 + fn splash(reduced: bool) -> (Splash, Instant) {
490 + let t0 = Instant::now();
491 + let mut s = Splash::new(t0);
492 + s.reduced = reduced;
493 + (s, t0)
494 + }
495 +
496 + #[test]
497 + fn stays_for_the_minimum_even_when_ready() {
498 + let (mut s, t0) = splash(false);
499 + assert_eq!(
500 + s.update(t0 + Duration::from_millis(1000), true),
501 + Phase::Running
502 + );
503 + assert_eq!(s.exit_progress(t0 + Duration::from_millis(1000)), 0.0);
504 + let at = t0 + MIN_SHOW;
505 + assert_eq!(s.update(at, true), Phase::Running);
506 + assert_eq!(s.update(at + EXIT, true), Phase::Done);
507 + }
508 +
509 + #[test]
510 + fn waits_for_checks_then_gives_up_at_the_cap() {
511 + let (mut s, t0) = splash(false);
512 + assert_eq!(
513 + s.update(t0 + Duration::from_millis(3900), false),
514 + Phase::Running
515 + );
516 + assert_eq!(s.exit_progress(t0 + Duration::from_millis(3900)), 0.0);
517 + let at = t0 + HARD_CAP;
518 + s.update(at, false);
519 + assert_eq!(s.update(at + EXIT, false), Phase::Done);
520 + }
521 +
522 + #[test]
523 + fn a_key_press_leaves_without_waiting() {
524 + let (mut s, t0) = splash(false);
525 + s.skip();
526 + s.update(t0 + Duration::from_millis(100), false);
527 + assert_eq!(
528 + s.update(t0 + Duration::from_millis(450), false),
529 + Phase::Done
530 + );
531 + }
532 +
533 + #[test]
534 + fn reduced_motion_leaves_as_soon_as_the_checks_are_in() {
535 + let (mut s, t0) = splash(true);
536 + assert_eq!(
537 + s.update(t0 + Duration::from_millis(10), false),
538 + Phase::Running
539 + );
540 + assert_eq!(s.update(t0 + Duration::from_millis(20), true), Phase::Done);
541 + }
542 +
543 + #[test]
544 + fn colour_falls_back_to_the_256_cube() {
545 + assert_eq!(
546 + color([0xfe, 0x6d, 0x36], true),
547 + Color::Rgb(0xfe, 0x6d, 0x36)
548 + );
549 + assert_eq!(color([0, 0, 0], false), Color::Indexed(16));
550 + assert_eq!(color([255, 255, 255], false), Color::Indexed(231));
551 + }
552 +}
crates/cli/src/startup.rs new
+238
@@ -0,0 +1,238 @@
1 +//! The checks the launch splash waits on. Each runs on its own task and
2 +//! reports as soon as it has an answer, so a slow network never holds up the
3 +//! model scan and the splash can show them landing one by one.
4 +//!
5 +//! None of this blocks the first frame: the scan of the model caches and the
6 +//! `git` call used to run inside `App::new`, before anything was drawn.
7 +
8 +use std::time::Duration;
9 +
10 +use tokio::sync::mpsc;
11 +
12 +use crate::account::{self, SessionState};
13 +use crate::models::{ModelCacheHealth, ModelPickerItem, build_model_picker_items};
14 +
15 +/// How long the account check may spend on the network.
16 +const ACCOUNT_TIMEOUT: Duration = Duration::from_millis(2500);
17 +/// How long `git remote get-url` may take before the workspace goes unnamed.
18 +const GIT_TIMEOUT: Duration = Duration::from_millis(1500);
19 +
20 +/// One check's answer.
21 +pub(crate) enum Report {
22 + Account(SessionState),
23 + Models {
24 + items: Vec<ModelPickerItem>,
25 + /// The saved on-device selection, if it resolves to a known model.
26 + selected: Option<String>,
27 + },
28 + /// `owner/name` of the repository on the sigit.si host, when there is one.
29 + Workspace(Option<String>),
30 + /// MCP discovery finished. `None` if it left no state behind.
31 + Tools(Option<crate::mcp::Overview>),
32 +}
33 +
34 +/// Start every check and return the channel they report on.
35 +pub(crate) fn spawn() -> mpsc::UnboundedReceiver<Report> {
36 + let (tx, rx) = mpsc::unbounded_channel();
37 +
38 + let account_tx = tx.clone();
39 + tokio::spawn(async move {
40 + let state = account::check_session(ACCOUNT_TIMEOUT).await;
41 + let _ = account_tx.send(Report::Account(state));
42 + });
43 +
44 + let models_tx = tx.clone();
45 + tokio::task::spawn_blocking(move || {
46 + let items = build_model_picker_items();
47 + let selected = crate::setup::load_selected_model_name();
48 + let _ = models_tx.send(Report::Models { items, selected });
49 + });
50 +
51 + // MCP discovery used to run before the terminal was set up, so a slow
52 + // server meant a blank screen for up to its handshake timeout. It runs here
53 + // instead, and a turn started before it finishes simply lacks those tools.
54 + let tools_tx = tx.clone();
55 + tokio::spawn(async move {
56 + crate::mcp::init().await;
57 + let _ = tools_tx.send(Report::Tools(crate::mcp::overview()));
58 + });
59 +
60 + tokio::spawn(async move {
61 + let _ = tx.send(Report::Workspace(detect_repo().await));
62 + });
63 +
64 + rx
65 +}
66 +
67 +/// `owner/name` when the current directory's `origin` remote lives on the
68 +/// sigit.si host (per `SIGIT_API_URL`). Any failure (no git, no repo, no
69 +/// origin, a hung `git`) is `None`.
70 +async fn detect_repo() -> Option<String> {
71 + let output = tokio::time::timeout(
72 + GIT_TIMEOUT,
73 + tokio::process::Command::new("git")
74 + .args(["remote", "get-url", "origin"])
75 + .kill_on_drop(true)
76 + .output(),
77 + )
78 + .await
79 + .ok()?
80 + .ok()?;
81 + if !output.status.success() {
82 + return None;
83 + }
84 + let url = String::from_utf8_lossy(&output.stdout);
85 + crate::chat::parse_repo_remote(url.trim(), &crate::chat::sigit_host())
86 +}
87 +
88 +/// What one line of the launch checklist says.
89 +#[derive(Clone, Debug, PartialEq, Eq)]
90 +pub(crate) enum Check {
91 + Pending,
92 + /// Fine, and worth a few words.
93 + Ok(String),
94 + /// Nothing wrong, nothing to report either (not signed in, no models yet).
95 + Note(String),
96 + /// Needs the user's attention.
97 + Warn(String),
98 +}
99 +
100 +impl Check {
101 + pub(crate) fn is_done(&self) -> bool {
102 + !matches!(self, Check::Pending)
103 + }
104 +}
105 +
106 +/// How many lines the checklist has.
107 +pub(crate) const CHECK_ROWS: usize = 4;
108 +
109 +/// The lines under the wordmark.
110 +#[derive(Clone, Debug, PartialEq, Eq)]
111 +pub(crate) struct Checks {
112 + pub account: Check,
113 + pub models: Check,
114 + pub workspace: Check,
115 + pub tools: Check,
116 +}
117 +
118 +impl Checks {
119 + pub(crate) fn new() -> Self {
120 + Self {
121 + account: Check::Pending,
122 + models: Check::Pending,
123 + workspace: Check::Pending,
124 + tools: Check::Pending,
125 + }
126 + }
127 +
128 + /// Every check has reported.
129 + pub(crate) fn all_done(&self) -> bool {
130 + self.account.is_done()
131 + && self.models.is_done()
132 + && self.workspace.is_done()
133 + && self.tools.is_done()
134 + }
135 +
136 + pub(crate) fn rows(&self) -> [(&'static str, &Check); CHECK_ROWS] {
137 + [
138 + ("Account", &self.account),
139 + ("Models", &self.models),
140 + ("Workspace", &self.workspace),
141 + ("Tools", &self.tools),
142 + ]
143 + }
144 +}
145 +
146 +pub(crate) fn account_check(state: &SessionState) -> Check {
147 + match state {
148 + SessionState::SignedOut => Check::Note("not signed in".into()),
149 + SessionState::Valid { email } => Check::Ok(email.clone()),
150 + SessionState::Offline { email } => Check::Ok(format!("{email} (offline)")),
151 + SessionState::Expired { .. } => Check::Warn("session expired, run /login".into()),
152 + }
153 +}
154 +
155 +pub(crate) fn models_check(items: &[ModelPickerItem]) -> Check {
156 + let ready = items
157 + .iter()
158 + .filter(|item| item.cloud_tier.is_none() && item.cache_health == ModelCacheHealth::Complete)
159 + .count();
160 + match ready {
161 + 0 => Check::Note("none on device, /models to get one".into()),
162 + 1 => Check::Ok("1 on device".into()),
163 + n => Check::Ok(format!("{n} on device")),
164 + }
165 +}
166 +
167 +pub(crate) fn tools_check(overview: Option<&crate::mcp::Overview>) -> Check {
168 + match overview {
169 + None => Check::Note("not available".into()),
170 + Some(o) if o.servers == 0 => Check::Note("no MCP servers configured".into()),
171 + Some(o) if o.unavailable == 0 => {
172 + Check::Ok(format!("{} from {} MCP server(s)", o.tools, o.servers))
173 + }
174 + Some(o) => Check::Warn(format!(
175 + "{} MCP server(s) unavailable, see /mcp ({} tools)",
176 + o.unavailable, o.tools
177 + )),
178 + }
179 +}
180 +
181 +pub(crate) fn workspace_check(repo: Option<&str>) -> Check {
182 + match repo {
183 + Some(repo) => Check::Ok(repo.to_string()),
184 + None => Check::Note(
185 + std::env::current_dir()
186 + .ok()
187 + .and_then(|dir| dir.file_name().map(|n| n.to_string_lossy().into_owned()))
188 + .unwrap_or_else(|| "no repository".into()),
189 + ),
190 + }
191 +}
192 +
193 +#[cfg(test)]
194 +mod tests {
195 + use super::*;
196 +
197 + #[test]
198 + fn checks_are_done_only_when_every_one_has_reported() {
199 + let mut checks = Checks::new();
200 + assert!(!checks.all_done());
201 + checks.account = Check::Note("not signed in".into());
202 + checks.models = Check::Ok("2 on device".into());
203 + assert!(!checks.all_done());
204 + checks.workspace = workspace_check(Some("getsigit/sigit"));
205 + assert!(!checks.all_done());
206 + checks.tools = tools_check(None);
207 + assert!(checks.all_done());
208 + }
209 +
210 + #[test]
211 + fn account_states_map_to_lines() {
212 + assert_eq!(
213 + account_check(&SessionState::Valid {
214 + email: "a@b.c".into()
215 + }),
216 + Check::Ok("a@b.c".into())
217 + );
218 + assert_eq!(
219 + account_check(&SessionState::Offline {
220 + email: "a@b.c".into()
221 + }),
222 + Check::Ok("a@b.c (offline)".into())
223 + );
224 + assert!(matches!(
225 + account_check(&SessionState::Expired { status: 401 }),
226 + Check::Warn(_)
227 + ));
228 + assert!(matches!(
229 + account_check(&SessionState::SignedOut),
230 + Check::Note(_)
231 + ));
232 + }
233 +
234 + #[test]
235 + fn no_models_is_a_note_not_a_warning() {
236 + assert!(matches!(models_check(&[]), Check::Note(_)));
237 + }
238 +}