@hej / sigit / commits / 31afcdb

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
@@ -2,7 +2,7 @@
2
3 [![Crates.io Version](https://img.shields.io/crates/v/sigit)](https://crates.io/crates/sigit)
4
5 -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.
5 +A coding agent for [smbCloud](https://smbcloud.xyz/) that runs entirely on your machine. No API keys. No cloud round-trips.
6
7 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.
8
@@ -30,6 +30,8 @@ Outside smbCloud, it should still behave like a normal coding agent and not forc
30
31 ## Install
32
33 +Install siGit Code with cargo, Homebrew, PyPi, or NPM:
34 +
35 ```sh
36 cargo install sigit
37 ```
@@ -57,6 +59,22 @@ Add to `~/.config/zed/settings.json`:
59
60 Use the full absolute path. `~` will not be expanded here.
61
62 +## VSCode via ACP Client extension
63 +
64 +Install [ACP client](https://marketplace.visualstudio.com/items?itemName=formulahendry.acp-client):
65 +
66 +```json
67 +{
68 + "acp.agents": {
69 + "siGit Code": {
70 + "command": "sigit",
71 + "args": [],
72 + "env": {}
73 + }
74 + }
75 +}
76 +```
77 +
78 ## Terminal mode
79
80 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
@@ -24,8 +24,9 @@ The right binary for your platform gets pulled in automatically. Works on macOS
24
25 | Method | Command |
26 |---|---|
27 -| **Homebrew** | `brew install getsigit/homebrew-tap/sigit` |
28 -| **pip** | `pip install sigit` |
27 +| **Homebrew** | `brew tap getsigit/tap && brew install sigit` |
28 +| **pip** | `pip install sigit-code` |
29 +| **uv** | `uvx --from sigit-code sigit` |
30 | **Cargo** | `cargo install sigit` |
31
32 ---
@@ -82,4 +83,4 @@ Add siGit as an agent in Zed by adding this to your settings:
83
84 ## Copyright
85
85 -2026 smbCloud (Splitfire AB).
\ No newline at end of file
86 +2026 smbCloud (Splitfire AB).
pypi/README.md
+9 -11
@@ -23,8 +23,11 @@
23
24 ## Install
25
26 +Use `pip` or `uv`:
27 +
28 ```sh
29 pip install sigit-code
30 +uvx --from sigit-code sigit
31 ```
32
33 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
48
49 ```json
50 {
48 - "agent": {
49 - "profiles": {
50 - "sigit": {
51 - "provider": "acp",
52 - "binary": {
53 - "path": "sigit",
54 - "args": ["--acp"]
55 - }
56 - }
51 + "agent_servers": {
52 + "siGit Code": {
53 + "type": "custom",
54 + "command": "/absolute/path/to/sigit"
55 }
56 }
57 }
@@ -66,7 +64,7 @@ Then select **sigit** as your agent profile in the Zed assistant panel.
64 | Method | Command |
65 |--------|---------|
66 | npm | `npm install -g @smbcloud/sigit` |
69 -| Homebrew | `brew install getsigit/sigit/sigit` |
67 +| Homebrew | `brew tap getsigit/tap && brew install sigit` |
68 | Cargo | `cargo install sigit` |
69
70 ### From source
@@ -104,4 +102,4 @@ Licensed under **Apache 2.0**.
102
103 ## Copyright
104
107 -2026 smbCloud (Splitfire AB).
\ No newline at end of file
105 +2026 smbCloud (Splitfire AB).
src/chat.rs
+5 -96
@@ -17,11 +17,9 @@ use std::sync::mpsc as std_mpsc;
17 use anyhow::Result;
18 use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
19 use futures::StreamExt;
20 -use onde::inference::{
21 - ChatEngine, GgufModelConfig, SamplingConfig, StreamChunk, ToolDefinition, ToolResult,
22 -};
20 +use onde::inference::{ChatEngine, SamplingConfig, StreamChunk, ToolDefinition, ToolResult};
21
24 -use crate::setup::{DiscoveredModel, ModelCacheHealth};
22 +use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items};
23 use ratatui::{
24 Frame,
25 layout::{Constraint, Layout, Position},
@@ -379,98 +377,9 @@ fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
377
378 // ── Model table ──────────────────────────────────────────────────────────────
379
382 -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
383 -enum ModelSource {
384 - Onde,
385 - HuggingFace,
386 - Fallback,
387 -}
388 -
389 -#[derive(Clone)]
390 -pub(crate) struct ModelPickerItem {
391 - pub(crate) display_name: String,
392 - pub(crate) description: String,
393 - pub(crate) tool_calling: bool,
394 - pub(crate) max_tokens: u64,
395 - pub(crate) config: GgufModelConfig,
396 - pub(crate) source_label: String,
397 - brand_mark: &'static str,
398 - source: ModelSource,
399 - pub(crate) cache_health: ModelCacheHealth,
400 -}
401 -
402 -pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
403 - let mut items = Vec::new();
404 -
405 - for discovered in crate::setup::discover_local_models() {
406 - if let Some(item) = discovered_model_to_picker_item(discovered) {
407 - items.push(item);
408 - }
409 - }
410 -
411 - if items.is_empty() {
412 - let config = GgufModelConfig::platform_default();
413 - let tool_calling = config.display_name == "Qwen 3 4B (Q4_K_M)";
414 - let max_tokens = if tool_calling { 4096 } else { 512 };
415 -
416 - items.push(ModelPickerItem {
417 - display_name: config.display_name.clone(),
418 - description: config.approx_memory.clone(),
419 - tool_calling,
420 - max_tokens,
421 - config,
422 - source_label: "Platform default".to_string(),
423 - brand_mark: "◎",
424 - source: ModelSource::Fallback,
425 - cache_health: ModelCacheHealth::Complete,
426 - });
427 - }
428 -
429 - items.sort_by(|left, right| {
430 - left.source
431 - .cmp(&right.source)
432 - .then_with(|| left.display_name.cmp(&right.display_name))
433 - });
434 - items
435 -}
436 -
437 -fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> {
438 - let source_label = if model.from_app_group {
439 - "Onde".to_string()
440 - } else {
441 - "HuggingFace".to_string()
442 - };
443 -
444 - let config = match model.model_id.as_str() {
445 - "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
446 - "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
447 - "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
448 - "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
449 - "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
450 - "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
451 - _ => return None,
452 - };
453 -
454 - let tool_calling = model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF"
455 - || model.model_id == "bartowski/Qwen_Qwen3-8B-GGUF";
456 - let max_tokens = if tool_calling { 4096 } else { 512 };
457 -
458 - Some(ModelPickerItem {
459 - display_name: config.display_name.clone(),
460 - description: config.approx_memory.clone(),
461 - tool_calling,
462 - max_tokens,
463 - config,
464 - source_label,
465 - brand_mark: if model.from_app_group { "◉" } else { "○" },
466 - source: if model.from_app_group {
467 - ModelSource::Onde
468 - } else {
469 - ModelSource::HuggingFace
470 - },
471 - cache_health: model.cache_health,
472 - })
473 -}
380 +// ModelSource, ModelPickerItem, and build_model_picker_items live in
381 +// crate::models so they are available on all platforms (including Windows),
382 +// not just unix where this chat module is compiled.
383
384 fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
385 let popup = centered_rect(82, 72, area);
src/main.rs
+8 -7
@@ -42,6 +42,7 @@
42
43 #[cfg(unix)]
44 mod chat;
45 +mod models;
46 mod setup;
47 mod tools;
48
@@ -351,7 +352,7 @@ impl SiGitAgent {
352 agent_client_protocol::Error::new(-32603, format!("model switch failed: {error}"))
353 })?;
354
354 - if let Some(item) = chat::build_model_picker_items()
355 + if let Some(item) = models::build_model_picker_items()
356 .iter()
357 .find(|item| item.config.model_id == new_config.model_id)
358 && let Err(err) = setup::save_selected_model(&setup::SelectedModel {
@@ -388,7 +389,7 @@ const MODEL_CONFIG_ID: &str = "sigit-model";
389
390 /// Build the `SessionConfigOption` list for model selection.
391 fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
391 - let items = chat::build_model_picker_items();
392 + let items = models::build_model_picker_items();
393
394 let options: Vec<SessionConfigSelectOption> = items
395 .iter()
@@ -428,7 +429,7 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
429
430 /// Look up the GgufModelConfig for a given model_id value from the picker items.
431 fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64)> {
431 - let items = chat::build_model_picker_items();
432 + let items = models::build_model_picker_items();
433 items
434 .into_iter()
435 .find(|item| {
@@ -467,7 +468,7 @@ fn parse_slash(input: &str) -> Option<SlashCommand> {
468 }
469
470 fn format_models_list(current_model: &GgufModelConfig) -> String {
470 - let items = chat::build_model_picker_items();
471 + let items = models::build_model_picker_items();
472 if items.is_empty() {
473 return "No local models found. siGit will use the platform default model.".to_string();
474 }
@@ -576,7 +577,7 @@ async fn exec_slash_acp(
577 .await;
578 }
579 SlashCommand::Models(Some(number)) => {
579 - let items = chat::build_model_picker_items();
580 + let items = models::build_model_picker_items();
581 let index = number.saturating_sub(1);
582 match items.get(index).cloned() {
583 None => {
@@ -1201,7 +1202,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1202 let config = startup_selection
1203 .as_ref()
1204 .and_then(|selection| {
1204 - chat::build_model_picker_items()
1205 + models::build_model_picker_items()
1206 .into_iter()
1207 .find(|item| {
1208 selection
@@ -1283,7 +1284,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
1284 let config = startup_selection
1285 .as_ref()
1286 .and_then(|selection| {
1286 - chat::build_model_picker_items()
1287 + models::build_model_picker_items()
1288 .into_iter()
1289 .find(|item| {
1290 selection
src/models.rs new
+122
@@ -0,0 +1,122 @@
1 +//! Platform-independent model picker types and item construction.
2 +//!
3 +//! This module is available on all target platforms (Windows, macOS, Linux).
4 +//! The TUI rendering code in `chat.rs` (unix-only) re-uses these types
5 +//! rather than defining them inline.
6 +
7 +use onde::inference::GgufModelConfig;
8 +
9 +use crate::setup::DiscoveredModel;
10 +
11 +pub(crate) use crate::setup::ModelCacheHealth;
12 +
13 +// ── Types ─────────────────────────────────────────────────────────────────────
14 +
15 +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
16 +pub(crate) enum ModelSource {
17 + Onde,
18 + HuggingFace,
19 + Fallback,
20 +}
21 +
22 +#[derive(Clone)]
23 +pub(crate) struct ModelPickerItem {
24 + pub(crate) display_name: String,
25 + pub(crate) description: String,
26 + pub(crate) tool_calling: bool,
27 + pub(crate) max_tokens: u64,
28 + pub(crate) config: GgufModelConfig,
29 + pub(crate) source_label: String,
30 + pub(crate) brand_mark: &'static str,
31 + pub(crate) source: ModelSource,
32 + pub(crate) cache_health: ModelCacheHealth,
33 +}
34 +
35 +// ── Builder ───────────────────────────────────────────────────────────────────
36 +
37 +/// Build the full list of available model picker items from the local cache.
38 +///
39 +/// Items are sourced from:
40 +/// 1. The Onde app-group model cache (macOS shared container).
41 +/// 2. The HuggingFace hub cache (`HF_HUB_CACHE` / `HF_HOME` / `~/.cache/huggingface/hub`).
42 +///
43 +/// If no models are discovered at all, a single fallback entry for the
44 +/// platform-default model is returned so the picker is never empty.
45 +///
46 +/// Items are sorted by source priority (Onde first, then HuggingFace, then
47 +/// Fallback) and then alphabetically by display name within each group.
48 +pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
49 + let mut items = Vec::new();
50 +
51 + for discovered in crate::setup::discover_local_models() {
52 + if let Some(item) = discovered_model_to_picker_item(discovered) {
53 + items.push(item);
54 + }
55 + }
56 +
57 + if items.is_empty() {
58 + let config = GgufModelConfig::platform_default();
59 + let tool_calling = config.display_name == "Qwen 3 4B (Q4_K_M)";
60 + let max_tokens = if tool_calling { 4096 } else { 512 };
61 +
62 + items.push(ModelPickerItem {
63 + display_name: config.display_name.clone(),
64 + description: config.approx_memory.clone(),
65 + tool_calling,
66 + max_tokens,
67 + config,
68 + source_label: "Platform default".to_string(),
69 + brand_mark: "◎",
70 + source: ModelSource::Fallback,
71 + cache_health: ModelCacheHealth::Complete,
72 + });
73 + }
74 +
75 + items.sort_by(|left, right| {
76 + left.source
77 + .cmp(&right.source)
78 + .then_with(|| left.display_name.cmp(&right.display_name))
79 + });
80 +
81 + items
82 +}
83 +
84 +// ── Internal helpers ──────────────────────────────────────────────────────────
85 +
86 +fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> {
87 + let source_label = if model.from_app_group {
88 + "Onde".to_string()
89 + } else {
90 + "HuggingFace".to_string()
91 + };
92 +
93 + let config = match model.model_id.as_str() {
94 + "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
95 + "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
96 + "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
97 + "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
98 + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
99 + "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
100 + _ => return None,
101 + };
102 +
103 + let tool_calling = model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF"
104 + || model.model_id == "bartowski/Qwen_Qwen3-8B-GGUF";
105 + let max_tokens = if tool_calling { 4096 } else { 512 };
106 +
107 + Some(ModelPickerItem {
108 + display_name: config.display_name.clone(),
109 + description: config.approx_memory.clone(),
110 + tool_calling,
111 + max_tokens,
112 + config,
113 + source_label,
114 + brand_mark: if model.from_app_group { "◉" } else { "○" },
115 + source: if model.from_app_group {
116 + ModelSource::Onde
117 + } else {
118 + ModelSource::HuggingFace
119 + },
120 + cache_health: model.cache_health,
121 + })
122 +}