@setoelkahfi / sigit / commits / 99d4bfe

Add Local Inference on/off toggle with state-aware model picker

Introduce a persisted "Local Inference" setting that becomes the explicit local-vs-cloud mode switch and drives how the model picker is presented. - New src/settings.rs: TOML-backed preferences at $SIGIT_CONFIG_DIR/settings.toml (local_inference, default on / local-first), with a SIGIT_LOCAL_INFERENCE env override. Mirrors the credentials.rs storage pattern. - models.rs: group picker items by inference nature (Local vs Cloud) and sort the active mode's group first so the highlighted group leads the list. - TUI (chat.rs): /local [on|off] command, a "Local inference: ON/OFF" banner, and Local/Cloud group headers with the inactive group dimmed (still shown, so the cloud offering stays surfaced). Selecting a model from the inactive group flips the persisted mode to match. - ACP (main.rs): /local slash command, a second "Local Inference" select config option (On/Off) so panel-only clients without slash-command support can flip the mode, ConfigOptionUpdate refresh, and startup resolution that routes to a cloud tier when local inference is off and an account is signed in (falling back to on-device with a notice when not signed in). - Serialize the env-mutating credentials/settings unit tests behind a shared lock to avoid a parallel-test race on SIGIT_CONFIG_DIR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016K7GDe9d6cLd9RkWYCFGmm

Claude committed Jun 29, 2026 at 16:42 UTC 99d4bfe91b0643d042fed0af0a84ffcec91740a1
5 files changed +526 -23
src/chat.rs
+119 -4
index 953a708..84eb8b5 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -78,7 +78,9 @@ mod tui { use onde::inference::{ChatEngine, SamplingConfig}; use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend, ToolResult, ToolSpec}; - use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items}; + use crate::models::{ + InferenceKind, ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items, + }; use ratatui::{ Frame, layout::{Constraint, Layout, Position}, @@ -470,10 +472,68 @@ mod tui { let inner = block.inner(popup); frame.render_widget(block, popup); + let active_kind = crate::models::active_inference_kind(); let mut lines = Vec::new(); + + // State banner: which mode is active, and how to flip it. + let (state_word, state_style) = match active_kind { + InferenceKind::Local => ( + "ON (on-device)", + Style::default().fg(Color::Green).bg(Color::Black), + ), + InferenceKind::Cloud => ( + "OFF (siGit Code Cloud)", + Style::default().fg(Color::Magenta).bg(Color::Black), + ), + }; + lines.push(Line::from(vec![ + Span::styled( + "Local inference: ", + Style::default() + .fg(Color::White) + .bg(Color::Black) + .add_modifier(Modifier::BOLD), + ), + Span::styled(state_word, state_style.add_modifier(Modifier::BOLD)), + Span::styled( + " toggle with /local on|off", + Style::default().fg(Color::DarkGray).bg(Color::Black), + ), + ])); + lines.push(Line::from("").style(Style::default().bg(Color::Black))); + let mut last_section: Option<ModelSource> = None; + let mut last_kind: Option<InferenceKind> = None; for (index, item) in app.model_picker_items.iter().enumerate() { + let item_kind = item.source.kind(); + let item_active = item_kind == active_kind; + + // Top-level group header (Local / Cloud) whenever the nature changes. + if last_kind != Some(item_kind) { + if last_kind.is_some() { + lines.push(Line::from("").style(Style::default().bg(Color::Black))); + } + let group_label = match item_kind { + InferenceKind::Local => "LOCAL — on-device inference", + InferenceKind::Cloud => "CLOUD — siGit Code Cloud", + }; + let group_style = if item_active { + Style::default() + .fg(Color::White) + .bg(Color::Black) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + } else { + Style::default().fg(Color::DarkGray).bg(Color::Black) + }; + lines.push( + Line::from(vec![Span::styled(group_label, group_style)]) + .style(Style::default().bg(Color::Black)), + ); + last_kind = Some(item_kind); + last_section = None; + } + if last_section != Some(item.source) { if last_section.is_some() { lines.push(Line::from("").style(Style::default().bg(Color::Black))); @@ -522,9 +582,16 @@ mod tui { ), }; + // Dim the section header when it belongs to the inactive group. + let section_style = if item_active { + section_style + } else { + Style::default().fg(Color::DarkGray).bg(Color::Black) + }; + lines.push( Line::from(vec![ - Span::styled(format!("{section_mark} "), section_style), + Span::styled(format!(" {section_mark} "), section_style), Span::styled(section_name, section_style), ]) .style(Style::default().bg(Color::Black)), @@ -561,12 +628,17 @@ mod tui { let base_style = if selected { Style::default().fg(Color::Black).bg(Color::Green) - } else { + } else if item_active { Style::default().fg(Color::White).bg(Color::Black) + } else { + // Inactive group: still visible (we surface the offering) but dimmed. + Style::default().fg(Color::DarkGray).bg(Color::Black) }; let source_style = if selected { Style::default().fg(Color::Black).bg(Color::Green) + } else if !item_active { + Style::default().fg(Color::DarkGray).bg(Color::Black) } else { match item.source { ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black), @@ -653,6 +725,8 @@ mod tui { Status, /// picker UI, or jump straight to model N Models(Option<usize>), + /// toggle on-device inference mode. `Some(true/false)` sets it, `None` flips it. + Local(Option<bool>), /// `/login <email> <password>` — the raw argument, parsed when executed. Login(Option<String>), Logout, @@ -674,6 +748,7 @@ mod tui { "/clear" => SlashCommand::Clear, "/status" => SlashCommand::Status, "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())), + "/local" => SlashCommand::Local(parse_on_off(arg)), "/login" => SlashCommand::Login(arg.map(str::to_string)), "/logout" => SlashCommand::Logout, "/whoami" => SlashCommand::Whoami, @@ -682,6 +757,16 @@ mod tui { }) } + /// `on`/`off` (and synonyms) → `Some(bool)`; missing or unrecognized → `None` + /// (meaning "toggle the current value"). + fn parse_on_off(arg: Option<&str>) -> Option<bool> { + match arg.map(|s| s.trim().to_ascii_lowercase())?.as_str() { + "on" | "true" | "1" | "yes" => Some(true), + "off" | "false" | "0" | "no" => Some(false), + _ => None, + } + } + // ── Rendering ───────────────────────────────────────────────────────────── fn render(frame: &mut Frame, app: &mut App) { @@ -1143,6 +1228,7 @@ mod tui { "/help — show this message\n\ /models — open the model picker\n\ /models N — switch to model N\n\ + /local [on|off]— toggle on-device inference mode\n\ /login E P — sign in to siGit Code Cloud\n\ /logout — sign out\n\ /whoami — show the signed-in account\n\ @@ -1195,6 +1281,8 @@ mod tui { )); app.current_model_name = provider.display_name.clone(); app.tool_calling = true; + // Selecting a cloud tier puts us in cloud mode. + let _ = crate::settings::set_local_inference(false); app.messages.push(ChatMessage::system(format!( "Switched to {}.", provider.display_name @@ -1220,7 +1308,9 @@ mod tui { } // Route inference on-device; the loader thread below - // fills the engine the LocalBackend reads from. + // fills the engine the LocalBackend reads from. Selecting + // an on-device model puts us in local mode. + let _ = crate::settings::set_local_inference(true); app.backend = Arc::new(LocalBackend::new(Arc::clone(&engine))); let loading_msg = if model.cache_health @@ -1286,6 +1376,31 @@ mod tui { } } }, + SlashCommand::Local(value) => { + let enabled = value.unwrap_or(!crate::settings::local_inference_enabled()); + match crate::settings::set_local_inference(enabled) { + Ok(()) => { + let state = if enabled { "on" } else { "off" }; + let hint = if enabled { + "On-device models are highlighted. Type /models to pick one." + } else { + "siGit Code Cloud tiers are highlighted. Type /models to pick one." + }; + app.messages.push(ChatMessage::system(format!( + "Local inference is {state}. {hint}" + ))); + // Refresh the picker so emphasis/order reflects the new mode. + if app.show_model_picker { + app.open_model_picker(&engine); + } + } + Err(error) => { + app.messages.push(ChatMessage::system(format!( + "error: could not save local inference setting: {error}" + ))); + } + } + } SlashCommand::Login(arg) => { let message = match arg.as_deref().and_then(crate::account::parse_login_args) { Some((email, password)) => {
src/credentials.rs
+3
index 10af020..9d3b576 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -81,6 +81,9 @@ mod tests { #[test] fn round_trips_credentials_via_temp_dir() { + let _guard = crate::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let dir = std::env::temp_dir().join(format!("sigit_creds_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); // SAFETY: single-threaded test; restores below.
src/main.rs
+216 -17
index 8d6de8e..8f590ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,9 +34,16 @@ mod chat; mod credentials; mod models; mod provider; +mod settings; mod setup; mod tools; +/// Serializes tests that mutate process-global env vars (`SIGIT_CONFIG_DIR` +/// etc.). `cargo test` runs tests in parallel within a binary, so without this +/// the credentials and settings round-trip tests clobber each other's env. +#[cfg(test)] +pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + use std::io::IsTerminal; #[cfg(unix)] use std::io::{BufWriter, Write}; @@ -660,6 +667,11 @@ impl SiGitAgent { "model number to switch to (optional)", )), ), + with_hint( + "local", + "Toggle on-device inference mode", + "on|off (optional)", + ), with_hint("login", "Sign in to siGit Code Cloud", "<email> <password>"), AvailableCommand::new("logout", "Sign out of siGit Code Cloud"), AvailableCommand::new("whoami", "Show the signed-in account"), @@ -868,6 +880,9 @@ impl SiGitAgent { ))) .await; + // Honor the persisted Local Inference toggle (off + signed in → cloud). + self.apply_startup_inference_mode().await; + let config_options = { let guard = self.current_model.lock().unwrap(); build_model_config_options(&guard) @@ -916,6 +931,9 @@ impl SiGitAgent { ))) .await; + // Honor the persisted Local Inference toggle (off + signed in → cloud). + self.apply_startup_inference_mode().await; + let config_options = { let guard = self.current_model.lock().unwrap(); build_model_config_options(&guard) @@ -962,6 +980,9 @@ impl SiGitAgent { ))) .await; + // Honor the persisted Local Inference toggle (off + signed in → cloud). + self.apply_startup_inference_mode().await; + let config_options = { let guard = self.current_model.lock().unwrap(); build_model_config_options(&guard) @@ -1299,10 +1320,32 @@ impl SiGitAgent { *guard = cloud_config; } + // Explicitly choosing a cloud tier puts us in cloud mode. + let _ = settings::set_local_inference(false); + log::info!("switched to cloud tier {tier}"); Some(cfg.display_name) } + /// Apply the persisted Local Inference mode at session start. When local + /// inference is off and an account is signed in, route to a cloud tier so the + /// on-device model is never loaded; otherwise leave the on-device backend in + /// place. Call after the session cwd is set so the cloud system prompt picks + /// it up. Does not flip the stored setting on the not-signed-in fallback. + async fn apply_startup_inference_mode(&self) { + if settings::local_inference_enabled() { + return; + } + if self.switch_to_cloud_tier("balanced").await.is_some() { + log::info!("startup: local inference off; routing inference to siGit Code Cloud"); + } else { + log::warn!( + "local inference is off but no account is signed in; staying on-device. \ + Run /login or set Local Inference on." + ); + } + } + /// Route inference back on-device. Used after leaving a cloud tier for a /// local model. The `LocalBackend` reads the live `engine`, so this just /// repoints the active backend. @@ -1391,6 +1434,37 @@ impl SiGitAgent { args.value ); + // ── Local Inference toggle ────────────────────────────────────────── + if args.config_id.0.as_ref() == LOCAL_INFERENCE_CONFIG_ID { + let enabled = match args.value.0.as_ref() { + LOCAL_INFERENCE_ON => true, + LOCAL_INFERENCE_OFF => false, + other => { + return Err(agent_client_protocol::Error::new( + -32602, + format!("unknown Local Inference value: {other}"), + )); + } + }; + if let Err(error) = settings::set_local_inference(enabled) { + return Err(agent_client_protocol::Error::new( + -32603, + format!("could not save Local Inference setting: {error}"), + )); + } + let message = if enabled { + "Local inference is on. On-device models are highlighted; pick one from Model." + } else { + "Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model." + }; + self.send_assistant_message(cx, args.session_id.clone(), format!("\n\n{message}")) + .ok(); + // Rebuild so the Model picker reflects the new emphasis/order. + let current = self.current_model.lock().unwrap().clone(); + let config_options = build_model_config_options(&current); + return Ok(SetSessionConfigOptionResponse::new(config_options)); + } + if args.config_id.0.as_ref() != MODEL_CONFIG_ID { return Err(agent_client_protocol::Error::new( -32602, @@ -1645,6 +1719,8 @@ impl SiGitAgent { Ok(new_config) => { // Route inference back on-device (in case we were on a cloud tier). self.reset_to_local_backend().await; + // Selecting an on-device model puts us in local mode. + let _ = settings::set_local_inference(true); let completion_title = if needs_download { format!("✓ {} downloaded and loaded", new_config.display_name) @@ -1703,6 +1779,15 @@ impl SiGitAgent { /// config option ID for the model picker in Zed's agent panel const MODEL_CONFIG_ID: &str = "sigit-model"; +/// config option ID for the Local Inference on/off toggle. Surfaced as a +/// two-option `select` so ACP clients without slash-command support (e.g. Xcode) +/// can still flip the mode from the agent panel. +const LOCAL_INFERENCE_CONFIG_ID: &str = "sigit-local-inference"; + +/// `select` value ids for the Local Inference toggle. +const LOCAL_INFERENCE_ON: &str = "local-inference-on"; +const LOCAL_INFERENCE_OFF: &str = "local-inference-off"; + /// Replace non-ASCII chars so a downstream byte-index truncation can't split a /// multi-byte char. Zed slices the model-picker label at a fixed byte offset /// (`agent_ui/src/config_options.rs`) and panics — crashing the whole editor — @@ -1718,12 +1803,18 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon // The full list, including the siGit Code Cloud tiers, so the panel picker // mirrors the TUI `/models`. Cloud entries are sign-in gated at selection. let items = models::build_model_picker_items(); + let active_kind = models::active_inference_kind(); let options: Vec<SessionConfigSelectOption> = items .iter() .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete) .map(|item| { let mut desc_parts = Vec::new(); + // Mark options in the inactive mode so the active group reads as the + // recommended set (the list is already ordered active-group-first). + if item.source.kind() != active_kind { + desc_parts.push("inactive mode".to_string()); + } if item.tool_calling { desc_parts.push("tool calling".to_string()); } @@ -1764,8 +1855,36 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon }) .collect(); + // Local Inference on/off toggle, modeled as a two-option select so panel-only + // ACP clients (no slash commands) can flip the mode. + let local_on = settings::local_inference_enabled(); + let local_current = SessionConfigValueId::new(if local_on { + LOCAL_INFERENCE_ON + } else { + LOCAL_INFERENCE_OFF + }); + let local_options = vec![ + SessionConfigSelectOption::new( + SessionConfigValueId::new(LOCAL_INFERENCE_ON), + "On (on-device)".to_string(), + ) + .description("Run inference on-device; on-device models are highlighted".to_string()), + SessionConfigSelectOption::new( + SessionConfigValueId::new(LOCAL_INFERENCE_OFF), + "Off (siGit Code Cloud)".to_string(), + ) + .description("Use siGit Code Cloud; cloud tiers are highlighted".to_string()), + ]; + let local_option = SessionConfigOption::select( + LOCAL_INFERENCE_CONFIG_ID, + "Local Inference", + local_current, + local_options, + ) + .description("Toggle on-device inference; changes which models are highlighted"); + if options.is_empty() { - return vec![]; + return vec![local_option]; } let current_value = SessionConfigValueId::new(current_model.model_id.as_str()); @@ -1774,6 +1893,7 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options) .category(SessionConfigOptionCategory::Model) .description("Select an on-device model or a siGit Code Cloud tier"), + local_option, ] } @@ -1797,6 +1917,8 @@ enum SlashCommand { Clear, Status, Models(Option<usize>), + /// toggle on-device inference mode. `Some(true/false)` sets it, `None` flips it. + Local(Option<bool>), /// `/login <email> <password>` — the raw argument, parsed when executed. Login(Option<String>), Logout, @@ -1820,6 +1942,7 @@ fn parse_slash(input: &str) -> Option<SlashCommand> { "/clear" => SlashCommand::Clear, "/status" => SlashCommand::Status, "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())), + "/local" => SlashCommand::Local(parse_on_off(argument)), "/login" => SlashCommand::Login(argument.map(str::to_string)), "/logout" => SlashCommand::Logout, "/whoami" => SlashCommand::Whoami, @@ -1829,6 +1952,16 @@ fn parse_slash(input: &str) -> Option<SlashCommand> { }) } +/// `on`/`off` (and synonyms) → `Some(bool)`; missing or unrecognized → `None` +/// (meaning "toggle the current value"). +fn parse_on_off(arg: Option<&str>) -> Option<bool> { + match arg.map(|s| s.trim().to_ascii_lowercase())?.as_str() { + "on" | "true" | "1" | "yes" => Some(true), + "off" | "false" | "0" | "no" => Some(false), + _ => None, + } +} + fn format_models_list(current_model: &GgufModelConfig) -> String { let items = models::build_model_picker_items(); if items.is_empty() { @@ -1914,6 +2047,7 @@ async fn exec_slash_acp( "/help - show this message\n\ /models - list available models\n\ /models N - switch to model N\n\ + /local [on|off]- toggle on-device inference mode\n\ /login E P - sign in to siGit Code Cloud\n\ /logout - sign out\n\ /whoami - show the signed-in account\n\ @@ -2004,6 +2138,7 @@ async fn exec_slash_acp( match agent.switch_model_by_id(&model.config.model_id).await { Ok(new_config) => { agent.reset_to_local_backend().await; + let _ = settings::set_local_inference(true); agent.engine.clear_history().await; agent .send_assistant_message( @@ -2037,6 +2172,7 @@ async fn exec_slash_acp( let switched = agent.switch_model_by_id(&model.config.model_id).await?; agent.reset_to_local_backend().await; + let _ = settings::set_local_inference(true); agent.engine.clear_history().await; agent @@ -2050,6 +2186,33 @@ async fn exec_slash_acp( } } } + SlashCommand::Local(value) => { + let enabled = value.unwrap_or(!settings::local_inference_enabled()); + let message = match settings::set_local_inference(enabled) { + Ok(()) if enabled => "Local inference is on. On-device models are highlighted; \ + pick one with /models." + .to_string(), + Ok(()) => "Local inference is off. siGit Code Cloud tiers are highlighted; \ + pick one with /models." + .to_string(), + Err(error) => format!("error: could not save local inference setting: {error}"), + }; + agent + .send_assistant_message(cx, session_id.clone(), message) + .ok(); + // Refresh the panel so the Model picker reflects the new emphasis. + let config_options = { + let current = agent.current_model.lock().unwrap(); + build_model_config_options(&current) + }; + agent + .send_tool_call_update( + cx, + session_id, + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)), + ) + .ok(); + } SlashCommand::Login(argument) => { let message = match argument.as_deref().and_then(account::parse_login_args) { Some((email, password)) => match account::authenticate(&email, &password).await { @@ -2277,22 +2440,58 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> (backend, label) } None => { - // On-device: load the local GGUF model on a real thread. - let loader_engine = Arc::clone(&engine); - let system_prompt = system_prompt_for_model(tool_calling).to_string(); - std::thread::spawn(move || { - let rt = - tokio::runtime::Runtime::new().expect("failed to create loader runtime"); - let result = rt.block_on(loader_engine.load_gguf_model( - config, - Some(system_prompt), - Some(sampling), - )); - let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string())); - }); - let backend = - Arc::new(LocalBackend::new(Arc::clone(&engine))) as Arc<dyn InferenceBackend>; - (backend, startup_model_name) + // Honor the Local Inference toggle: when off and signed in, start + // on a cloud tier instead of loading an on-device model. When off + // but not signed in, fall back to on-device (a usable backend) — + // the user can /login or /local on. + let cloud_when_off = if settings::local_inference_enabled() { + None + } else { + provider::cloud_tier_provider("balanced") + }; + + match cloud_when_off { + Some(provider) => { + log::info!( + "inference: local inference off; using {} (model {})", + provider.display_name, + provider.model + ); + let _ = load_tx.send(Ok(())); + let label = provider.display_name.clone(); + let backend = Arc::new(OpenAiBackend::new( + provider.base_url, + provider.api_key, + provider.model, + Some(SYSTEM_PROMPT.to_string()), + )) as Arc<dyn InferenceBackend>; + (backend, label) + } + None => { + if !settings::local_inference_enabled() { + log::warn!( + "local inference is off but no account is signed in; \ + falling back to on-device. Run /login or /local on." + ); + } + // On-device: load the local GGUF model on a real thread. + let loader_engine = Arc::clone(&engine); + let system_prompt = system_prompt_for_model(tool_calling).to_string(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new() + .expect("failed to create loader runtime"); + let result = rt.block_on(loader_engine.load_gguf_model( + config, + Some(system_prompt), + Some(sampling), + )); + let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string())); + }); + let backend = Arc::new(LocalBackend::new(Arc::clone(&engine))) + as Arc<dyn InferenceBackend>; + (backend, startup_model_name) + } + } } };
src/models.rs
+35 -2
index d9095da..0c9a08c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -20,6 +20,33 @@ pub(crate) enum ModelSource { Cloud, } +/// The broad nature of where inference runs. The picker groups by this and the +/// `local_inference` setting decides which group is the active (highlighted) one. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum InferenceKind { + Local, + Cloud, +} + +impl ModelSource { + /// On-device sources are `Local`; the cloud tiers are `Cloud`. + pub(crate) fn kind(self) -> InferenceKind { + match self { + ModelSource::Cloud => InferenceKind::Cloud, + _ => InferenceKind::Local, + } + } +} + +/// The active inference mode, from the persisted `local_inference` setting. +pub(crate) fn active_inference_kind() -> InferenceKind { + if crate::settings::local_inference_enabled() { + InferenceKind::Local + } else { + InferenceKind::Cloud + } +} + #[derive(Clone)] pub(crate) struct ModelPickerItem { pub(crate) display_name: String, @@ -159,9 +186,15 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> { }); } + // Group by inference nature, active mode first (so the highlighted group + // leads the picker), then by source, then alphabetically. + let active = active_inference_kind(); + let group_rank = + |item: &ModelPickerItem| -> u8 { if item.source.kind() == active { 0 } else { 1 } }; items.sort_by(|left, right| { - left.source - .cmp(&right.source) + group_rank(left) + .cmp(&group_rank(right)) + .then_with(|| left.source.cmp(&right.source)) .then_with(|| left.display_name.cmp(&right.display_name)) });
src/settings.rs
+153
new file mode 100644 index 0000000..413cd60 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,153 @@ +//! Local user preferences. +//! +//! Persisted as TOML at `$SIGIT_CONFIG_DIR/settings.toml` or +//! `~/.config/sigit/settings.toml`. Mirrors the storage pattern of +//! [`crate::credentials`] but holds preferences rather than secrets, so it is +//! not permission-restricted. +//! +//! The only setting today is `local_inference`: whether on-device inference is +//! the active mode. It is the source of truth for the local/cloud toggle and +//! drives how `/models` presents the picker. It is stored locally so the toggle +//! works even on ACP clients that do not support slash commands (e.g. Xcode), +//! where it is also surfaced as a session config option. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Env override for `local_inference`. When set to a truthy/falsy value it wins +/// over the stored file for reads (matching the existing `SIGIT_*` override +/// style); it never writes the file. +const LOCAL_INFERENCE_ENV: &str = "SIGIT_LOCAL_INFERENCE"; + +fn default_local_inference() -> bool { + true +} + +/// Persisted preferences. New fields must carry `#[serde(default)]` so older +/// files keep deserializing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Settings { + /// Whether on-device inference is the active mode. `true` (local-first) on a + /// fresh install. + #[serde(default = "default_local_inference")] + pub local_inference: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { + local_inference: default_local_inference(), + } + } +} + +/// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`. +fn config_dir() -> Option<PathBuf> { + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") { + return Some(PathBuf::from(dir)); + } + let home = std::env::var("HOME").ok()?; + Some(PathBuf::from(home).join(".config/sigit")) +} + +fn settings_path() -> Option<PathBuf> { + config_dir().map(|dir| dir.join("settings.toml")) +} + +/// Parse an env value as a boolean. Accepts `1/0`, `true/false`, `on/off`, +/// `yes/no` (case-insensitive). Returns `None` for anything unrecognized so a +/// stray value falls back to the stored setting instead of silently flipping. +fn parse_bool_env(value: &str) -> Option<bool> { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "on" | "yes" => Some(true), + "0" | "false" | "off" | "no" => Some(false), + _ => None, + } +} + +/// Load stored settings, or defaults if the file is absent or unreadable. +pub fn load() -> Settings { + let Some(path) = settings_path() else { + return Settings::default(); + }; + match std::fs::read_to_string(&path) { + Ok(contents) => toml::from_str::<Settings>(&contents).unwrap_or_else(|error| { + log::warn!("settings: ignoring settings.toml: {error}"); + Settings::default() + }), + Err(_) => Settings::default(), + } +} + +/// Persist settings, creating the config dir if needed. +pub fn store(settings: &Settings) -> Result<(), String> { + let dir = config_dir().ok_or_else(|| "cannot resolve config directory".to_string())?; + std::fs::create_dir_all(&dir).map_err(|error| format!("create {dir:?}: {error}"))?; + let path = dir.join("settings.toml"); + let body = toml::to_string(settings).map_err(|error| format!("serialize settings: {error}"))?; + std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))?; + Ok(()) +} + +/// Whether on-device inference is the active mode. The `SIGIT_LOCAL_INFERENCE` +/// env var, when set to a recognized boolean, overrides the stored value. +pub fn local_inference_enabled() -> bool { + if let Ok(raw) = std::env::var(LOCAL_INFERENCE_ENV) + && let Some(value) = parse_bool_env(&raw) + { + return value; + } + load().local_inference +} + +/// Persist a new `local_inference` value, preserving any other settings. +pub fn set_local_inference(enabled: bool) -> Result<(), String> { + let mut settings = load(); + settings.local_inference = enabled; + store(&settings) +} + +#[cfg(test)] +mod tests { + use super::*; + + // One test (not several) because each mutates the process-global + // `SIGIT_CONFIG_DIR` / `SIGIT_LOCAL_INFERENCE` env vars; splitting would let + // them race under `cargo test`'s parallel runner. + #[test] + fn defaults_round_trip_and_env_override() { + let _guard = crate::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let dir = std::env::temp_dir().join(format!("sigit_settings_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // SAFETY: single-threaded test; restores below. + unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; + unsafe { std::env::remove_var(LOCAL_INFERENCE_ENV) }; + + // Fresh install: no file → local-first. + assert!( + load().local_inference, + "fresh install should be local-first" + ); + assert!(local_inference_enabled()); + + set_local_inference(false).unwrap(); + assert!(!load().local_inference); + assert!(!local_inference_enabled()); + + // Env override wins over the stored `false`. + unsafe { std::env::set_var(LOCAL_INFERENCE_ENV, "true") }; + assert!(local_inference_enabled()); + unsafe { std::env::set_var(LOCAL_INFERENCE_ENV, "garbage") }; + assert!( + !local_inference_enabled(), + "unrecognized env value falls back to stored setting" + ); + + unsafe { std::env::remove_var(LOCAL_INFERENCE_ENV) }; + unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; + let _ = std::fs::remove_dir_all(&dir); + } +}