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.