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

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 }