+119
-4
index 953a708..84eb8b5 100644
--- a/src/chat.rs
+++ b/src/chat.rs
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},
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)));
),
};
+ // 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)),
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),
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,
"/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,
})
}
+ /// `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) {
"/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\
));
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
}
// 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
}
}
},
+ 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)) => {
+3
index 10af020..9d3b576 100644
--- a/src/credentials.rs
+++ b/src/credentials.rs
#[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.
+216
-17
index 8d6de8e..8f590ae 100644
--- a/src/main.rs
+++ b/src/main.rs
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};
"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"),
)))
.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)
)))
.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)
)))
.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)
*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.
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(¤t);
+ return Ok(SetSessionConfigOptionResponse::new(config_options));
+ }
+
if args.config_id.0.as_ref() != MODEL_CONFIG_ID {
return Err(agent_client_protocol::Error::new(
-32602,
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)
/// 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 —
// 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());
}
})
.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());
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,
]
}
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,
"/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,
})
}
+/// `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() {
"/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\
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(
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
}
}
}
+ 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(¤t)
+ };
+ 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 {
(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)
+ }
+ }
}
};
+35
-2
index d9095da..0c9a08c 100644
--- a/src/models.rs
+++ b/src/models.rs
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,
});
}
+ // 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))
});
+153
new file mode 100644
index 0000000..413cd60
--- /dev/null
+++ b/src/settings.rs
+//! 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);
+ }
+}