Offer the Inference switch as a toggle to clients that render one

A client that advertises session.configOptions.boolean now gets the local/cloud Inference option as a boolean "Local inference" toggle. Every other client keeps the two-value select it has today, and session/set_config_option accepts a boolean or one of the select's ids for it. The capability is read once in initialize, since one process serves one connection. Closes #198 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 9, 2026 at 00:21 UTC fa9847015daa00c21bee1ebae7249de8a618934d
3 files changed +110 -13
.agents/AGENTS.md
+3
@@ -122,6 +122,9 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
122 it, reasoning included, shares `StreamedReply::message_id` and a tool round starts a new id.
123 A standalone notice (`send_assistant_message`) gets an id of its own, and replayed messages
124 get fresh ones; ids are random, so none is stored in history.
125 + The Inference (local/cloud) config option is a `boolean` for a client that advertises
126 + `session.configOptions.boolean` (read into `CLIENT_BOOLEAN_CONFIG_OPTIONS` in `initialize`)
127 + and a two-value select for any other; `handle_set_session_config_option` takes either value.
128 `session/load` and `session/resume` share `restore_session`; the only difference is that
129 resume must not replay the history as `session/update`. `session/close` is the one place a
130 `SessionState` is dropped. It runs in two halves: `begin_close` signals the session's turn
src/main.rs
+39 -13
@@ -2220,6 +2220,18 @@ impl SiGitAgent {
2220 client_terminal::register(Arc::new(AcpClientTerminal { cx: cx.clone() }), terminal);
2221 }
2222
2223 + // A client that renders boolean config options gets the Inference
2224 + // switch as a toggle; the rest keep the two-value select.
2225 + let boolean_options = req
2226 + .client_capabilities
2227 + .session
2228 + .as_ref()
2229 + .and_then(|session| session.config_options.as_ref())
2230 + .and_then(|config_options| config_options.boolean.as_ref())
2231 + .is_some();
2232 + log::info!("boolean config options: {boolean_options}");
2233 + CLIENT_BOOLEAN_CONFIG_OPTIONS.store(boolean_options, Ordering::Relaxed);
2234 +
2235 // Agent-handled auth method. We don't use `AuthMethod::Terminal`: editors
2236 // like Zed advertise terminal-auth capability but don't actually spawn the
2237 // login terminal for *custom* ACP agents, so the button is a silent no-op.
@@ -4398,10 +4410,15 @@ impl SiGitAgent {
4410
4411 // ── Local Inference toggle ──────────────────────────────────────────
4412 if args.config_id.0.as_ref() == LOCAL_INFERENCE_CONFIG_ID {
4401 - let enabled = match args.value.as_value_id().map(|v| v.0.as_ref()) {
4402 - Some(LOCAL_INFERENCE_ON) => true,
4403 - Some(LOCAL_INFERENCE_OFF) => false,
4404 - other => {
4413 + // A toggle sends a boolean, the select fallback one of its ids.
4414 + let enabled = match (
4415 + args.value.as_bool(),
4416 + args.value.as_value_id().map(|v| v.0.as_ref()),
4417 + ) {
4418 + (Some(enabled), _) => enabled,
4419 + (_, Some(LOCAL_INFERENCE_ON)) => true,
4420 + (_, Some(LOCAL_INFERENCE_OFF)) => false,
4421 + (_, other) => {
4422 return Err(agent_client_protocol::Error::new(
4423 -32602,
4424 format!("unknown Local Inference value: {other:?}"),
@@ -4790,11 +4807,16 @@ impl SiGitAgent {
4807 /// config option ID for the model picker in Zed's agent panel
4808 const MODEL_CONFIG_ID: &str = "sigit-model";
4809
4793 -/// config option ID for the Local Inference on/off toggle. Surfaced as a
4794 -/// two-option `select` so ACP clients without slash-command support (e.g. Xcode)
4795 -/// can still flip the mode from the agent panel.
4810 +/// config option ID for the Local Inference on/off toggle, so ACP clients
4811 +/// without slash-command support (e.g. Xcode) can still flip the mode from the
4812 +/// agent panel. A `boolean` option for clients that advertise
4813 +/// `session.configOptions.boolean`, a two-option `select` for the rest.
4814 const LOCAL_INFERENCE_CONFIG_ID: &str = "sigit-local-inference";
4815
4816 +/// Whether the connected client advertised boolean config options. One
4817 +/// process serves one ACP connection, so this is set once in `initialize`.
4818 +static CLIENT_BOOLEAN_CONFIG_OPTIONS: AtomicBool = AtomicBool::new(false);
4819 +
4820 /// `select` value ids for the Local Inference toggle.
4821 const LOCAL_INFERENCE_ON: &str = "local-inference-on";
4822 const LOCAL_INFERENCE_OFF: &str = "local-inference-off";
@@ -4987,12 +5009,16 @@ fn build_config_options(
5009 )
5010 .description("Use siGit Code Cloud; cloud tiers are highlighted".to_string()),
5011 ];
4990 - let local_option = SessionConfigOption::select(
4991 - LOCAL_INFERENCE_CONFIG_ID,
4992 - "Inference",
4993 - local_current,
4994 - local_options,
4995 - )
5012 + let local_option = if CLIENT_BOOLEAN_CONFIG_OPTIONS.load(Ordering::Relaxed) {
5013 + SessionConfigOption::boolean(LOCAL_INFERENCE_CONFIG_ID, "Local inference", local_on)
5014 + } else {
5015 + SessionConfigOption::select(
5016 + LOCAL_INFERENCE_CONFIG_ID,
5017 + "Inference",
5018 + local_current,
5019 + local_options,
5020 + )
5021 + }
5022 .description("Toggle on-device inference; changes which models are highlighted");
5023
5024 // Permissions dropdown (issue #76): Manual (ask), Auto (run unattended),
tests/acp_permissions.rs
+68
@@ -723,6 +723,74 @@ fn a_call_denied_for_the_session_is_not_asked_about_again() {
723 let _ = std::fs::remove_dir_all(&scratch);
724 }
725
726 +/// A client that renders boolean config options gets the Inference switch as
727 +/// a toggle, and can flip it with a boolean value; any other client keeps the
728 +/// two-value select (issue #198).
729 +#[test]
730 +fn the_inference_switch_is_a_toggle_for_clients_that_render_one() {
731 + let inference_option = |options: &Value| -> Value {
732 + options
733 + .as_array()
734 + .expect("config options")
735 + .iter()
736 + .find(|option| option["id"] == "sigit-local-inference")
737 + .cloned()
738 + .expect("inference option")
739 + };
740 +
741 + for (capabilities, expected_type) in [
742 + (
743 + json!({"session": {"configOptions": {"boolean": {}}}}),
744 + "boolean",
745 + ),
746 + (json!({}), "select"),
747 + ] {
748 + let endpoint = start_fake_endpoint(vec![]);
749 + let scratch = std::env::temp_dir().join(format!(
750 + "sigit_acp_bool_option_{expected_type}_{}",
751 + std::process::id()
752 + ));
753 + let config_dir = scratch.join("config");
754 + let cwd = scratch.join("cwd");
755 + std::fs::create_dir_all(&config_dir).unwrap();
756 + std::fs::create_dir_all(&cwd).unwrap();
757 +
758 + let mut agent = spawn_agent(endpoint.port, &config_dir);
759 + let id = agent.request(
760 + "initialize",
761 + json!({"protocolVersion": 1, "clientCapabilities": capabilities}),
762 + );
763 + agent.wait_for_response(id);
764 +
765 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
766 + let created = agent.wait_for_response(id);
767 + let session_id = created["result"]["sessionId"]
768 + .as_str()
769 + .expect("session id")
770 + .to_string();
771 + let option = inference_option(&created["result"]["configOptions"]);
772 + assert_eq!(option["type"], expected_type, "{option}");
773 +
774 + if expected_type == "boolean" {
775 + let id = agent.request(
776 + "session/set_config_option",
777 + json!({
778 + "sessionId": session_id,
779 + "configId": "sigit-local-inference",
780 + "type": "boolean",
781 + "value": true,
782 + }),
783 + );
784 + let response = agent.wait_for_response(id);
785 + let option = inference_option(&response["result"]["configOptions"]);
786 + assert_eq!(option["currentValue"], true, "{option}");
787 + }
788 +
789 + drop(agent);
790 + let _ = std::fs::remove_dir_all(&scratch);
791 + }
792 +}
793 +
794 #[test]
795 fn successful_config_option_changes_are_rendered_as_system_status_cards() {
796 let endpoint = start_fake_endpoint(vec![]);