Refactor model picker logic into platform-independent module
Move ModelSource, ModelPickerItem, and build_model_picker_items to src/models.rs for use on all platforms. Update imports and references accordingly. Update installation instructions and VSCode integration examples in README files.
paydii committed
Apr 25, 2026 at 21:24 UTC
31afcdb0dbc09417f42802542e2e917579ff37ce
6 files changed
+167
-118
README.md
+19
-1
index c9bb918..505a0a2 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://crates.io/crates/sigit)
-A coding agent for [smbCloud](https://smbcloud.xyz/) that runs entirely on your machine. No API keys. No cloud round-trips. The model lives in your local HuggingFace cache.
+A coding agent for [smbCloud](https://smbcloud.xyz/) that runs entirely on your machine. No API keys. No cloud round-trips.
siGit is meant to be a general coding agent, but it is especially good in smbCloud codebases. It already knows the rough shape of the platform: Rust workspaces with focused crates, Rails services, deploy flows, auth boundaries, and platform-managed services like GresIQ. In smbCloud repos, that means it can usually give more grounded answers with less back-and-forth.
@@ -30,6 +30,8 @@ Outside smbCloud, it should still behave like a normal coding agent and not forc
## Install
+Install siGit Code with cargo, Homebrew, PyPi, or NPM:
+
```sh
cargo install sigit
```
@@ -57,6 +59,22 @@ Add to `~/.config/zed/settings.json`:
Use the full absolute path. `~` will not be expanded here.
+## VSCode via ACP Client extension
+
+Install [ACP client](https://marketplace.visualstudio.com/items?itemName=formulahendry.acp-client):
+
+```json
+{
+ "acp.agents": {
+ "siGit Code": {
+ "command": "sigit",
+ "args": [],
+ "env": {}
+ }
+ }
+}
+```
+
## Terminal mode
If you run `sigit` directly in a terminal, it opens an interactive chat UI. It uses the same model and system prompt as the editor integration, so it is useful for quick questions when you do not want to open Zed first.
npm/sigit/README.md
+4
-3
index e641af3..f37a4a3 100644
--- a/npm/sigit/README.md
+++ b/npm/sigit/README.md
@@ -24,8 +24,9 @@ The right binary for your platform gets pulled in automatically. Works on macOS
| Method | Command |
|---|---|
-| **Homebrew** | `brew install getsigit/homebrew-tap/sigit` |
-| **pip** | `pip install sigit` |
+| **Homebrew** | `brew tap getsigit/tap && brew install sigit` |
+| **pip** | `pip install sigit-code` |
+| **uv** | `uvx --from sigit-code sigit` |
| **Cargo** | `cargo install sigit` |
---
@@ -82,4 +83,4 @@ Add siGit as an agent in Zed by adding this to your settings:
## Copyright
-2026 smbCloud (Splitfire AB).
\ No newline at end of file
+2026 smbCloud (Splitfire AB).
pypi/README.md
+9
-11
index bdc4e57..3c19dac 100644
--- a/pypi/README.md
+++ b/pypi/README.md
@@ -23,8 +23,11 @@
## Install
+Use `pip` or `uv`:
+
```sh
pip install sigit-code
+uvx --from sigit-code sigit
```
Installs the native `sigit` binary for your platform — no compiler, no Node.js, no runtime dependencies.
@@ -45,15 +48,10 @@ siGit works as an [ACP-compatible](https://github.com/nicobailon/agent-client-pr
```json
{
- "agent": {
- "profiles": {
- "sigit": {
- "provider": "acp",
- "binary": {
- "path": "sigit",
- "args": ["--acp"]
- }
- }
+ "agent_servers": {
+ "siGit Code": {
+ "type": "custom",
+ "command": "/absolute/path/to/sigit"
}
}
}
@@ -66,7 +64,7 @@ Then select **sigit** as your agent profile in the Zed assistant panel.
| Method | Command |
|--------|---------|
| npm | `npm install -g @smbcloud/sigit` |
-| Homebrew | `brew install getsigit/sigit/sigit` |
+| Homebrew | `brew tap getsigit/tap && brew install sigit` |
| Cargo | `cargo install sigit` |
### From source
@@ -104,4 +102,4 @@ Licensed under **Apache 2.0**.
## Copyright
-2026 smbCloud (Splitfire AB).
\ No newline at end of file
+2026 smbCloud (Splitfire AB).
src/chat.rs
+5
-96
index 2aa7a0e..1de1824 100644
--- a/src/chat.rs
+++ b/src/chat.rs
@@ -17,11 +17,9 @@ use std::sync::mpsc as std_mpsc;
use anyhow::Result;
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use futures::StreamExt;
-use onde::inference::{
- ChatEngine, GgufModelConfig, SamplingConfig, StreamChunk, ToolDefinition, ToolResult,
-};
+use onde::inference::{ChatEngine, SamplingConfig, StreamChunk, ToolDefinition, ToolResult};
-use crate::setup::{DiscoveredModel, ModelCacheHealth};
+use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items};
use ratatui::{
Frame,
layout::{Constraint, Layout, Position},
@@ -379,98 +377,9 @@ fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
// ── Model table ──────────────────────────────────────────────────────────────
-#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
-enum ModelSource {
- Onde,
- HuggingFace,
- Fallback,
-}
-
-#[derive(Clone)]
-pub(crate) struct ModelPickerItem {
- pub(crate) display_name: String,
- pub(crate) description: String,
- pub(crate) tool_calling: bool,
- pub(crate) max_tokens: u64,
- pub(crate) config: GgufModelConfig,
- pub(crate) source_label: String,
- brand_mark: &'static str,
- source: ModelSource,
- pub(crate) cache_health: ModelCacheHealth,
-}
-
-pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
- let mut items = Vec::new();
-
- for discovered in crate::setup::discover_local_models() {
- if let Some(item) = discovered_model_to_picker_item(discovered) {
- items.push(item);
- }
- }
-
- if items.is_empty() {
- let config = GgufModelConfig::platform_default();
- let tool_calling = config.display_name == "Qwen 3 4B (Q4_K_M)";
- let max_tokens = if tool_calling { 4096 } else { 512 };
-
- items.push(ModelPickerItem {
- display_name: config.display_name.clone(),
- description: config.approx_memory.clone(),
- tool_calling,
- max_tokens,
- config,
- source_label: "Platform default".to_string(),
- brand_mark: "◎",
- source: ModelSource::Fallback,
- cache_health: ModelCacheHealth::Complete,
- });
- }
-
- items.sort_by(|left, right| {
- left.source
- .cmp(&right.source)
- .then_with(|| left.display_name.cmp(&right.display_name))
- });
- items
-}
-
-fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> {
- let source_label = if model.from_app_group {
- "Onde".to_string()
- } else {
- "HuggingFace".to_string()
- };
-
- let config = match model.model_id.as_str() {
- "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
- "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
- "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
- "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
- "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
- "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
- _ => return None,
- };
-
- let tool_calling = model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF"
- || model.model_id == "bartowski/Qwen_Qwen3-8B-GGUF";
- let max_tokens = if tool_calling { 4096 } else { 512 };
-
- Some(ModelPickerItem {
- display_name: config.display_name.clone(),
- description: config.approx_memory.clone(),
- tool_calling,
- max_tokens,
- config,
- source_label,
- brand_mark: if model.from_app_group { "◉" } else { "○" },
- source: if model.from_app_group {
- ModelSource::Onde
- } else {
- ModelSource::HuggingFace
- },
- cache_health: model.cache_health,
- })
-}
+// ModelSource, ModelPickerItem, and build_model_picker_items live in
+// crate::models so they are available on all platforms (including Windows),
+// not just unix where this chat module is compiled.
fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let popup = centered_rect(82, 72, area);
src/main.rs
+8
-7
index 279e555..d9889eb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -42,6 +42,7 @@
#[cfg(unix)]
mod chat;
+mod models;
mod setup;
mod tools;
@@ -351,7 +352,7 @@ impl SiGitAgent {
agent_client_protocol::Error::new(-32603, format!("model switch failed: {error}"))
})?;
- if let Some(item) = chat::build_model_picker_items()
+ if let Some(item) = models::build_model_picker_items()
.iter()
.find(|item| item.config.model_id == new_config.model_id)
&& let Err(err) = setup::save_selected_model(&setup::SelectedModel {
@@ -388,7 +389,7 @@ const MODEL_CONFIG_ID: &str = "sigit-model";
/// Build the `SessionConfigOption` list for model selection.
fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
- let items = chat::build_model_picker_items();
+ let items = models::build_model_picker_items();
let options: Vec<SessionConfigSelectOption> = items
.iter()
@@ -428,7 +429,7 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
/// Look up the GgufModelConfig for a given model_id value from the picker items.
fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64)> {
- let items = chat::build_model_picker_items();
+ let items = models::build_model_picker_items();
items
.into_iter()
.find(|item| {
@@ -467,7 +468,7 @@ fn parse_slash(input: &str) -> Option<SlashCommand> {
}
fn format_models_list(current_model: &GgufModelConfig) -> String {
- let items = chat::build_model_picker_items();
+ let items = models::build_model_picker_items();
if items.is_empty() {
return "No local models found. siGit will use the platform default model.".to_string();
}
@@ -576,7 +577,7 @@ async fn exec_slash_acp(
.await;
}
SlashCommand::Models(Some(number)) => {
- let items = chat::build_model_picker_items();
+ let items = models::build_model_picker_items();
let index = number.saturating_sub(1);
match items.get(index).cloned() {
None => {
@@ -1201,7 +1202,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
let config = startup_selection
.as_ref()
.and_then(|selection| {
- chat::build_model_picker_items()
+ models::build_model_picker_items()
.into_iter()
.find(|item| {
selection
@@ -1283,7 +1284,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
let config = startup_selection
.as_ref()
.and_then(|selection| {
- chat::build_model_picker_items()
+ models::build_model_picker_items()
.into_iter()
.find(|item| {
selection
src/models.rs
+122
new file mode 100644
index 0000000..3e18909
--- /dev/null
+++ b/src/models.rs
@@ -0,0 +1,122 @@
+//! Platform-independent model picker types and item construction.
+//!
+//! This module is available on all target platforms (Windows, macOS, Linux).
+//! The TUI rendering code in `chat.rs` (unix-only) re-uses these types
+//! rather than defining them inline.
+
+use onde::inference::GgufModelConfig;
+
+use crate::setup::DiscoveredModel;
+
+pub(crate) use crate::setup::ModelCacheHealth;
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) enum ModelSource {
+ Onde,
+ HuggingFace,
+ Fallback,
+}
+
+#[derive(Clone)]
+pub(crate) struct ModelPickerItem {
+ pub(crate) display_name: String,
+ pub(crate) description: String,
+ pub(crate) tool_calling: bool,
+ pub(crate) max_tokens: u64,
+ pub(crate) config: GgufModelConfig,
+ pub(crate) source_label: String,
+ pub(crate) brand_mark: &'static str,
+ pub(crate) source: ModelSource,
+ pub(crate) cache_health: ModelCacheHealth,
+}
+
+// ── Builder ───────────────────────────────────────────────────────────────────
+
+/// Build the full list of available model picker items from the local cache.
+///
+/// Items are sourced from:
+/// 1. The Onde app-group model cache (macOS shared container).
+/// 2. The HuggingFace hub cache (`HF_HUB_CACHE` / `HF_HOME` / `~/.cache/huggingface/hub`).
+///
+/// If no models are discovered at all, a single fallback entry for the
+/// platform-default model is returned so the picker is never empty.
+///
+/// Items are sorted by source priority (Onde first, then HuggingFace, then
+/// Fallback) and then alphabetically by display name within each group.
+pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
+ let mut items = Vec::new();
+
+ for discovered in crate::setup::discover_local_models() {
+ if let Some(item) = discovered_model_to_picker_item(discovered) {
+ items.push(item);
+ }
+ }
+
+ if items.is_empty() {
+ let config = GgufModelConfig::platform_default();
+ let tool_calling = config.display_name == "Qwen 3 4B (Q4_K_M)";
+ let max_tokens = if tool_calling { 4096 } else { 512 };
+
+ items.push(ModelPickerItem {
+ display_name: config.display_name.clone(),
+ description: config.approx_memory.clone(),
+ tool_calling,
+ max_tokens,
+ config,
+ source_label: "Platform default".to_string(),
+ brand_mark: "◎",
+ source: ModelSource::Fallback,
+ cache_health: ModelCacheHealth::Complete,
+ });
+ }
+
+ items.sort_by(|left, right| {
+ left.source
+ .cmp(&right.source)
+ .then_with(|| left.display_name.cmp(&right.display_name))
+ });
+
+ items
+}
+
+// ── Internal helpers ──────────────────────────────────────────────────────────
+
+fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> {
+ let source_label = if model.from_app_group {
+ "Onde".to_string()
+ } else {
+ "HuggingFace".to_string()
+ };
+
+ let config = match model.model_id.as_str() {
+ "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
+ "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
+ "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
+ "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
+ "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
+ "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
+ _ => return None,
+ };
+
+ let tool_calling = model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF"
+ || model.model_id == "bartowski/Qwen_Qwen3-8B-GGUF";
+ let max_tokens = if tool_calling { 4096 } else { 512 };
+
+ Some(ModelPickerItem {
+ display_name: config.display_name.clone(),
+ description: config.approx_memory.clone(),
+ tool_calling,
+ max_tokens,
+ config,
+ source_label,
+ brand_mark: if model.from_app_group { "◉" } else { "○" },
+ source: if model.from_app_group {
+ ModelSource::Onde
+ } else {
+ ModelSource::HuggingFace
+ },
+ cache_health: model.cache_health,
+ })
+}