@setoelkahfi / sigit / commits / b0e7b7a

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 {