@sigitcode-old / sigit / commits / b0e7b7a
Fork 1

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

Wire ACP auth, cloud tiers, and slash commands for the Zed panel

Make the editor (ACP) path reach parity with the TUI for siGit Code Cloud. - handle_authenticate verifies the stored session against sigit.si /api/v1/me instead of being a no-op, so the panel's auth button reports status (and the Agent auth method's description points to /login). - Account verbs work as both ACP slash commands and `sigit login|logout|whoami` shell subcommands; add account::verify_session and interactive_login. - Advertise slash commands via AvailableCommandsUpdate on session create/load/fork — without this Zed rejects /login, /models, etc. client-side. - Show siGit Code Cloud tiers in the panel model picker and /models list; selecting a tier hot-swaps the backend (sign-in gated) via shared switch_to_cloud_tier / reset_to_local_backend helpers. - Route the ACP prompt loop through InferenceBackend (was calling the onde ChatEngine directly) so cloud tiers actually run; skip local model load when the active backend is remote, and carry the project cwd into the cloud prompt. - Fix /models cloud section rendering (blank line so items 9+ form an ordered list under CommonMark). Deps: enable agent-client-protocol unstable_auth_methods; add rpassword. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 24, 2026 at 15:21 UTC b0e7b7acf7ae3c68e0311b8f311d60785e6b16af
4 files changed +318 -40
Cargo.lock
+22
@@ -4586,6 +4586,27 @@ 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 +
4610 [[package]]
4611 name = "rubato"
4612 version = "0.16.2"
@@ -5202,6 +5223,7 @@ dependencies = [
5223 "ratatui",
5224 "regex",
5225 "reqwest 0.12.28",
5226 + "rpassword",
5227 "serde",
5228 "serde_json",
5229 "tokio",
Cargo.toml
+2 -1
@@ -18,7 +18,7 @@ path = "src/main.rs"
18
19 [dependencies]
20 # ACP protocol SDK
21 -agent-client-protocol = { version = "0.13", features = ["unstable_session_fork", "unstable_session_additional_directories"] }
21 +agent-client-protocol = { version = "0.13", features = ["unstable_session_fork", "unstable_session_additional_directories", "unstable_auth_methods"] }
22
23 # Onde Inference engine (local LLM)
24 # onde = { path = "../onde" }
@@ -45,3 +45,4 @@ async-trait = "0.1"
45 regex = "1"
46 reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
47 uuid = { version = "1", features = ["v4"] }
48 +rpassword = "7"
src/account.rs
+54
@@ -90,6 +90,60 @@ pub async fn authenticate(email: &str, password: &str) -> Result<String, String>
90 Err(format!("sign-in failed: {message}"))
91 }
92
93 +/// Verify that a usable session exists, confirming the stored token against the
94 +/// account API. Returns the signed-in email on success, or a human-readable
95 +/// reason it is not usable. Used by the ACP `authenticate` handler to answer the
96 +/// editor's auth gate.
97 +pub async fn verify_session() -> Result<String, String> {
98 + let Some(creds) = credentials::load() else {
99 + return Err("not signed in".to_string());
100 + };
101 +
102 + let url = format!("{}/api/v1/me", api_base().trim_end_matches('/'));
103 + let response = reqwest::Client::new()
104 + .get(&url)
105 + .bearer_auth(&creds.access_token)
106 + .send()
107 + .await
108 + .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?;
109 +
110 + if response.status().is_success() {
111 + let email = response
112 + .json::<MeResponse>()
113 + .await
114 + .ok()
115 + .and_then(|me| me.email)
116 + .or(creds.email)
117 + .unwrap_or_else(|| "(unknown)".to_string());
118 + Ok(email)
119 + } else {
120 + Err(format!(
121 + "session expired (HTTP {})",
122 + response.status().as_u16()
123 + ))
124 + }
125 +}
126 +
127 +/// Run an interactive sign-in against the terminal, prompting for email and a
128 +/// hidden password. Used by `sigit login`, which the editor launches in an
129 +/// embedded terminal for ACP terminal-based authentication. Returns the signed-in
130 +/// email or a human-readable error.
131 +pub async fn interactive_login() -> Result<String, String> {
132 + use std::io::{Write, stdin, stdout};
133 +
134 + print!("siGit Code email: ");
135 + stdout().flush().ok();
136 + let mut email = String::new();
137 + stdin()
138 + .read_line(&mut email)
139 + .map_err(|error| format!("could not read email: {error}"))?;
140 +
141 + let password = rpassword::prompt_password("siGit Code password: ")
142 + .map_err(|error| format!("could not read password: {error}"))?;
143 +
144 + authenticate(email.trim(), &password).await
145 +}
146 +
147 /// Clear the local session, notifying the server best-effort. Returns a message
148 /// suitable for display.
149 pub async fn end_session() -> String {
src/main.rs
+240 -39
@@ -46,6 +46,7 @@ use onde::inference::SamplingConfig;
46
47 use agent_client_protocol::schema::{
48 AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
49 + AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, UnstructuredCommandInput,
50 CancelNotification, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
51 ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
52 LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PromptRequest,
@@ -56,12 +57,11 @@ use agent_client_protocol::schema::{
57 ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
58 };
59 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
59 -use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult};
60 +use onde::inference::{ChatEngine, GgufModelConfig};
61
61 -// These back the interactive client (`run_interactive`), which is `#[cfg(unix)]`;
62 -// the import is unused on non-Unix targets that run ACP-only.
63 -#[cfg_attr(not(unix), allow(unused_imports))]
64 -use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend};
62 +use crate::backend::{
63 + InferenceBackend, LocalBackend, OpenAiBackend, ToolResult as BackendToolResult, ToolSpec,
64 +};
65 use std::path::PathBuf;
66 use std::sync::atomic::{AtomicBool, Ordering};
67 use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
@@ -210,10 +210,15 @@ pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str {
210 /// cap tool-call loops so a confused model can't spin forever
211 const MAX_TOOL_ROUNDS: usize = 10;
212
213 -fn agent_tools_as_onde() -> Vec<ToolDefinition> {
213 +/// Shown when a siGit Code Cloud tier is selected without a signed-in account.
214 +const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in with \
215 + `/login <email> <password>` (or the Authenticate button), then pick the tier again. \
216 + Create an account at https://sigit.si.";
217 +
218 +fn agent_tools_as_specs() -> Vec<ToolSpec> {
219 tools::all_tools()
220 .into_iter()
216 - .map(|t| ToolDefinition {
221 + .map(|t| ToolSpec {
222 name: t.name.to_string(),
223 description: t.description.to_string(),
224 parameters_schema: t.parameters_schema.to_string(),
@@ -271,6 +276,9 @@ fn initialize_meta() -> Meta {
276
277 struct SiGitAgent {
278 engine: Arc<ChatEngine>,
279 + /// The active inference backend. `LocalBackend` by default; swapped to an
280 + /// `OpenAiBackend` when the user selects a siGit Code Cloud tier in the panel.
281 + backend: tokio::sync::Mutex<Arc<dyn InferenceBackend>>,
282 /// cwd from the editor — tool calls run here, not where the process started
283 session_cwd: std::sync::Mutex<Option<PathBuf>>,
284 current_model: std::sync::Mutex<GgufModelConfig>,
@@ -299,8 +307,11 @@ impl SiGitAgent {
307 ) -> Self {
308 let startup_model_name = initial_model.display_name.clone();
309 let startup_model_id = initial_model.model_id.clone();
310 + let backend: Arc<dyn InferenceBackend> =
311 + Arc::new(LocalBackend::new(Arc::clone(&engine)));
312 Self {
313 engine,
314 + backend: tokio::sync::Mutex::new(backend),
315 session_cwd: std::sync::Mutex::new(None),
316 current_model: std::sync::Mutex::new(initial_model),
317 model_ready,
@@ -567,6 +578,35 @@ impl SiGitAgent {
578 cx.send_notification(SessionNotification::new(session_id, update))
579 }
580
581 + /// Advertise siGit's slash commands to the client. Editors like Zed parse
582 + /// `/`-prefixed input and only forward commands they've been told about, so
583 + /// without this `/login`, `/models`, etc. are rejected client-side.
584 + fn advertise_commands(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
585 + let with_hint = |name: &str, desc: &str, hint: &str| {
586 + AvailableCommand::new(name, desc).input(AvailableCommandInput::Unstructured(
587 + UnstructuredCommandInput::new(hint),
588 + ))
589 + };
590 + let commands = vec![
591 + AvailableCommand::new("help", "Show available commands"),
592 + AvailableCommand::new("models", "List available models")
593 + .input(AvailableCommandInput::Unstructured(
594 + UnstructuredCommandInput::new("model number to switch to (optional)"),
595 + )),
596 + with_hint("login", "Sign in to siGit Code Cloud", "<email> <password>"),
597 + AvailableCommand::new("logout", "Sign out of siGit Code Cloud"),
598 + AvailableCommand::new("whoami", "Show the signed-in account"),
599 + AvailableCommand::new("clear", "Wipe the conversation history"),
600 + AvailableCommand::new("status", "Show engine status"),
601 + ];
602 + self.send_tool_call_update(
603 + cx,
604 + session_id,
605 + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)),
606 + )
607 + .ok();
608 + }
609 +
610 async fn switch_model_by_id(
611 &self,
612 model_id: &str,
@@ -669,15 +709,23 @@ impl SiGitAgent {
709 ) -> agent_client_protocol::Result<InitializeResponse> {
710 log::info!("initialize");
711
712 + // Agent-handled auth method. We don't use `AuthMethod::Terminal`: editors
713 + // like Zed advertise terminal-auth capability but don't actually spawn the
714 + // login terminal for *custom* ACP agents, so the button is a silent no-op.
715 + // With an Agent method, clicking calls `authenticate`, which returns either
716 + // confirmation (already signed in via `/login`) or a message telling the
717 + // user to run `/login <email> <password>` — so the button does something.
718 + let auth_methods = vec![AuthMethod::Agent(
719 + AuthMethodAgent::new("sigit", "Sign in to siGit Code")
720 + .description("Sign in with `/login <email> <password>` in the message box."),
721 + )];
722 +
723 Ok(InitializeResponse::new(ProtocolVersion::V1)
724 .agent_info(
725 Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
726 .title("siGit Code - AI Coding Agent"),
727 )
677 - .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new(
678 - "sigit",
679 - "siGit Code",
680 - ))])
728 + .auth_methods(auth_methods)
729 .agent_capabilities(
730 AgentCapabilities::default()
731 .load_session(true)
@@ -690,14 +738,32 @@ impl SiGitAgent {
738
739 async fn handle_authenticate(
740 &self,
693 - _req: AuthenticateRequest,
741 + req: AuthenticateRequest,
742 ) -> agent_client_protocol::Result<AuthenticateResponse> {
695 - log::info!("authenticate");
696 - Ok(AuthenticateResponse::default())
743 + log::info!("authenticate: method={}", req.method_id.0);
744 +
745 + // Confirm the stored token works. The button can't collect a password,
746 + // so an unsigned-in user is pointed at the `/login` slash command; a user
747 + // already signed in via `/login` gets the gate cleared.
748 + match account::verify_session().await {
749 + Ok(email) => {
750 + log::info!("authenticate: verified session for {email}");
751 + Ok(AuthenticateResponse::default())
752 + }
753 + Err(reason) => Err(agent_client_protocol::Error::new(
754 + -32000,
755 + format!(
756 + "Not signed in to siGit Code Cloud ({reason}). \
757 + Sign in with `/login <email> <password>` in the message box, \
758 + or create an account at https://sigit.si."
759 + ),
760 + )),
761 + }
762 }
763
764 async fn handle_load_session(
765 &self,
766 + cx: &ConnectionTo<Client>,
767 args: LoadSessionRequest,
768 ) -> agent_client_protocol::Result<LoadSessionResponse> {
769 log::info!(
@@ -739,11 +805,14 @@ impl SiGitAgent {
805 build_model_config_options(&guard)
806 };
807
808 + self.advertise_commands(cx, args.session_id.clone());
809 +
810 Ok(LoadSessionResponse::new().config_options(config_options))
811 }
812
813 async fn handle_fork_session(
814 &self,
815 + cx: &ConnectionTo<Client>,
816 args: ForkSessionRequest,
817 ) -> agent_client_protocol::Result<ForkSessionResponse> {
818 let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
@@ -784,11 +853,14 @@ impl SiGitAgent {
853 build_model_config_options(&guard)
854 };
855
856 + self.advertise_commands(cx, new_id.clone());
857 +
858 Ok(ForkSessionResponse::new(new_id).config_options(config_options))
859 }
860
861 async fn handle_new_session(
862 &self,
863 + cx: &ConnectionTo<Client>,
864 args: NewSessionRequest,
865 ) -> agent_client_protocol::Result<NewSessionResponse> {
866 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
@@ -827,6 +899,8 @@ impl SiGitAgent {
899 build_model_config_options(&guard)
900 };
901
902 + self.advertise_commands(cx, session_id.clone());
903 +
904 Ok(NewSessionResponse::new(session_id).config_options(config_options))
905 }
906
@@ -984,20 +1058,25 @@ impl SiGitAgent {
1058 user_text.chars().take(80).collect::<String>()
1059 );
1060
987 - // load the default ACP model lazily so initialize/session/new stay clean
988 - // for registry validation and editor startup.
989 - self.start_startup_model_load_if_needed();
990 - self.await_model_ready(cx, &session_id).await?;
1061 + // The active backend drives the turn. Snapshot it once so a mid-turn
1062 + // model switch doesn't split the conversation across backends.
1063 + let backend = self.backend.lock().await.clone();
1064 +
1065 + // Only on-device inference needs a local model in memory. Cloud tiers run
1066 + // over the network, so skip the lazy load and the readiness wait for them.
1067 + if !backend.is_remote() {
1068 + self.start_startup_model_load_if_needed();
1069 + self.await_model_ready(cx, &session_id).await?;
1070 + }
1071
1072 // ── tool-calling loop ────────────────────────────────────────────
1073 // send message → execute any tool calls → feed results back
1074 // repeat up to MAX_TOOL_ROUNDS, then force a text reply
1075
996 - let onde_tools = agent_tools_as_onde();
1076 + let tools = agent_tools_as_specs();
1077
998 - let mut result = self
999 - .engine
1000 - .send_message_with_tools(&user_text, &onde_tools)
1078 + let mut result = backend
1079 + .send_message_with_tools(&user_text, &tools)
1080 .await
1081 .map_err(|error| {
1082 log::error!("send_message_with_tools failed: {error}");
@@ -1020,28 +1099,27 @@ impl SiGitAgent {
1099 for tc in &result.tool_calls {
1100 log::info!(
1101 " → {}({})",
1023 - tc.function_name,
1102 + tc.name,
1103 tc.arguments.chars().take(120).collect::<String>()
1104 );
1105
1027 - let output = tools::execute_tool(&tc.function_name, &tc.arguments).await;
1106 + let output = tools::execute_tool(&tc.name, &tc.arguments).await;
1107
1108 log::info!(" ← {} chars", output.len());
1109
1031 - tool_results.push(ToolResult {
1110 + tool_results.push(BackendToolResult {
1111 tool_call_id: tc.id.clone(),
1112 content: output,
1113 });
1114 }
1115
1116 let next_tools = if round < MAX_TOOL_ROUNDS {
1038 - Some(onde_tools.as_slice())
1117 + Some(tools.as_slice())
1118 } else {
1119 None // last round: force text
1120 };
1121
1043 - result = self
1044 - .engine
1122 + result = backend
1123 .send_tool_results(tool_results, next_tools)
1124 .await
1125 .map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?;
@@ -1085,6 +1163,57 @@ impl SiGitAgent {
1163 Ok(())
1164 }
1165
1166 + /// Swap the active backend to a siGit Code Cloud tier and reflect it as the
1167 + /// current model so the picker shows it selected. Returns the tier's display
1168 + /// name on success, or `None` when no account is signed in (caller prompts
1169 + /// for login). Shared by the panel picker and the `/models` slash command.
1170 + async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
1171 + let cfg = crate::provider::cloud_tier_provider(tier)?;
1172 + let mut system_prompt = system_prompt_for_model(true).to_string();
1173 + // Mirror the cwd guidance the local engine gets at session load, so the
1174 + // cloud model also uses absolute paths under the editor's project root.
1175 + if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
1176 + system_prompt.push_str(&format!(
1177 + "\n\nThe user's project working directory is {}. \
1178 + Always use absolute paths under this directory for all file \
1179 + and directory operations.",
1180 + cwd.display()
1181 + ));
1182 + }
1183 + let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
1184 + cfg.base_url,
1185 + cfg.api_key,
1186 + cfg.model,
1187 + Some(system_prompt),
1188 + ));
1189 + *self.backend.lock().await = cloud_backend;
1190 +
1191 + let cloud_config = GgufModelConfig {
1192 + model_id: format!("sigit-cloud:{tier}"),
1193 + files: Vec::new(),
1194 + tok_model_id: None,
1195 + display_name: cfg.display_name.clone(),
1196 + approx_memory: "Cloud".to_string(),
1197 + chat_template: None,
1198 + };
1199 + {
1200 + let mut guard = self.current_model.lock().unwrap();
1201 + *guard = cloud_config;
1202 + }
1203 +
1204 + log::info!("switched to cloud tier {tier}");
1205 + Some(cfg.display_name)
1206 + }
1207 +
1208 + /// Route inference back on-device. Used after leaving a cloud tier for a
1209 + /// local model. The `LocalBackend` reads the live `engine`, so this just
1210 + /// repoints the active backend.
1211 + async fn reset_to_local_backend(&self) {
1212 + let local_backend: Arc<dyn InferenceBackend> =
1213 + Arc::new(LocalBackend::new(Arc::clone(&self.engine)));
1214 + *self.backend.lock().await = local_backend;
1215 + }
1216 +
1217 async fn handle_set_session_config_option(
1218 &self,
1219 cx: &ConnectionTo<Client>,
@@ -1129,6 +1258,20 @@ impl SiGitAgent {
1258 }
1259 }
1260
1261 + // ── siGit Code Cloud tier: no local load; sign-in gated ─────────────
1262 + if let Some(tier) = model_id.strip_prefix("sigit-cloud:") {
1263 + let message = match self.switch_to_cloud_tier(tier).await {
1264 + Some(display_name) => format!("Switched to {display_name}."),
1265 + None => CLOUD_LOGIN_PROMPT.to_string(),
1266 + };
1267 + self.send_assistant_message(cx, args.session_id.clone(), message)
1268 + .ok();
1269 +
1270 + let current = self.current_model.lock().unwrap().clone();
1271 + let config_options = build_model_config_options(&current);
1272 + return Ok(SetSessionConfigOptionResponse::new(config_options));
1273 + }
1274 +
1275 let needs_download = models::local_picker_items()
1276 .into_iter()
1277 .find(|item| item.config.model_id == model_id)
@@ -1331,6 +1474,9 @@ impl SiGitAgent {
1474
1475 match switch_result {
1476 Ok(new_config) => {
1477 + // Route inference back on-device (in case we were on a cloud tier).
1478 + self.reset_to_local_backend().await;
1479 +
1480 let completion_title = if needs_download {
1481 format!("✓ {} downloaded and loaded", new_config.display_name)
1482 } else {
@@ -1389,7 +1535,9 @@ impl SiGitAgent {
1535 const MODEL_CONFIG_ID: &str = "sigit-model";
1536
1537 fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
1392 - let items = models::local_picker_items();
1538 + // The full list, including the siGit Code Cloud tiers, so the panel picker
1539 + // mirrors the TUI `/models`. Cloud entries are sign-in gated at selection.
1540 + let items = models::build_model_picker_items();
1541
1542 let options: Vec<SessionConfigSelectOption> = items
1543 .iter()
@@ -1404,7 +1552,9 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
1552 desc_parts.push("↓ download on select".to_string());
1553 }
1554 let description = desc_parts.join(" - ");
1407 - let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
1555 + let source_badge = if item.cloud_tier.is_some() {
1556 + " [☁ siGit Code Cloud]"
1557 + } else if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
1558 " [↓ Onde]"
1559 } else {
1560 match item.source_label.as_str() {
@@ -1431,7 +1581,7 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
1581 vec![
1582 SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
1583 .category(SessionConfigOptionCategory::Model)
1434 - .description("Select the local LLM model for inference"),
1584 + .description("Select an on-device model or a siGit Code Cloud tier"),
1585 ]
1586 }
1587
@@ -1485,7 +1635,7 @@ fn parse_slash(input: &str) -> Option<SlashCommand> {
1635 }
1636
1637 fn format_models_list(current_model: &GgufModelConfig) -> String {
1488 - let items = models::local_picker_items();
1638 + let items = models::build_model_picker_items();
1639 if items.is_empty() {
1640 return "No local models found. siGit will use the platform default model.".to_string();
1641 }
@@ -1497,6 +1647,7 @@ fn format_models_list(current_model: &GgufModelConfig) -> String {
1647 let source_key = match item.source_label.as_str() {
1648 "Onde" => "Onde",
1649 "HuggingFace" => "HuggingFace",
1650 + "siGit Code Cloud" => "Cloud",
1651 _ => "Fallback",
1652 };
1653
@@ -1507,9 +1658,15 @@ fn format_models_list(current_model: &GgufModelConfig) -> String {
1658 let section = match source_key {
1659 "Onde" => "Onde Inference",
1660 "HuggingFace" => "Hugging Face cache",
1661 + "Cloud" => "siGit Code Cloud",
1662 _ => "Fallback",
1663 };
1664 lines.push(section.to_string());
1665 + // Blank line so the following "N." items render as an ordered list.
1666 + // CommonMark only lets an ordered list interrupt a paragraph when it
1667 + // starts at 1, so without this the cloud section (items 9+) would be
1668 + // absorbed into the header paragraph.
1669 + lines.push(String::new());
1670 last_source = Some(source_key);
1671 }
1672
@@ -1532,6 +1689,7 @@ fn format_models_list(current_model: &GgufModelConfig) -> String {
1689 let source = match source_key {
1690 "Onde" => " [Onde]",
1691 "HuggingFace" => " [HuggingFace]",
1692 + "Cloud" => " [☁ Cloud]",
1693 _ => " [default]",
1694 };
1695
@@ -1602,7 +1760,7 @@ async fn exec_slash_acp(
1760 .ok();
1761 }
1762 SlashCommand::Models(Some(number)) => {
1605 - let items = models::local_picker_items();
1763 + let items = models::build_model_picker_items();
1764 let index = number.saturating_sub(1);
1765 match items.get(index).cloned() {
1766 None => {
@@ -1614,6 +1772,15 @@ async fn exec_slash_acp(
1772 )
1773 .ok();
1774 }
1775 + Some(model) if model.cloud_tier.is_some() => {
1776 + // siGit Code Cloud tier: swap backend, sign-in gated.
1777 + let tier = model.cloud_tier.clone().unwrap_or_default();
1778 + let message = match agent.switch_to_cloud_tier(&tier).await {
1779 + Some(display_name) => format!("Switched to {display_name}."),
1780 + None => CLOUD_LOGIN_PROMPT.to_string(),
1781 + };
1782 + agent.send_assistant_message(cx, session_id, message).ok();
1783 + }
1784 Some(model) => {
1785 if model.cache_health == setup::ModelCacheHealth::Incomplete {
1786 agent
@@ -1640,6 +1807,7 @@ async fn exec_slash_acp(
1807
1808 match agent.switch_model_by_id(&model.config.model_id).await {
1809 Ok(new_config) => {
1810 + agent.reset_to_local_backend().await;
1811 agent.engine.clear_history().await;
1812 agent
1813 .send_assistant_message(
@@ -1672,6 +1840,7 @@ async fn exec_slash_acp(
1840 .ok();
1841
1842 let switched = agent.switch_model_by_id(&model.config.model_id).await?;
1843 + agent.reset_to_local_backend().await;
1844 agent.engine.clear_history().await;
1845
1846 agent
@@ -2040,8 +2209,8 @@ async fn run_acp_server() -> anyhow::Result<()> {
2209 .on_receive_request(
2210 {
2211 let state = Arc::clone(&state);
2043 - async move |req: LoadSessionRequest, responder, _cx: ConnectionTo<Client>| {
2044 - handle_response(responder, state.handle_load_session(req).await)
2212 + async move |req: LoadSessionRequest, responder, cx: ConnectionTo<Client>| {
2213 + handle_response(responder, state.handle_load_session(&cx, req).await)
2214 }
2215 },
2216 agent_client_protocol::on_receive_request!(),
@@ -2049,8 +2218,8 @@ async fn run_acp_server() -> anyhow::Result<()> {
2218 .on_receive_request(
2219 {
2220 let state = Arc::clone(&state);
2052 - async move |req: ForkSessionRequest, responder, _cx: ConnectionTo<Client>| {
2053 - handle_response(responder, state.handle_fork_session(req).await)
2221 + async move |req: ForkSessionRequest, responder, cx: ConnectionTo<Client>| {
2222 + handle_response(responder, state.handle_fork_session(&cx, req).await)
2223 }
2224 },
2225 agent_client_protocol::on_receive_request!(),
@@ -2058,8 +2227,8 @@ async fn run_acp_server() -> anyhow::Result<()> {
2227 .on_receive_request(
2228 {
2229 let state = Arc::clone(&state);
2061 - async move |req: NewSessionRequest, responder, _cx: ConnectionTo<Client>| {
2062 - handle_response(responder, state.handle_new_session(req).await)
2230 + async move |req: NewSessionRequest, responder, cx: ConnectionTo<Client>| {
2231 + handle_response(responder, state.handle_new_session(&cx, req).await)
2232 }
2233 },
2234 agent_client_protocol::on_receive_request!(),
@@ -2108,6 +2277,38 @@ async fn run_acp_server() -> anyhow::Result<()> {
2277
2278 #[tokio::main]
2279 async fn main() -> anyhow::Result<()> {
2280 + // Account subcommands. The editor launches `sigit login` in an embedded
2281 + // terminal for ACP terminal-based authentication; the same verbs are handy
2282 + // directly from a shell. These must be handled before the TTY/ACP split.
2283 + if let Some(verb) = std::env::args().nth(1) {
2284 + match verb.as_str() {
2285 + "login" => {
2286 + init_logging(true);
2287 + match account::interactive_login().await {
2288 + Ok(email) => {
2289 + println!("Signed in to siGit Code Cloud as {email}.");
2290 + return Ok(());
2291 + }
2292 + Err(error) => {
2293 + eprintln!("Login failed: {error}");
2294 + std::process::exit(1);
2295 + }
2296 + }
2297 + }
2298 + "logout" => {
2299 + init_logging(true);
2300 + println!("{}", account::end_session().await);
2301 + return Ok(());
2302 + }
2303 + "whoami" => {
2304 + init_logging(true);
2305 + println!("{}", account::status_line().await);
2306 + return Ok(());
2307 + }
2308 + _ => {}
2309 + }
2310 + }
2311 +
2312 let is_tty = std::io::stdin().is_terminal();
2313
2314 if is_tty {