sigit-code 1.7.6__tar.gz → 1.7.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {sigit_code-1.7.6 → sigit_code-1.7.7}/Cargo.lock +2 -2
  2. {sigit_code-1.7.6 → sigit_code-1.7.7}/Cargo.toml +2 -2
  3. {sigit_code-1.7.6 → sigit_code-1.7.7}/PKG-INFO +1 -1
  4. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/account.rs +43 -11
  5. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/chat.rs +153 -87
  6. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/headless.rs +73 -16
  7. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/main.rs +7 -3
  8. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/mcp.rs +19 -0
  9. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/provider.rs +24 -0
  10. sigit_code-1.7.7/crates/cli/src/splash.rs +551 -0
  11. sigit_code-1.7.7/crates/cli/src/startup.rs +238 -0
  12. {sigit_code-1.7.6 → sigit_code-1.7.7}/README.md +0 -0
  13. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/Cargo.toml +0 -0
  14. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/README.md +0 -0
  15. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/backend.rs +0 -0
  16. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/client_fs.rs +0 -0
  17. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/client_terminal.rs +0 -0
  18. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/commands.rs +0 -0
  19. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/frontmatter.rs +0 -0
  20. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/harness_markup.rs +0 -0
  21. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/hooks.rs +0 -0
  22. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/inline_tool_calls.rs +0 -0
  23. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/instructions.rs +0 -0
  24. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/models.rs +0 -0
  25. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/permissions.rs +0 -0
  26. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/program_status.rs +0 -0
  27. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/repetition.rs +0 -0
  28. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/session_store.rs +0 -0
  29. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/settings.rs +0 -0
  30. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/setup.rs +0 -0
  31. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/skills.rs +0 -0
  32. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/subagents.rs +0 -0
  33. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/tools.rs +0 -0
  34. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/src/workspace.rs +0 -0
  35. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_audio.rs +0 -0
  36. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_client_fs.rs +0 -0
  37. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_client_terminal.rs +0 -0
  38. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_cloud_reauth.rs +0 -0
  39. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_concurrent_sessions.rs +0 -0
  40. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_elicitation.rs +0 -0
  41. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_embedded_context.rs +0 -0
  42. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_endpoint_errors.rs +0 -0
  43. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_error_codes.rs +0 -0
  44. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_harness_markup.rs +0 -0
  45. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_images.rs +0 -0
  46. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_logout.rs +0 -0
  47. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_multi_root.rs +0 -0
  48. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_permissions.rs +0 -0
  49. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_session_list.rs +0 -0
  50. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_session_load.rs +0 -0
  51. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_stop_reasons.rs +0 -0
  52. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_tool_call_name.rs +0 -0
  53. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/acp_tool_stdin.rs +0 -0
  54. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/headless_mode.rs +0 -0
  55. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/cli/tests/version_flag.rs +0 -0
  56. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/sigit-agent/Cargo.toml +0 -0
  57. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/sigit-agent/README.md +0 -0
  58. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/sigit-agent/assets/sign-in-logo.png +0 -0
  59. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/sigit-agent/src/auth.rs +0 -0
  60. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/sigit-agent/src/browser_auth.rs +0 -0
  61. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/sigit-agent/src/credentials.rs +0 -0
  62. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/sigit-agent/src/lib.rs +0 -0
  63. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/sigit-agent/src/profile.rs +0 -0
  64. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/sigit-agent/src/prompt.rs +0 -0
  65. {sigit_code-1.7.6 → sigit_code-1.7.7}/crates/sigit-agent/tests/embedded.rs +0 -0
  66. {sigit_code-1.7.6 → sigit_code-1.7.7}/pyproject.toml +0 -0
@@ -5945,7 +5945,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
5945
5945
 
5946
5946
  [[package]]
5947
5947
  name = "sigit"
5948
- version = "1.7.6"
5948
+ version = "1.7.7"
5949
5949
  dependencies = [
5950
5950
  "agent-client-protocol",
5951
5951
  "anyhow",
@@ -5976,7 +5976,7 @@ dependencies = [
5976
5976
 
5977
5977
  [[package]]
5978
5978
  name = "sigit-agent"
5979
- version = "1.7.6"
5979
+ version = "1.7.7"
5980
5980
  dependencies = [
5981
5981
  "agent-client-protocol",
5982
5982
  "anyhow",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/cli", "crates/sigit-*"]
4
4
 
5
5
  [workspace.package]
6
- version = "1.7.6"
6
+ version = "1.7.7"
7
7
  edition = "2024"
8
8
  license = "Apache-2.0"
9
9
  repository = "https://github.com/getsigit/sigit"
@@ -16,7 +16,7 @@ authors = ["Seto Elkahfi <seto@sigit.si>", "paydii <193906237+paydii@users.norep
16
16
  # crates.io, so this resolves from the registry rather than the onde-ed checkout next door.
17
17
  ed-acp = { version = "1.3.0", default-features = false }
18
18
  ed-acp-testkit = "1.3.0"
19
- sigit-agent = { version = "1.7.6", default-features = false, path = "crates/sigit-agent" }
19
+ sigit-agent = { version = "1.7.7", default-features = false, path = "crates/sigit-agent" }
20
20
 
21
21
  # ACP protocol SDK
22
22
  agent-client-protocol = { version = "2.2", features = ["unstable_session_fork"] }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sigit-code
3
- Version: 1.7.6
3
+ Version: 1.7.7
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -129,14 +129,32 @@ pub async fn end_session() -> String {
129
129
  }
130
130
  }
131
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 {
134
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;
136
150
  };
137
151
 
138
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
140
158
  .get(&url)
141
159
  .bearer_auth(&creds.access_token)
142
160
  .send()
@@ -150,14 +168,28 @@ pub async fn status_line() -> String {
150
168
  .and_then(|me| me.email)
151
169
  .or(creds.email)
152
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.")
154
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 } => {
161
193
  format!("Signed in as {email} (could not reach siGit Code Cloud to verify).")
162
194
  }
163
195
  }
@@ -520,6 +520,7 @@ mod tui {
520
520
  use onde::inference::{ChatEngine, SamplingConfig};
521
521
 
522
522
  use super::{RepoItem, Tab};
523
+ use crate::account::SessionState;
523
524
  use crate::backend::{
524
525
  InferenceBackend, LocalBackend, OpenAiBackend, TokenChunk, ToolResult, ToolSpec,
525
526
  };
@@ -528,6 +529,10 @@ mod tui {
528
529
  };
529
530
  use crate::program_status::{Reporter, Status};
530
531
  use crate::session_store::SessionEntry;
532
+ use crate::splash::{self, Phase, Splash, Wordmark};
533
+ use crate::startup::{
534
+ self, Check, Checks, Report, account_check, models_check, tools_check, workspace_check,
535
+ };
531
536
  use ratatui::{
532
537
  Frame,
533
538
  layout::{Constraint, Layout, Position},
@@ -545,8 +550,6 @@ mod tui {
545
550
  User,
546
551
  Assistant,
547
552
  System,
548
- /// rainbow-colored banner art
549
- Banner,
550
553
  /// a collapsible tool-call entry (`text` holds the title line)
551
554
  Tool,
552
555
  }
@@ -596,16 +599,6 @@ mod tui {
596
599
  }
597
600
  }
598
601
 
599
- fn banner(text: impl Into<String>) -> Self {
600
- Self {
601
- role: Role::Banner,
602
- text: text.into(),
603
- think_block: None,
604
- tool_detail: None,
605
- tool_result: None,
606
- }
607
- }
608
-
609
602
  fn tool_call(title: impl Into<String>, detail: impl Into<String>) -> Self {
610
603
  Self {
611
604
  role: Role::Tool,
@@ -771,6 +764,13 @@ mod tui {
771
764
  load_error: Option<String>,
772
765
  load_start: Instant,
773
766
  load_model_name: String,
767
+ /// The backend is up. The splash still decides when the loading view ends.
768
+ load_ready: bool,
769
+ /// The launch splash and the checks it waits on.
770
+ splash: Splash,
771
+ checks: Checks,
772
+ /// What the account check found, for the welcome message.
773
+ session: Option<SessionState>,
774
774
 
775
775
  // ── Model picker state ────────────────────────────────────────────────
776
776
  show_model_picker: bool,
@@ -832,21 +832,6 @@ mod tui {
832
832
  repo_rx: Option<mpsc::UnboundedReceiver<RepoUpdate>>,
833
833
  }
834
834
 
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
-
850
835
  const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
851
836
 
852
837
  fn rich_text_spans(text: &str, base_style: Style, bold_style: Style) -> Vec<Span<'static>> {
@@ -870,21 +855,10 @@ mod tui {
870
855
  backend: Arc<dyn InferenceBackend>,
871
856
  program_status: Reporter,
872
857
  ) -> 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
- };
858
+ // The model scan, the saved selection and the repo lookup used to run
859
+ // here, before the first frame. They run behind the splash now and
860
+ // land in `apply_startup`; until then the picker is empty and the
861
+ // passed-in name stands.
888
862
  Self {
889
863
  messages: Vec::new(),
890
864
  input: String::new(),
@@ -911,11 +885,15 @@ mod tui {
911
885
  load_error: None,
912
886
  load_start: Instant::now(),
913
887
  load_model_name: load_model_name.clone(),
888
+ load_ready: false,
889
+ splash: Splash::new(std::time::Instant::now()),
890
+ checks: Checks::new(),
891
+ session: None,
914
892
  show_model_picker: false,
915
893
  model_picker_index: 0,
916
- model_picker_items: items,
917
- current_model_name,
918
- tool_calling,
894
+ model_picker_items: Vec::new(),
895
+ current_model_name: load_model_name.clone(),
896
+ tool_calling: true,
919
897
  context_tokens_used: None,
920
898
  show_thinking: false,
921
899
  tools_expanded: false,
@@ -927,7 +905,7 @@ mod tui {
927
905
  history_notice: None,
928
906
  cloud_lines: None,
929
907
  cloud_rx: None,
930
- repo: detect_sigit_repo(),
908
+ repo: None,
931
909
  repo_section: RepoSection::Issues,
932
910
  repo_issues: RepoData::NotFetched,
933
911
  repo_prs: RepoData::NotFetched,
@@ -1108,16 +1086,54 @@ mod tui {
1108
1086
  self.download_progress = Some((downloaded, expected));
1109
1087
  }
1110
1088
 
1111
- /// switch to chat phase and show the welcome banner
1089
+ /// Take one startup check's answer.
1090
+ fn apply_startup(&mut self, report: Report) {
1091
+ match report {
1092
+ Report::Account(state) => {
1093
+ self.checks.account = account_check(&state);
1094
+ self.session = Some(state);
1095
+ }
1096
+ Report::Models { items, selected } => {
1097
+ self.tool_calling = items
1098
+ .iter()
1099
+ .find(|m| m.display_name == self.load_model_name)
1100
+ .map(|m| m.tool_calling)
1101
+ .unwrap_or(true);
1102
+ // For a remote provider the passed-in name is authoritative; the
1103
+ // persisted local selection must not override it (or the title
1104
+ // would show an on-device model while requests go to the cloud).
1105
+ if !self.backend.is_remote()
1106
+ && let Some(name) = selected
1107
+ {
1108
+ self.current_model_name = name;
1109
+ }
1110
+ self.checks.models = models_check(&items);
1111
+ self.model_picker_items = items;
1112
+ }
1113
+ Report::Tools(overview) => {
1114
+ self.checks.tools = tools_check(overview.as_ref());
1115
+ }
1116
+ Report::Workspace(repo) => {
1117
+ self.checks.workspace = workspace_check(repo.as_deref());
1118
+ self.repo = repo;
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ /// switch to chat phase and show the welcome notes
1112
1124
  fn finish_loading(&mut self) {
1113
1125
  self.is_loading = false;
1114
- for line in BANNER_ART.lines() {
1115
- self.messages.push(ChatMessage::banner(line));
1116
- }
1117
- self.messages.push(ChatMessage::system(""));
1118
1126
  self.messages.push(ChatMessage::system(
1119
1127
  "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit",
1120
1128
  ));
1129
+ for (label, check) in self.checks.rows() {
1130
+ match check {
1131
+ Check::Ok(d) | Check::Note(d) | Check::Warn(d) => self
1132
+ .messages
1133
+ .push(ChatMessage::system(format!("{label}: {d}"))),
1134
+ Check::Pending => {}
1135
+ }
1136
+ }
1121
1137
  if self.backend.is_remote() {
1122
1138
  self.messages.push(ChatMessage::system(format!(
1123
1139
  "Current model: {}",
@@ -1131,6 +1147,11 @@ mod tui {
1131
1147
  self.current_model_name
1132
1148
  )));
1133
1149
  }
1150
+ if matches!(self.session, Some(SessionState::Expired { .. })) {
1151
+ self.messages.push(ChatMessage::system(
1152
+ "Your siGit Code Cloud session has expired. Use /login to sign in again.",
1153
+ ));
1154
+ }
1134
1155
  self.messages
1135
1156
  .push(ChatMessage::system("Type /help for commands."));
1136
1157
  }
@@ -1555,21 +1576,6 @@ mod tui {
1555
1576
 
1556
1577
  // ── Repo tab (issues / pull requests via the official MCP server) ─────────
1557
1578
 
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
-
1573
1579
  /// Fetch both Repo-tab lists on spawned tasks feeding `repo_rx` — the same
1574
1580
  /// spawned-fetch pattern as the Cloud tab, so the render loop never blocks
1575
1581
  /// on the network. Replacing the channel drops any in-flight fetch's
@@ -2125,6 +2131,11 @@ mod tui {
2125
2131
  fn render(frame: &mut Frame, app: &mut App) {
2126
2132
  let area = frame.area();
2127
2133
 
2134
+ if app.is_loading && app.load_error.is_none() {
2135
+ render_splash(frame, app, area);
2136
+ return;
2137
+ }
2138
+
2128
2139
  if app.is_loading {
2129
2140
  let zones = Layout::vertical([
2130
2141
  Constraint::Length(1),
@@ -2247,9 +2258,58 @@ mod tui {
2247
2258
  }
2248
2259
  }
2249
2260
 
2261
+ /// The launch splash: the wordmark with a caption under it. The startup
2262
+ /// checks still gate it, and their results land in the chat's info lines.
2263
+ /// Too small a terminal gets the caption without the art.
2264
+ fn render_splash(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2265
+ let now = std::time::Instant::now();
2266
+ let reduced = app.splash.reduced();
2267
+ let tc = app.splash.truecolor;
2268
+ let t = if reduced {
2269
+ Duration::from_secs(2)
2270
+ } else {
2271
+ app.splash.elapsed(now)
2272
+ };
2273
+ let ms = t.as_millis() as f32;
2274
+ let exit = app.splash.exit_progress(now);
2275
+ let vis = 1.0 - exit;
2276
+
2277
+ // the wordmark, a gap, the caption
2278
+ let art = app
2279
+ .splash
2280
+ .banner
2281
+ .size(area.width)
2282
+ .filter(|(_, h)| area.height >= h + 2);
2283
+ let art_h = art.map_or(0, |(_, h)| h + 1);
2284
+ let total = art_h + 1;
2285
+ let top = area.y + area.height.saturating_sub(total) / 2;
2286
+ let rect = |y: u16, h: u16| ratatui::layout::Rect::new(area.x, y, area.width, h);
2287
+
2288
+ if let Some((_, h)) = art {
2289
+ frame.render_widget(
2290
+ Wordmark {
2291
+ banner: &app.splash.banner,
2292
+ t,
2293
+ exit,
2294
+ still: reduced,
2295
+ truecolor: tc,
2296
+ },
2297
+ rect(top, h),
2298
+ );
2299
+ }
2300
+
2301
+ let caption_y = top + art_h;
2302
+ let caption = Paragraph::new(Line::from(Span::styled(
2303
+ "siGit Code & Deploy",
2304
+ Style::default().fg(splash::fade(splash::MUTED, vis * (ms / 300.0).min(1.0), tc)),
2305
+ )))
2306
+ .alignment(ratatui::layout::Alignment::Center);
2307
+ frame.render_widget(caption, rect(caption_y, 1));
2308
+ }
2309
+
2250
2310
  fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2251
2311
  const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
2252
- let spin = SPINNER[(app.load_tick as usize) % SPINNER.len()];
2312
+ let spin = SPINNER[(app.load_tick as usize / 3) % SPINNER.len()];
2253
2313
  let label = format!(" siGit {} loading {}… ", spin, app.load_model_name);
2254
2314
  let line = Line::from(Span::styled(
2255
2315
  label,
@@ -2443,22 +2503,6 @@ mod tui {
2443
2503
  lines.push(Line::from(Span::styled(format!(" {entry_line}"), dim)));
2444
2504
  }
2445
2505
  }
2446
- Role::Banner => {
2447
- let palette = [
2448
- Color::Red,
2449
- Color::Yellow,
2450
- Color::Green,
2451
- Color::Cyan,
2452
- Color::Blue,
2453
- Color::Magenta,
2454
- ];
2455
- let mut spans = Vec::new();
2456
- for (i, ch) in msg.text.chars().enumerate() {
2457
- let color = palette[i % palette.len()];
2458
- spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
2459
- }
2460
- lines.push(Line::from(spans));
2461
- }
2462
2506
  Role::System => {
2463
2507
  for text_line in msg.text.split('\n') {
2464
2508
  let trimmed = text_line.trim();
@@ -3528,15 +3572,16 @@ mod tui {
3528
3572
  {
3529
3573
  let mut app = App::new(load_model_name, backend, program_status);
3530
3574
  let mut event_stream = EventStream::new();
3575
+ let mut startup_rx = startup::spawn();
3531
3576
 
3532
- // 10 fps is plenty for spinners
3533
- let mut ticker = interval(Duration::from_millis(100));
3577
+ // ~30 fps while the splash plays; the loading phase is the only user
3578
+ let mut ticker = interval(Duration::from_millis(33));
3534
3579
 
3535
3580
  loop {
3536
3581
  // ── Poll the loader channel (non-blocking) ────────────────────────
3537
3582
  if app.is_loading {
3538
3583
  match load_rx.try_recv() {
3539
- Ok(Ok(())) => app.finish_loading(),
3584
+ Ok(Ok(())) => app.load_ready = true,
3540
3585
  Ok(Err(e)) => app.set_load_error(e),
3541
3586
  Err(std_mpsc::TryRecvError::Empty) => {}
3542
3587
  Err(std_mpsc::TryRecvError::Disconnected) => {
@@ -3545,6 +3590,24 @@ mod tui {
3545
3590
  }
3546
3591
  }
3547
3592
 
3593
+ // Startup checks report as they finish, and keep reporting after a
3594
+ // skip: the picker and the Repo tab fill in behind the chat.
3595
+ while let Ok(report) = startup_rx.try_recv() {
3596
+ app.apply_startup(report);
3597
+ }
3598
+ // The splash leaves once the backend is up and every check has
3599
+ // reported (and never before its minimum), or on a key press.
3600
+ if app.is_loading
3601
+ && app.load_ready
3602
+ && app.load_error.is_none()
3603
+ && app
3604
+ .splash
3605
+ .update(std::time::Instant::now(), app.checks.all_done())
3606
+ == Phase::Done
3607
+ {
3608
+ app.finish_loading();
3609
+ }
3610
+
3548
3611
  // redraw every iteration
3549
3612
  terminal.draw(|frame| render(frame, &mut app))?;
3550
3613
 
@@ -3637,7 +3700,7 @@ mod tui {
3637
3700
  tokio::select! {
3638
3701
  biased;
3639
3702
 
3640
- // ── Spinner tick (loading phase only) ─────────────────────────
3703
+ // ── Splash / spinner tick (loading phase only) ────────────────
3641
3704
  _ = ticker.tick(), if app.is_loading => {
3642
3705
  app.tick();
3643
3706
  }
@@ -3800,6 +3863,9 @@ mod tui {
3800
3863
  || key.code == KeyCode::Char('d'))
3801
3864
  {
3802
3865
  app.quit = true;
3866
+ } else {
3867
+ // any other key skips the splash
3868
+ app.splash.skip();
3803
3869
  }
3804
3870
  }
3805
3871
  continue;
@@ -30,7 +30,7 @@ use crate::{permissions, provider, session_store, settings, tools};
30
30
  pub const USAGE: &str = "Usage: sigit run \"<prompt>\" [--cwd <dir>] [--add-dir <dir>]... \
31
31
  [--output text|jsonl] [--quiet] [--resume <session-id>] \
32
32
  [--allow-tool <name>]... [--deny-tool <name>]... \
33
- [--max-tool-rounds <n>]\n \
33
+ [--max-tool-rounds <n>] [--model <tier>]\n \
34
34
  sigit -p \"<prompt>\" [same options]";
35
35
 
36
36
  #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -67,6 +67,9 @@ pub struct HeadlessConfig {
67
67
  /// asked for its final message. `--max-tool-rounds`, else
68
68
  /// `SIGIT_MAX_TOOL_ROUNDS`, else the built-in cap.
69
69
  pub max_tool_rounds: usize,
70
+ /// siGit Code Cloud tier named by `--model`. When set it wins over the
71
+ /// provider override and the local-inference setting.
72
+ pub cloud_tier: Option<&'static str>,
70
73
  }
71
74
 
72
75
  /// Upper bound for `--max-tool-rounds` / `SIGIT_MAX_TOOL_ROUNDS`.
@@ -147,6 +150,7 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
147
150
  let mut allow_tools: Vec<String> = Vec::new();
148
151
  let mut deny_tools: Vec<String> = Vec::new();
149
152
  let mut max_tool_rounds: Option<usize> = None;
153
+ let mut cloud_tier: Option<&'static str> = None;
150
154
 
151
155
  let mut iter = args.iter().skip(usize::from(run_command));
152
156
  while let Some(arg) = iter.next() {
@@ -211,6 +215,21 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
211
215
  .ok_or_else(|| "--max-tool-rounds requires a number".to_string())?;
212
216
  max_tool_rounds = Some(parse_tool_rounds(value, "--max-tool-rounds")?);
213
217
  }
218
+ "--model" => {
219
+ let value = iter
220
+ .next()
221
+ .ok_or_else(|| "--model requires a cloud tier".to_string())?;
222
+ if cloud_tier.is_some() {
223
+ return Err("--model was given more than once".to_string());
224
+ }
225
+ let tier = provider::cloud_tier_named(value).ok_or_else(|| {
226
+ format!(
227
+ "unknown cloud tier: {value} (expected one of: {})",
228
+ provider::CLOUD_TIERS.join(", ")
229
+ )
230
+ })?;
231
+ cloud_tier = Some(tier);
232
+ }
214
233
  // A mistyped flag must not become the prompt. A prompt that
215
234
  // really starts with '-' can still go through -p.
216
235
  other if other.starts_with('-') => return Err(format!("unknown argument: {other}")),
@@ -258,6 +277,7 @@ pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
258
277
  allow_tools,
259
278
  deny_tools,
260
279
  max_tool_rounds,
280
+ cloud_tier,
261
281
  }))
262
282
  }
263
283
 
@@ -312,22 +332,32 @@ pub async fn run(config: HeadlessConfig) -> i32 {
312
332
  // first, else the signed-in cloud tier when local inference is off (what
313
333
  // `apply_startup_inference_mode` does at every ACP session entry). There is
314
334
  // never an implicit on-device load — a fresh process has no model in memory
315
- // and headless mode must not silently download gigabytes.
316
- let provider_cfg = provider::active_provider().or_else(|| {
317
- if settings::local_inference_enabled() {
318
- None
319
- } else {
320
- provider::cloud_tier_provider(provider::DEFAULT_CLOUD_TIER)
321
- }
322
- });
335
+ // and headless mode must not silently download gigabytes. `--model` is the
336
+ // exception to that order: a tier named on the command line wins over the
337
+ // override and the setting, the way a flag wins over the environment.
338
+ let provider_cfg = match config.cloud_tier {
339
+ Some(tier) => provider::cloud_tier_provider(tier),
340
+ None => provider::active_provider().or_else(|| {
341
+ if settings::local_inference_enabled() {
342
+ None
343
+ } else {
344
+ provider::cloud_tier_provider(provider::DEFAULT_CLOUD_TIER)
345
+ }
346
+ }),
347
+ };
323
348
  let Some(cfg) = provider_cfg else {
324
- emit_error(
325
- &config,
326
- "headless mode needs a remote inference provider — running on-device would require \
327
- loading (and possibly downloading) a local model, which headless mode never does \
328
- implicitly. Set OPENAI_BASE_URL and OPENAI_API_KEY (or configure providers.toml), \
329
- or sign in with `sigit login` and turn local inference off.",
330
- );
349
+ let message = match config.cloud_tier {
350
+ Some(tier) => format!(
351
+ "--model {tier} needs a siGit Code Cloud session. Sign in with `sigit login`."
352
+ ),
353
+ None => "headless mode needs a remote inference provider — running on-device would \
354
+ require loading (and possibly downloading) a local model, which headless \
355
+ mode never does implicitly. Set OPENAI_BASE_URL and OPENAI_API_KEY (or \
356
+ configure providers.toml), or sign in with `sigit login` and turn local \
357
+ inference off."
358
+ .to_string(),
359
+ };
360
+ emit_error(&config, &message);
331
361
  return 1;
332
362
  };
333
363
 
@@ -814,6 +844,32 @@ mod tests {
814
844
  assert!(resolve_max_tool_rounds(None, Some("0")).is_err());
815
845
  }
816
846
 
847
+ #[test]
848
+ fn model_flag_names_a_cloud_tier() {
849
+ let config = parse_args(&args(&["run", "go"])).unwrap().unwrap();
850
+ assert_eq!(config.cloud_tier, None);
851
+ let config = parse_args(&args(&["run", "go", "--model", "prism"]))
852
+ .unwrap()
853
+ .unwrap();
854
+ assert_eq!(config.cloud_tier, Some("prism"));
855
+ // The wire id works too, so a model id copied from logs is accepted.
856
+ let config = parse_args(&args(&["-p", "go", "--model", "onde-zenith"]))
857
+ .unwrap()
858
+ .unwrap();
859
+ assert_eq!(config.cloud_tier, Some("zenith"));
860
+ }
861
+
862
+ #[test]
863
+ fn model_flag_rejects_unknown_and_repeated_tiers() {
864
+ let error = parse_args(&args(&["run", "go", "--model", "gpt-4o"])).unwrap_err();
865
+ assert!(error.starts_with("unknown cloud tier: gpt-4o"), "{error}");
866
+ assert!(error.contains("prism"), "{error}");
867
+ assert_eq!(
868
+ parse_args(&args(&["run", "go", "--model", "apex", "--model", "flare"])),
869
+ Err("--model was given more than once".to_string())
870
+ );
871
+ }
872
+
817
873
  #[test]
818
874
  fn wrap_up_prompt_names_the_cap_only_when_it_was_reached() {
819
875
  let capped = wrap_up_prompt(true, 24);
@@ -877,6 +933,7 @@ mod tests {
877
933
  assert!(parse_args(&args(&["-p", "x", "--deny-tool"])).is_err());
878
934
  assert!(parse_args(&args(&["run", "x", "--output"])).is_err());
879
935
  assert!(parse_args(&args(&["run", "x", "--resume"])).is_err());
936
+ assert!(parse_args(&args(&["run", "x", "--model"])).is_err());
880
937
  }
881
938
 
882
939
  #[test]
@@ -51,6 +51,10 @@ mod session_store;
51
51
  mod settings;
52
52
  mod setup;
53
53
  mod skills;
54
+ #[cfg(unix)]
55
+ mod splash;
56
+ #[cfg(unix)]
57
+ mod startup;
54
58
  mod subagents;
55
59
  mod tools;
56
60
  mod workspace;
@@ -6922,9 +6926,9 @@ async fn main() -> anyhow::Result<()> {
6922
6926
  let (tty, cleanup_tty) = redirect_output_to_log()?;
6923
6927
  init_logging(true);
6924
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.
6928
6932
  run_interactive(tty, cleanup_tty).await
6929
6933
  }
6930
6934
  #[cfg(not(unix))]