@setoelkahfi / sigit / commits / 29b45d7

Make account management slash commands; cloud tiers in /models

- /login, /logout, /whoami as slash commands in the TUI and ACP, backed by I/O-free core functions in account.rs (drops the argv subcommands and rpassword) - login is auth only: it no longer auto-selects the cloud backend - /models always lists the siGit Code Cloud tiers (Fast/Balanced/Large) next to on-device models; sign-in is gated at selection, not visibility - selecting a model or tier hot-swaps the running backend in place - account API client matches sigit.si /api/v1 (auth/sign_in, me, auth/sign_out) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 22, 2026 at 22:49 UTC 29b45d707047c5bae2b5335f1419248ada0e21af
7 files changed +307 -180
Cargo.lock
-22
@@ -4586,27 +4586,6 @@ dependencies = [
4586 "windows-sys 0.52.0",
4587 ]
4588
4589 -[[package]]
4590 -name = "rpassword"
4591 -version = "7.5.4"
4592 -source = "registry+https://github.com/rust-lang/crates.io-index"
4593 -checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
4594 -dependencies = [
4595 - "libc",
4596 - "rtoolbox",
4597 - "windows-sys 0.61.2",
4598 -]
4599 -
4600 -[[package]]
4601 -name = "rtoolbox"
4602 -version = "0.0.5"
4603 -source = "registry+https://github.com/rust-lang/crates.io-index"
4604 -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
4605 -dependencies = [
4606 - "libc",
4607 - "windows-sys 0.59.0",
4608 -]
4609 -
4589 [[package]]
4590 name = "rubato"
4591 version = "0.16.2"
@@ -5223,7 +5202,6 @@ dependencies = [
5202 "ratatui",
5203 "regex",
5204 "reqwest 0.12.28",
5226 - "rpassword",
5205 "serde",
5206 "serde_json",
5207 "tokio",
Cargo.toml
-1
@@ -42,7 +42,6 @@ serde = { version = "1", features = ["derive"] }
42 serde_json = "1"
43 toml = "0.8"
44 async-trait = "0.1"
45 -rpassword = "7"
45 regex = "1"
46 reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
47 uuid = { version = "1", features = ["v4"] }
src/account.rs
+83 -86
@@ -1,7 +1,10 @@
1 -//! Account commands: `login`, `logout`, `whoami`.
1 +//! Account access for siGit Code Cloud, surfaced as the `/login`, `/logout`,
2 +//! and `/whoami` slash commands in both the TUI and ACP sessions.
3 //!
3 -//! These authenticate against the siGit account API and store a session token
4 -//! locally. The token is the credential used for siGit Code Cloud requests.
4 +//! These functions authenticate against the siGit account API and store a
5 +//! session token locally. The token is the credential used for siGit Code Cloud
6 +//! requests. They perform no console I/O, so each slash surface can render the
7 +//! returned message however it likes.
8 //!
9 //! Base URL: `$SIGIT_API_URL`, else `https://sigit.si`.
10
@@ -16,25 +19,12 @@ fn api_base() -> String {
19 std::env::var("SIGIT_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string())
20 }
21
19 -// ── sigit.si /api/v1 response shapes ─────────────────────────────────────────────
20 -
21 -/// Sign-in response. A successful sign-in carries an `access_token`; an
22 -/// unverified account reports a `status`; failures arrive as an `error`.
23 -#[derive(Debug, Deserialize)]
24 -struct SignInResponse {
25 - #[serde(default)]
26 - access_token: Option<String>,
27 - #[serde(default)]
28 - status: Option<String>,
29 - #[serde(default)]
30 - error: Option<ApiError>,
31 -}
32 -
33 -#[derive(Debug, Deserialize)]
34 -struct ApiError {
35 - #[serde(default)]
36 - message: Option<String>,
37 -}
22 +// Sign-in returns an `AccountStatus`, one of:
23 +// "NotFound" (a bare JSON string)
24 +// {"Ready":{"access_token":"…"}}
25 +// {"Incomplete":{"status":<u32>}}
26 +// Failures return {"error_code":<i32>,"message":"…"}. Parsed from a
27 +// `serde_json::Value` rather than a struct because of the bare-string variant.
28
29 #[derive(Debug, Deserialize)]
30 struct MeResponse {
@@ -42,60 +32,70 @@ struct MeResponse {
32 email: Option<String>,
33 }
34
45 -// ── Commands ──────────────────────────────────────────────────────────────────────
46 -
47 -/// `sigit login`: prompt for credentials, authenticate, and store the token.
48 -pub async fn login() -> anyhow::Result<()> {
49 - let base = api_base();
50 - println!("Sign in to siGit Code Cloud ({base})");
51 -
52 - let email = prompt("Email: ")?;
53 - let password = rpassword::prompt_password("Password: ")?;
54 - if email.trim().is_empty() || password.is_empty() {
55 - anyhow::bail!("email and password are required");
35 +/// Authenticate with email and password, storing the session token on success.
36 +/// Returns the signed-in email, or a human-readable error message.
37 +pub async fn authenticate(email: &str, password: &str) -> Result<String, String> {
38 + let email = email.trim();
39 + if email.is_empty() || password.is_empty() {
40 + return Err("email and password are required".to_string());
41 }
42
58 - let url = format!("{}/api/v1/users/sign_in", base.trim_end_matches('/'));
43 + let url = format!("{}/api/v1/auth/sign_in", api_base().trim_end_matches('/'));
44 let response = reqwest::Client::new()
45 .post(&url)
61 - .json(&serde_json::json!({ "email": email.trim(), "password": password }))
46 + .json(&serde_json::json!({ "email": email, "password": password }))
47 .send()
48 .await
64 - .map_err(|error| anyhow::anyhow!("could not reach siGit Code Cloud: {error}"))?;
49 + .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?;
50
51 let status = response.status();
67 - let parsed: SignInResponse = response
52 + let body: serde_json::Value = response
53 .json()
54 .await
70 - .map_err(|error| anyhow::anyhow!("unexpected response from siGit Code Cloud: {error}"))?;
71 -
72 - if let Some(token) = parsed.access_token.filter(|token| !token.trim().is_empty()) {
73 - credentials::store(&Credentials {
74 - access_token: token,
75 - email: Some(email.trim().to_string()),
76 - })
77 - .map_err(|error| anyhow::anyhow!("could not save session: {error}"))?;
78 - println!("✓ Signed in as {}. siGit Code Cloud is ready.", email.trim());
79 - return Ok(());
55 + .map_err(|error| format!("unexpected response from siGit Code Cloud: {error}"))?;
56 +
57 + if status.is_success() {
58 + // AccountStatus::Ready
59 + if let Some(token) = body
60 + .get("Ready")
61 + .and_then(|ready| ready.get("access_token"))
62 + .and_then(|token| token.as_str())
63 + .filter(|token| !token.trim().is_empty())
64 + {
65 + credentials::store(&Credentials {
66 + access_token: token.to_string(),
67 + email: Some(email.to_string()),
68 + })?;
69 + return Ok(email.to_string());
70 + }
71 + // AccountStatus::Incomplete
72 + if body.get("Incomplete").is_some() {
73 + return Err(
74 + "your account is not verified yet. Check your email to confirm it, then sign in again."
75 + .to_string(),
76 + );
77 + }
78 + // AccountStatus::NotFound (a bare JSON string)
79 + if body.as_str() == Some("NotFound") {
80 + return Err(format!("no siGit account found for {email}."));
81 + }
82 + return Err("unexpected sign-in response from siGit Code Cloud".to_string());
83 }
84
82 - // No token: surface the most specific message available.
83 - if let Some(message) = parsed.error.and_then(|error| error.message) {
84 - anyhow::bail!("sign-in failed: {message}");
85 - }
86 - if let Some(account_status) = parsed.status {
87 - anyhow::bail!(
88 - "sign-in incomplete (status: {account_status}). Check your email to verify your account."
89 - );
90 - }
91 - anyhow::bail!("sign-in failed (HTTP {})", status.as_u16());
85 + // ErrorResponse { error_code, message }
86 + let message = body
87 + .get("message")
88 + .and_then(|message| message.as_str())
89 + .unwrap_or("sign-in failed");
90 + Err(format!("sign-in failed: {message}"))
91 }
92
94 -/// `sigit logout`: clear the local session, notifying the server best-effort.
95 -pub async fn logout() -> anyhow::Result<()> {
93 +/// Clear the local session, notifying the server best-effort. Returns a message
94 +/// suitable for display.
95 +pub async fn end_session() -> String {
96 if let Some(token) = credentials::load_token() {
97 - let url = format!("{}/api/v1/users/sign_out", api_base().trim_end_matches('/'));
98 - // Best-effort: a failed server call must not block local logout.
97 + let url = format!("{}/api/v1/auth/sign_out", api_base().trim_end_matches('/'));
98 + // A failed server call must not block local sign-out.
99 let _ = reqwest::Client::new()
100 .delete(&url)
101 .bearer_auth(&token)
@@ -103,18 +103,16 @@ pub async fn logout() -> anyhow::Result<()> {
103 .await;
104 }
105 if credentials::clear() {
106 - println!("✓ Signed out of siGit Code Cloud.");
106 + "Signed out of siGit Code Cloud.".to_string()
107 } else {
108 - println!("Not signed in.");
108 + "Not signed in.".to_string()
109 }
110 - Ok(())
110 }
111
113 -/// `sigit whoami`: show the signed-in account, verifying the token if reachable.
114 -pub async fn whoami() -> anyhow::Result<()> {
112 +/// One-line description of the current session, verifying the token if reachable.
113 +pub async fn status_line() -> String {
114 let Some(creds) = credentials::load() else {
116 - println!("Not signed in. Run `sigit login` to use siGit Code Cloud.");
117 - return Ok(());
115 + return "Not signed in. Use `/login <email> <password>` to use siGit Code Cloud.".to_string();
116 };
117
118 let url = format!("{}/api/v1/me", api_base().trim_end_matches('/'));
@@ -132,29 +130,28 @@ pub async fn whoami() -> anyhow::Result<()> {
130 .and_then(|me| me.email)
131 .or(creds.email)
132 .unwrap_or_else(|| "(unknown)".to_string());
135 - println!("Signed in to siGit Code Cloud as {email}.");
136 - }
137 - Ok(response) => {
138 - println!(
139 - "Session may be expired (HTTP {}). Run `sigit login` again.",
140 - response.status().as_u16()
141 - );
133 + format!("Signed in to siGit Code Cloud as {email}.")
134 }
135 + Ok(response) => format!(
136 + "Session may be expired (HTTP {}). Use `/login` again.",
137 + response.status().as_u16()
138 + ),
139 Err(_) => {
144 - // Offline: fall back to the cached email.
140 let email = creds.email.unwrap_or_else(|| "(unknown)".to_string());
146 - println!("Signed in as {email} (could not reach siGit Code Cloud to verify).");
141 + format!("Signed in as {email} (could not reach siGit Code Cloud to verify).")
142 }
143 }
149 - Ok(())
144 }
145
152 -/// Print a prompt and read one trimmed line from stdin.
153 -fn prompt(label: &str) -> anyhow::Result<String> {
154 - use std::io::Write;
155 - print!("{label}");
156 - std::io::stdout().flush()?;
157 - let mut line = String::new();
158 - std::io::stdin().read_line(&mut line)?;
159 - Ok(line.trim_end_matches(['\n', '\r']).to_string())
146 +/// Split a `/login` argument into `(email, password)`. The password is the rest
147 +/// of the line after the first whitespace, so it may contain spaces.
148 +pub fn parse_login_args(arg: &str) -> Option<(String, String)> {
149 + let mut parts = arg.trim().splitn(2, char::is_whitespace);
150 + let email = parts.next().unwrap_or("").trim();
151 + let password = parts.next().unwrap_or("").trim();
152 + if email.is_empty() || password.is_empty() {
153 + None
154 + } else {
155 + Some((email.to_string(), password.to_string()))
156 + }
157 }
src/chat.rs
+93 -10
@@ -77,7 +77,7 @@ mod tui {
77 use futures::StreamExt;
78 use onde::inference::{ChatEngine, SamplingConfig, StreamChunk};
79
80 - use crate::backend::{InferenceBackend, ToolResult, ToolSpec};
80 + use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend, ToolResult, ToolSpec};
81 use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items};
82 use ratatui::{
83 Frame,
@@ -199,6 +199,11 @@ mod tui {
199 switching_model_id: Option<String>,
200 /// (downloaded, expected) bytes — polled every tick during a model switch
201 download_progress: Option<(u64, u64)>,
202 +
203 + // ── Active inference backend ──────────────────────────────────────────
204 + /// The backend serving inference. Swapped in place when the user picks a
205 + /// different model or cloud tier via `/models`.
206 + backend: Arc<dyn InferenceBackend>,
207 }
208
209 const BANNER_ART: &str = "\
@@ -234,7 +239,8 @@ mod tui {
239 }
240
241 impl App {
237 - fn new(load_model_name: String, is_remote: bool) -> Self {
242 + fn new(load_model_name: String, backend: Arc<dyn InferenceBackend>) -> Self {
243 + let is_remote = backend.is_remote();
244 let items = build_model_picker_items();
245 let tool_calling = items
246 .iter()
@@ -279,6 +285,7 @@ mod tui {
285 model_picker_items: items,
286 current_model_name,
287 tool_calling,
288 + backend,
289 }
290 }
291
@@ -541,6 +548,14 @@ mod tui {
548 .bg(Color::Black)
549 .add_modifier(Modifier::BOLD),
550 ),
551 + ModelSource::Cloud => (
552 + "☁",
553 + "siGit Code Cloud",
554 + Style::default()
555 + .fg(Color::Magenta)
556 + .bg(Color::Black)
557 + .add_modifier(Modifier::BOLD),
558 + ),
559 };
560
561 lines.push(
@@ -576,6 +591,7 @@ mod tui {
591 ModelSource::HuggingFace => "○",
592 ModelSource::Available => "↓",
593 ModelSource::Fallback => "◎",
594 + ModelSource::Cloud => "☁",
595 };
596 let source = format!(" [{} {}]", brand_mark, item.source_label);
597
@@ -593,6 +609,7 @@ mod tui {
609 ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black),
610 ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black),
611 ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black),
612 + ModelSource::Cloud => Style::default().fg(Color::Magenta).bg(Color::Black),
613 }
614 };
615
@@ -672,6 +689,10 @@ mod tui {
689 Status,
690 /// picker UI, or jump straight to model N
691 Models(Option<usize>),
692 + /// `/login <email> <password>` — the raw argument, parsed when executed.
693 + Login(Option<String>),
694 + Logout,
695 + Whoami,
696 Exit,
697 Unknown(String),
698 }
@@ -689,6 +710,9 @@ mod tui {
710 "/clear" => SlashCommand::Clear,
711 "/status" => SlashCommand::Status,
712 "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),
713 + "/login" => SlashCommand::Login(arg.map(str::to_string)),
714 + "/logout" => SlashCommand::Logout,
715 + "/whoami" => SlashCommand::Whoami,
716 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
717 other => SlashCommand::Unknown(other.to_string()),
718 })
@@ -1134,12 +1158,15 @@ mod tui {
1158 match cmd {
1159 SlashCommand::Help => {
1160 app.messages.push(ChatMessage::system(
1137 - "/help — show this message\n\
1138 - /models — open the model picker\n\
1139 - /models N — switch to model N\n\
1140 - /clear — wipe conversation history\n\
1141 - /status — show engine status\n\
1142 - /exit — quit chat",
1161 + "/help — show this message\n\
1162 + /models — open the model picker\n\
1163 + /models N — switch to model N\n\
1164 + /login E P — sign in to siGit Code Cloud\n\
1165 + /logout — sign out\n\
1166 + /whoami — show the signed-in account\n\
1167 + /clear — wipe conversation history\n\
1168 + /status — show engine status\n\
1169 + /exit — quit chat",
1170 ));
1171 }
1172 SlashCommand::Clear => {
@@ -1172,6 +1199,36 @@ mod tui {
1199 )));
1200 }
1201 Some(model) => {
1202 + // ── siGit Code Cloud tier: no local load; sign-in gated ──
1203 + if let Some(tier) = model.cloud_tier.clone() {
1204 + app.close_model_picker();
1205 + match crate::provider::cloud_tier_provider(&tier) {
1206 + Some(provider) => {
1207 + let system_prompt =
1208 + crate::system_prompt_for_model(true).to_string();
1209 + app.backend = Arc::new(OpenAiBackend::new(
1210 + provider.base_url,
1211 + provider.api_key,
1212 + provider.model,
1213 + Some(system_prompt),
1214 + ));
1215 + app.current_model_name = provider.display_name.clone();
1216 + app.tool_calling = true;
1217 + app.messages.push(ChatMessage::system(format!(
1218 + "Switched to {}.",
1219 + provider.display_name
1220 + )));
1221 + }
1222 + None => {
1223 + app.messages.push(ChatMessage::system(
1224 + "siGit Code Cloud needs an account. Use \
1225 + `/login <email> <password>`, or create one at sigit.si.",
1226 + ));
1227 + }
1228 + }
1229 + return;
1230 + }
1231 +
1232 if model.cache_health == ModelCacheHealth::Incomplete {
1233 app.close_model_picker();
1234 app.messages.push(ChatMessage::system(format!(
@@ -1181,6 +1238,10 @@ mod tui {
1238 return;
1239 }
1240
1241 + // Route inference on-device; the loader thread below
1242 + // fills the engine the LocalBackend reads from.
1243 + app.backend = Arc::new(LocalBackend::new(Arc::clone(&engine)));
1244 +
1245 let loading_msg = if model.cache_health
1246 == ModelCacheHealth::NotDownloaded
1247 {
@@ -1244,6 +1305,28 @@ mod tui {
1305 }
1306 }
1307 },
1308 + SlashCommand::Login(arg) => {
1309 + let message = match arg.as_deref().and_then(crate::account::parse_login_args) {
1310 + Some((email, password)) => {
1311 + match crate::account::authenticate(&email, &password).await {
1312 + Ok(email) => format!(
1313 + "Signed in as {email}. siGit Code Cloud applies to your next session."
1314 + ),
1315 + Err(error) => format!("Login failed: {error}"),
1316 + }
1317 + }
1318 + None => "usage: /login <email> <password>".to_string(),
1319 + };
1320 + app.messages.push(ChatMessage::system(message));
1321 + }
1322 + SlashCommand::Logout => {
1323 + let message = crate::account::end_session().await;
1324 + app.messages.push(ChatMessage::system(message));
1325 + }
1326 + SlashCommand::Whoami => {
1327 + let message = crate::account::status_line().await;
1328 + app.messages.push(ChatMessage::system(message));
1329 + }
1330 SlashCommand::Exit => {
1331 app.quit = true;
1332 }
@@ -1377,7 +1460,7 @@ mod tui {
1460 load_rx: std_mpsc::Receiver<Result<(), String>>,
1461 load_model_name: String,
1462 ) -> Result<()> {
1380 - let mut app = App::new(load_model_name, backend.is_remote());
1463 + let mut app = App::new(load_model_name, backend);
1464 let mut event_stream = EventStream::new();
1465
1466 // 10 fps is plenty for spinners
@@ -1612,7 +1695,7 @@ mod tui {
1695 let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
1696 app.inference_rx = Some(rx);
1697
1615 - let backend_handle = Arc::clone(&backend);
1698 + let backend_handle = Arc::clone(&app.backend);
1699 let user_text = text.clone();
1700 let tools_enabled = app.tool_calling;
1701 tokio::spawn(async move {
src/main.rs
+49 -34
@@ -324,7 +324,7 @@ impl SiGitAgent {
324 }
325
326 let startup_config = self.current_model.lock().unwrap().clone();
327 - let (max_tokens, tool_calling) = models::build_model_picker_items()
327 + let (max_tokens, tool_calling) = models::local_picker_items()
328 .into_iter()
329 .find(|item| {
330 item.config.model_id == startup_config.model_id
@@ -625,7 +625,7 @@ impl SiGitAgent {
625 *guard = None;
626 }
627
628 - if let Some(item) = models::build_model_picker_items()
628 + if let Some(item) = models::local_picker_items()
629 .iter()
630 .find(|item| item.config.model_id == new_config.model_id)
631 && let Err(err) = setup::save_selected_model(&setup::SelectedModel {
@@ -1126,7 +1126,7 @@ impl SiGitAgent {
1126 }
1127 }
1128
1129 - let needs_download = models::build_model_picker_items()
1129 + let needs_download = models::local_picker_items()
1130 .into_iter()
1131 .find(|item| item.config.model_id == model_id)
1132 .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
@@ -1145,7 +1145,7 @@ impl SiGitAgent {
1145 .map(|m| m.expected_size_bytes)
1146 .unwrap_or(0);
1147
1148 - let display_name = models::build_model_picker_items()
1148 + let display_name = models::local_picker_items()
1149 .into_iter()
1150 .find(|item| item.config.model_id == model_id_owned)
1151 .map(|item| item.display_name.clone())
@@ -1252,7 +1252,7 @@ impl SiGitAgent {
1252
1253 // cached models still take 10-30s to load weights; show a spinner
1254 if !needs_download {
1255 - let cached_display_name = models::build_model_picker_items()
1255 + let cached_display_name = models::local_picker_items()
1256 .into_iter()
1257 .find(|item| item.config.model_id == model_id)
1258 .map(|item| item.display_name.clone())
@@ -1386,7 +1386,7 @@ impl SiGitAgent {
1386 const MODEL_CONFIG_ID: &str = "sigit-model";
1387
1388 fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
1389 - let items = models::build_model_picker_items();
1389 + let items = models::local_picker_items();
1390
1391 let options: Vec<SessionConfigSelectOption> = items
1392 .iter()
@@ -1434,7 +1434,7 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
1434
1435 /// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None
1436 fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
1437 - let items = models::build_model_picker_items();
1437 + let items = models::local_picker_items();
1438 items
1439 .into_iter()
1440 .find(|item| {
@@ -1452,6 +1452,10 @@ enum SlashCommand {
1452 Clear,
1453 Status,
1454 Models(Option<usize>),
1455 + /// `/login <email> <password>` — the raw argument, parsed when executed.
1456 + Login(Option<String>),
1457 + Logout,
1458 + Whoami,
1459 Exit,
1460 Unknown(String),
1461 }
@@ -1469,13 +1473,16 @@ fn parse_slash(input: &str) -> Option<SlashCommand> {
1473 "/clear" => SlashCommand::Clear,
1474 "/status" => SlashCommand::Status,
1475 "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),
1476 + "/login" => SlashCommand::Login(argument.map(str::to_string)),
1477 + "/logout" => SlashCommand::Logout,
1478 + "/whoami" => SlashCommand::Whoami,
1479 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
1480 other => SlashCommand::Unknown(other.to_string()),
1481 })
1482 }
1483
1484 fn format_models_list(current_model: &GgufModelConfig) -> String {
1478 - let items = models::build_model_picker_items();
1485 + let items = models::local_picker_items();
1486 if items.is_empty() {
1487 return "No local models found. siGit will use the platform default model.".to_string();
1488 }
@@ -1548,12 +1555,15 @@ async fn exec_slash_acp(
1555 .send_assistant_message(
1556 cx,
1557 session_id,
1551 - "/help - show this message\n\
1552 - /models - list available models\n\
1553 - /models N - switch to model N\n\
1554 - /clear - wipe conversation history\n\
1555 - /status - show engine status\n\
1556 - /exit - end this turn",
1558 + "/help - show this message\n\
1559 + /models - list available models\n\
1560 + /models N - switch to model N\n\
1561 + /login E P - sign in to siGit Code Cloud\n\
1562 + /logout - sign out\n\
1563 + /whoami - show the signed-in account\n\
1564 + /clear - wipe conversation history\n\
1565 + /status - show engine status\n\
1566 + /exit - end this turn",
1567 )
1568 .ok();
1569 }
@@ -1589,7 +1599,7 @@ async fn exec_slash_acp(
1599 .ok();
1600 }
1601 SlashCommand::Models(Some(number)) => {
1592 - let items = models::build_model_picker_items();
1602 + let items = models::local_picker_items();
1603 let index = number.saturating_sub(1);
1604 match items.get(index).cloned() {
1605 None => {
@@ -1672,6 +1682,26 @@ async fn exec_slash_acp(
1682 }
1683 }
1684 }
1685 + SlashCommand::Login(argument) => {
1686 + let message = match argument.as_deref().and_then(account::parse_login_args) {
1687 + Some((email, password)) => match account::authenticate(&email, &password).await {
1688 + Ok(email) => format!(
1689 + "Signed in as {email}. siGit Code Cloud applies to your next session."
1690 + ),
1691 + Err(error) => format!("Login failed: {error}"),
1692 + },
1693 + None => "usage: /login <email> <password>".to_string(),
1694 + };
1695 + agent.send_assistant_message(cx, session_id, message).ok();
1696 + }
1697 + SlashCommand::Logout => {
1698 + let message = account::end_session().await;
1699 + agent.send_assistant_message(cx, session_id, message).ok();
1700 + }
1701 + SlashCommand::Whoami => {
1702 + let message = account::status_line().await;
1703 + agent.send_assistant_message(cx, session_id, message).ok();
1704 + }
1705 SlashCommand::Exit => {
1706 agent
1707 .send_assistant_message(
@@ -1813,7 +1843,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1843 let config = startup_selection
1844 .as_ref()
1845 .and_then(|selection| {
1816 - models::build_model_picker_items()
1846 + models::local_picker_items()
1847 .into_iter()
1848 .find(|item| {
1849 selection
@@ -1840,7 +1870,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1870 // std::sync::mpsc on a real thread so model loading can't starve the TUI
1871 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
1872
1843 - let tool_calling = models::build_model_picker_items()
1873 + let tool_calling = models::local_picker_items()
1874 .iter()
1875 .find(|item| item.config.model_id == config.model_id)
1876 .map(|item| item.tool_calling)
@@ -1925,7 +1955,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
1955 .as_ref()
1956 .and_then(|selection| {
1957 selection.selected_model.as_ref().and_then(|selected| {
1928 - models::build_model_picker_items()
1958 + models::local_picker_items()
1959 .into_iter()
1960 .find(|item| {
1961 item.config.model_id == selected.model_id
@@ -1940,7 +1970,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
1970 })
1971 .unwrap_or_else(GgufModelConfig::qwen25_3b);
1972
1943 - let needs_download = models::build_model_picker_items()
1973 + let needs_download = models::local_picker_items()
1974 .iter()
1975 .find(|item| item.config.model_id == config.model_id)
1976 .map(|item| item.cache_health != setup::ModelCacheHealth::Complete)
@@ -2069,21 +2099,6 @@ async fn run_acp_server() -> anyhow::Result<()> {
2099
2100 #[tokio::main]
2101 async fn main() -> anyhow::Result<()> {
2072 - // Account subcommands run before the TUI/ACP split. They are plain CLI verbs.
2073 - if let Some(command) = std::env::args().nth(1) {
2074 - match command.as_str() {
2075 - "login" | "logout" | "whoami" => {
2076 - init_logging(false);
2077 - return match command.as_str() {
2078 - "login" => account::login().await,
2079 - "logout" => account::logout().await,
2080 - _ => account::whoami().await,
2081 - };
2082 - }
2083 - _ => {}
2084 - }
2085 - }
2086 -
2102 let is_tty = std::io::stdin().is_terminal();
2103
2104 if is_tty {
src/models.rs
+44 -1
@@ -16,6 +16,8 @@ pub(crate) enum ModelSource {
16 /// not downloaded yet — selecting it triggers a download into the app-group cache.
17 Available,
18 Fallback,
19 + /// a siGit Code Cloud tier (runs over the network, not on-device).
20 + Cloud,
21 }
22
23 #[derive(Clone)]
@@ -29,6 +31,8 @@ pub(crate) struct ModelPickerItem {
31
32 pub(crate) source: ModelSource,
33 pub(crate) cache_health: ModelCacheHealth,
34 + /// `Some(tier)` for a siGit Code Cloud entry; `None` for an on-device model.
35 + pub(crate) cloud_tier: Option<String>,
36 }
37
38 // ── Model ID → GgufModelConfig mapping ────────────────────────────────────────
@@ -105,10 +109,11 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
109
110 source: ModelSource::Available,
111 cache_health: ModelCacheHealth::NotDownloaded,
112 + cloud_tier: None,
113 });
114 }
115
111 - // ── 3. Fallback ──────────────────────────────────────────────────────
116 + // ── 3. Fallback (on-device default when nothing else is present) ─────
117 if items.is_empty() {
118 let config = GgufModelConfig::platform_default();
119 let tool_calling = is_tool_calling(&config.model_id);
@@ -124,6 +129,33 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
129
130 source: ModelSource::Fallback,
131 cache_health: ModelCacheHealth::Complete,
132 + cloud_tier: None,
133 + });
134 + }
135 +
136 + // ── 4. siGit Code Cloud tiers (always offered; sign-in gated at select) ─
137 + for tier in crate::provider::CLOUD_TIERS {
138 + let label = crate::provider::cloud_tier_label(tier);
139 + // Synthetic config: a `sigit-cloud:<tier>` id never collides with a real
140 + // HuggingFace id (no `/`), so on-device matching code stays inert.
141 + let config = GgufModelConfig {
142 + model_id: format!("sigit-cloud:{tier}"),
143 + files: Vec::new(),
144 + tok_model_id: None,
145 + display_name: label.clone(),
146 + approx_memory: "Cloud".to_string(),
147 + chat_template: None,
148 + };
149 + items.push(ModelPickerItem {
150 + display_name: label,
151 + description: "siGit Code Cloud".to_string(),
152 + tool_calling: true,
153 + max_tokens: 4096,
154 + config,
155 + source_label: "siGit Code Cloud".to_string(),
156 + source: ModelSource::Cloud,
157 + cache_health: ModelCacheHealth::Complete,
158 + cloud_tier: Some((*tier).to_string()),
159 });
160 }
161
@@ -136,6 +168,16 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
168 items
169 }
170
171 +/// Picker items restricted to on-device models (no cloud tiers). Used by the
172 +/// model-loading and ACP session-config paths, which only handle local GGUF
173 +/// models. The cloud tiers are an interactive TUI-picker feature.
174 +pub(crate) fn local_picker_items() -> Vec<ModelPickerItem> {
175 + build_model_picker_items()
176 + .into_iter()
177 + .filter(|item| item.cloud_tier.is_none())
178 + .collect()
179 +}
180 +
181 // ── Internal helpers ──────────────────────────────────────────────────────────
182
183 fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> {
@@ -164,5 +206,6 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker
206 ModelSource::HuggingFace
207 },
208 cache_health: model.cache_health,
209 + cloud_tier: None,
210 })
211 }
src/provider.rs
+38 -26
@@ -16,9 +16,33 @@ use serde::Deserialize;
16 /// (dev: `http://localhost:8090/v1`).
17 const DEFAULT_CLOUD_URL: &str = "https://cloud.ondeinference.com/v1";
18
19 -/// Default quality tier when the user hasn't chosen one. Override with `SIGIT_TIER`
20 -/// (`fast` | `balanced` | `large`).
21 -const DEFAULT_TIER: &str = "balanced";
19 +/// The cloud quality tiers, in display order. Always offered in `/models`;
20 +/// selecting one requires a signed-in account.
21 +pub const CLOUD_TIERS: &[&str] = &["fast", "balanced", "large"];
22 +
23 +/// Base URL of the siGit Code Cloud inference endpoint. Override with
24 +/// `SIGIT_CLOUD_URL` (dev: `http://localhost:8090/v1`).
25 +pub fn cloud_base_url() -> String {
26 + std::env::var("SIGIT_CLOUD_URL").unwrap_or_else(|_| DEFAULT_CLOUD_URL.to_string())
27 +}
28 +
29 +/// Display label for a tier, e.g. `siGit Code Cloud · Balanced`.
30 +pub fn cloud_tier_label(tier: &str) -> String {
31 + format!("siGit Code Cloud · {}", tier_title(tier))
32 +}
33 +
34 +/// Build a siGit Code Cloud provider for `tier`, if the user is signed in.
35 +/// Returns `None` when there is no stored session, so the caller can prompt for
36 +/// login rather than silently failing.
37 +pub fn cloud_tier_provider(tier: &str) -> Option<ProviderConfig> {
38 + let token = crate::credentials::load_token()?;
39 + Some(ProviderConfig {
40 + display_name: cloud_tier_label(tier),
41 + base_url: cloud_base_url(),
42 + api_key: token,
43 + model: tier_to_model(tier),
44 + })
45 +}
46
47 /// Map a neutral tier name to the model id sent on the wire. Unknown values pass
48 /// through unchanged so an explicit model id still works.
@@ -58,35 +82,23 @@ fn tier_title(tier: &str) -> String {
82 /// Default model id used when an environment-configured provider omits one.
83 const DEFAULT_ENV_MODEL: &str = "onde-large";
84
61 -/// Resolve the active provider, or `None` to run on-device.
85 +/// Resolve the startup provider, or `None` to run on-device.
86 +///
87 +/// This is only the explicit override (env or `providers.toml`), for power users
88 +/// and BYO endpoints. Being signed in does **not** auto-select the cloud: model
89 +/// choice is separate from identity, and the user picks a model or tier in
90 +/// `/models`. With no override, siGit Code is local-first.
91 pub fn active_provider() -> Option<ProviderConfig> {
63 - // 1. Explicit override (env or providers.toml).
92 if let Some(config) = from_env() {
93 return Some(config);
94 }
95 match from_config_file() {
68 - Ok(Some(config)) => return Some(config),
69 - Ok(None) => {}
70 - Err(error) => log::warn!("provider: ignoring providers.toml: {error}"),
96 + Ok(config) => config,
97 + Err(error) => {
98 + log::warn!("provider: ignoring providers.toml: {error}");
99 + None
100 + }
101 }
72 - // 2. siGit Code Cloud, used when logged in.
73 - from_login()
74 - // 3. Otherwise None, meaning on-device.
75 -}
76 -
77 -/// The siGit Code Cloud provider, used once the user has logged in. The session
78 -/// token is the credential.
79 -fn from_login() -> Option<ProviderConfig> {
80 - let token = crate::credentials::load_token()?;
81 - let base_url =
82 - std::env::var("SIGIT_CLOUD_URL").unwrap_or_else(|_| DEFAULT_CLOUD_URL.to_string());
83 - let tier = std::env::var("SIGIT_TIER").unwrap_or_else(|_| DEFAULT_TIER.to_string());
84 - Some(ProviderConfig {
85 - display_name: format!("siGit Code Cloud · {}", tier_title(&tier)),
86 - base_url,
87 - api_key: token,
88 - model: tier_to_model(&tier),
89 - })
102 }
103
104 /// Provider from environment variables, if both URL and key are present.