Add model selection to agent config options and slash commands
- Expose available models as a config option in the agent panel - Implement /models and /models N slash commands to list and switch models - Track current model in SiGitAgent and persist selection - Refactor model discovery and picker item fields for public access
paydii committed
Apr 25, 2026 at 20:28 UTC
3b673efc23868b25f02fffbd908a8ef8251d65e4
3 files changed
+520
-83
src/chat.rs
+51
-47
@@ -27,7 +27,7 @@ use ratatui::{
27
layout::{Constraint, Layout, Position},
28
style::{Color, Modifier, Style},
29
text::{Line, Span},
30
- widgets::{Block, Borders, Paragraph, Wrap},
30
+ widgets::{Block, Borders, Clear, Paragraph, Wrap},
31
};
32
use tokio::sync::mpsc;
33
use tokio::time::{Duration, Instant, interval};
@@ -389,15 +389,14 @@ enum ModelSource {
389
#[derive(Clone)]
390
pub(crate) struct ModelPickerItem {
391
pub(crate) display_name: String,
392
- description: String,
393
- tool_calling: bool,
394
- max_tokens: u64,
392
+ pub(crate) description: String,
393
+ pub(crate) tool_calling: bool,
394
+ pub(crate) max_tokens: u64,
395
pub(crate) config: GgufModelConfig,
396
- source_label: String,
397
- local_path: Option<String>,
396
+ pub(crate) source_label: String,
397
brand_mark: &'static str,
398
source: ModelSource,
400
- cache_health: ModelCacheHealth,
399
+ pub(crate) cache_health: ModelCacheHealth,
400
}
401
402
pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
@@ -421,7 +420,6 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
420
max_tokens,
421
config,
422
source_label: "Platform default".to_string(),
424
- local_path: None,
423
brand_mark: "◎",
424
source: ModelSource::Fallback,
425
cache_health: ModelCacheHealth::Complete,
@@ -438,9 +436,9 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
436
437
fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> {
438
let source_label = if model.from_app_group {
441
- "Onde app group".to_string()
439
+ "Onde".to_string()
440
} else {
443
- "Hugging Face cache".to_string()
441
+ "HuggingFace".to_string()
442
};
443
444
let config = match model.model_id.as_str() {
@@ -464,7 +462,6 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker
462
max_tokens,
463
config,
464
source_label,
467
- local_path: Some(model.gguf_path.display().to_string()),
465
brand_mark: if model.from_app_group { "◉" } else { "○" },
466
source: if model.from_app_group {
467
ModelSource::Onde
@@ -476,11 +473,16 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker
473
}
474
475
fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
479
- let popup = centered_rect(72, 72, area);
476
+ let popup = centered_rect(82, 72, area);
477
+
478
+ // Erase whatever is behind the popup so the panel is fully readable.
479
+ frame.render_widget(Clear, popup);
480
+
481
let block = Block::default()
482
.title(" Select a model… ")
483
.borders(Borders::ALL)
483
- .border_style(Style::default().fg(Color::DarkGray));
484
+ .border_style(Style::default().fg(Color::DarkGray))
485
+ .style(Style::default().bg(Color::Black));
486
487
let inner = block.inner(popup);
488
frame.render_widget(block, popup);
@@ -491,7 +493,7 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
493
for (index, item) in app.model_picker_items.iter().enumerate() {
494
if last_section != Some(item.source) {
495
if last_section.is_some() {
494
- lines.push(Line::from(""));
496
+ lines.push(Line::from("").style(Style::default().bg(Color::Black)));
497
}
498
499
let (section_mark, section_name, section_style) = match item.source {
@@ -500,6 +502,7 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
502
"Onde Inference",
503
Style::default()
504
.fg(Color::Green)
505
+ .bg(Color::Black)
506
.add_modifier(Modifier::BOLD),
507
),
508
ModelSource::HuggingFace => (
@@ -507,6 +510,7 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
510
"Hugging Face cache",
511
Style::default()
512
.fg(Color::Cyan)
513
+ .bg(Color::Black)
514
.add_modifier(Modifier::BOLD),
515
),
516
ModelSource::Fallback => (
@@ -514,14 +518,18 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
518
"Fallback",
519
Style::default()
520
.fg(Color::Yellow)
521
+ .bg(Color::Black)
522
.add_modifier(Modifier::BOLD),
523
),
524
};
525
521
- lines.push(Line::from(vec![
522
- Span::styled(format!("{section_mark} "), section_style),
523
- Span::styled(section_name, section_style),
524
- ]));
526
+ lines.push(
527
+ Line::from(vec![
528
+ Span::styled(format!("{section_mark} "), section_style),
529
+ Span::styled(section_name, section_style),
530
+ ])
531
+ .style(Style::default().bg(Color::Black)),
532
+ );
533
last_section = Some(item.source);
534
}
535
@@ -545,25 +553,25 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
553
let source = format!(" [{} {}]", item.brand_mark, item.source_label);
554
555
let base_style = if selected {
548
- Style::default().fg(Color::Black).bg(Color::White)
556
+ Style::default().fg(Color::Black).bg(Color::Green)
557
} else {
550
- Style::default().fg(Color::White)
558
+ Style::default().fg(Color::White).bg(Color::Black)
559
};
560
561
let source_style = if selected {
554
- Style::default().fg(Color::DarkGray).bg(Color::White)
562
+ Style::default().fg(Color::Black).bg(Color::Green)
563
} else {
564
match item.source {
557
- ModelSource::Onde => Style::default().fg(Color::Green),
558
- ModelSource::HuggingFace => Style::default().fg(Color::Cyan),
559
- ModelSource::Fallback => Style::default().fg(Color::Yellow),
565
+ ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black),
566
+ ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black),
567
+ ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black),
568
}
569
};
570
571
let health_style = if selected {
564
- Style::default().fg(Color::Red).bg(Color::White)
572
+ Style::default().fg(Color::Red).bg(Color::Green)
573
} else {
566
- Style::default().fg(Color::Red)
574
+ Style::default().fg(Color::Red).bg(Color::Black)
575
};
576
577
lines.push(Line::from(vec![
@@ -574,44 +582,38 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
582
Span::styled(
583
tool_badge.to_string(),
584
if selected {
577
- Style::default().fg(Color::Green).bg(Color::White)
585
+ Style::default().fg(Color::Black).bg(Color::Green)
586
} else {
579
- Style::default().fg(Color::Green)
587
+ Style::default().fg(Color::Green).bg(Color::Black)
588
},
589
),
590
Span::styled(health_badge.to_string(), health_style),
591
Span::styled(
592
disabled_badge.to_string(),
593
if selected {
586
- Style::default().fg(Color::DarkGray).bg(Color::White)
594
+ Style::default().fg(Color::Black).bg(Color::Green)
595
} else {
588
- Style::default().fg(Color::DarkGray)
596
+ Style::default().fg(Color::DarkGray).bg(Color::Black)
597
},
598
),
599
Span::styled(
600
current_badge.to_string(),
601
if selected {
594
- Style::default().fg(Color::Blue).bg(Color::White)
602
+ Style::default().fg(Color::Black).bg(Color::Green)
603
} else {
596
- Style::default().fg(Color::Blue)
604
+ Style::default().fg(Color::Cyan).bg(Color::Black)
605
},
606
),
607
Span::styled(source, source_style),
608
]));
601
-
602
- if let Some(path) = &item.local_path {
603
- lines.push(Line::from(Span::styled(
604
- format!(" {}", path),
605
- if selected {
606
- Style::default().fg(Color::DarkGray).bg(Color::White)
607
- } else {
608
- Style::default().fg(Color::DarkGray)
609
- },
610
- )));
611
- }
609
}
610
614
- frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
611
+ frame.render_widget(
612
+ Paragraph::new(lines)
613
+ .wrap(Wrap { trim: false })
614
+ .style(Style::default().bg(Color::Black)),
615
+ inner,
616
+ );
617
}
618
619
fn centered_rect(
@@ -1026,7 +1028,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
1028
return None;
1029
}
1030
KeyCode::Enter => {
1029
- return Some("/models __pick__".to_string());
1031
+ return Some(format!("/models {}", app.model_picker_index + 1));
1032
}
1033
_ => return None,
1034
}
@@ -1163,8 +1165,10 @@ async fn exec_slash<B: ratatui::backend::Backend>(
1165
..SamplingConfig::default()
1166
};
1167
1166
- engine.unload_model().await;
1167
-
1168
+ // load_gguf_model unloads any existing model internally before
1169
+ // loading the new one. Calling unload_model() explicitly first
1170
+ // would create a window where no model is loaded — if a message
1171
+ // arrived in that gap it would fail with NoModelLoaded.
1172
let update = match engine
1173
.load_gguf_model(model.config.clone(), None, Some(sampling))
1174
.await
src/main.rs
+418
-6
@@ -58,7 +58,9 @@ use agent_client_protocol::{
58
ContentChunk, ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest,
59
InitializeResponse, LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest,
60
NewSessionResponse, PromptRequest, PromptResponse, ProtocolVersion, SessionCapabilities,
61
- SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate, StopReason,
61
+ SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
62
+ SessionConfigValueId, SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
63
+ SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason,
64
};
65
use futures::future::LocalBoxFuture;
66
use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult};
@@ -262,16 +264,377 @@ struct SiGitAgent {
264
/// creation. Tool calls use this as `cwd` so file operations target the
265
/// correct project, not wherever the agent process was spawned.
266
session_cwd: std::sync::Mutex<Option<PathBuf>>,
267
+ /// The currently loaded model config, used for config_options reporting.
268
+ current_model: std::sync::Mutex<GgufModelConfig>,
269
}
270
271
impl SiGitAgent {
268
- fn new(engine: Arc<ChatEngine>, notification_tx: mpsc::Sender<SessionNotification>) -> Self {
272
+ fn new(
273
+ engine: Arc<ChatEngine>,
274
+ notification_tx: mpsc::Sender<SessionNotification>,
275
+ initial_model: GgufModelConfig,
276
+ ) -> Self {
277
Self {
278
engine,
279
notification_tx,
280
session_cwd: std::sync::Mutex::new(None),
281
+ current_model: std::sync::Mutex::new(initial_model),
282
}
283
}
284
+
285
+ async fn send_assistant_message(&self, session_id: SessionId, text: impl Into<String>) {
286
+ let notification = SessionNotification::new(
287
+ session_id,
288
+ SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.into()))),
289
+ );
290
+ if self.notification_tx.send(notification).await.is_err() {
291
+ log::warn!("notification channel closed");
292
+ }
293
+ }
294
+
295
+ async fn switch_model_by_id(
296
+ &self,
297
+ model_id: &str,
298
+ ) -> agent_client_protocol::Result<GgufModelConfig> {
299
+ let (new_config, max_tokens) = resolve_model_config(model_id).ok_or_else(|| {
300
+ agent_client_protocol::Error::new(
301
+ -32602,
302
+ format!("unknown or unavailable model: {model_id}"),
303
+ )
304
+ })?;
305
+
306
+ log::info!(
307
+ "switching model to {} (max_tokens={max_tokens})",
308
+ new_config.display_name
309
+ );
310
+
311
+ let sampling = SamplingConfig {
312
+ max_tokens: Some(max_tokens),
313
+ ..SamplingConfig::default()
314
+ };
315
+
316
+ // load_gguf_model calls block_in_place internally. Calling it from
317
+ // inside the ACP LocalSet (spawn_local) panics with "can call blocking
318
+ // only when running on the multi-threaded runtime". Fix: run the
319
+ // unload + load on a dedicated OS thread with its own runtime, then
320
+ // await the result over a oneshot channel — same pattern used at
321
+ // startup in run_acp_server.
322
+ let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
323
+ let loader_engine = Arc::clone(&self.engine);
324
+ let loader_config = new_config.clone();
325
+ let loader_system_prompt = SYSTEM_PROMPT.to_string();
326
+ let loader_sampling = sampling;
327
+
328
+ std::thread::spawn(move || {
329
+ let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
330
+ let result = rt.block_on(async move {
331
+ // load_gguf_model unloads any existing model internally before
332
+ // loading the new one. Calling unload_model() explicitly first
333
+ // would create a window where no model is loaded — if a prompt
334
+ // arrived in that gap it would fail with NoModelLoaded.
335
+ loader_engine
336
+ .load_gguf_model(
337
+ loader_config,
338
+ Some(loader_system_prompt),
339
+ Some(loader_sampling),
340
+ )
341
+ .await
342
+ });
343
+ let _ = result_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
344
+ });
345
+
346
+ result_rx
347
+ .await
348
+ .map_err(|_| agent_client_protocol::Error::new(-32603, "model loader thread crashed"))?
349
+ .map_err(|error| {
350
+ log::error!("model switch failed: {error}");
351
+ agent_client_protocol::Error::new(-32603, format!("model switch failed: {error}"))
352
+ })?;
353
+
354
+ if let Some(item) = chat::build_model_picker_items()
355
+ .iter()
356
+ .find(|item| item.config.model_id == new_config.model_id)
357
+ && let Err(err) = setup::save_selected_model(&setup::SelectedModel {
358
+ model_id: item.config.model_id.clone(),
359
+ gguf_file: item.config.files.first().cloned().unwrap_or_default(),
360
+ })
361
+ {
362
+ log::warn!("failed to persist model selection: {err}");
363
+ }
364
+
365
+ {
366
+ let mut guard = self.current_model.lock().unwrap();
367
+ *guard = new_config.clone();
368
+ }
369
+
370
+ if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
371
+ self.engine
372
+ .push_history(onde::inference::ChatMessage::system(format!(
373
+ "The user's project working directory is {}. \
374
+ Always use absolute paths under this directory for all file \
375
+ and directory operations. This is the root of the project \
376
+ the user has open in their editor.",
377
+ cwd.display()
378
+ )))
379
+ .await;
380
+ }
381
+
382
+ Ok(new_config)
383
+ }
384
+}
385
+
386
+/// The config option ID used for the model selector in the Zed agent panel.
387
+const MODEL_CONFIG_ID: &str = "sigit-model";
388
+
389
+/// Build the `SessionConfigOption` list for model selection.
390
+fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
391
+ let items = chat::build_model_picker_items();
392
+
393
+ let options: Vec<SessionConfigSelectOption> = items
394
+ .iter()
395
+ .filter(|item| item.cache_health == setup::ModelCacheHealth::Complete)
396
+ .map(|item| {
397
+ let description = if item.tool_calling {
398
+ format!("{} - tool calling", item.description)
399
+ } else {
400
+ item.description.clone()
401
+ };
402
+ let source_badge = match item.source_label.as_str() {
403
+ "Onde" => " [◉ Onde]",
404
+ "HuggingFace" => " [○ HuggingFace]",
405
+ _ => "",
406
+ };
407
+ let name = format!("{}{}", item.display_name, source_badge);
408
+ SessionConfigSelectOption::new(
409
+ SessionConfigValueId::new(item.config.model_id.as_str()),
410
+ name,
411
+ )
412
+ .description(description)
413
+ })
414
+ .collect();
415
+
416
+ if options.is_empty() {
417
+ return vec![];
418
+ }
419
+
420
+ let current_value = SessionConfigValueId::new(current_model.model_id.as_str());
421
+
422
+ vec![
423
+ SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
424
+ .category(SessionConfigOptionCategory::Model)
425
+ .description("Select the local LLM model for inference"),
426
+ ]
427
+}
428
+
429
+/// Look up the GgufModelConfig for a given model_id value from the picker items.
430
+fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64)> {
431
+ let items = chat::build_model_picker_items();
432
+ items
433
+ .into_iter()
434
+ .find(|item| {
435
+ item.config.model_id == model_id
436
+ && item.cache_health == setup::ModelCacheHealth::Complete
437
+ })
438
+ .map(|item| (item.config, item.max_tokens))
439
+}
440
+
441
+#[derive(Debug, Clone)]
442
+enum SlashCommand {
443
+ Help,
444
+ Clear,
445
+ Status,
446
+ Models(Option<usize>),
447
+ Exit,
448
+ Unknown(String),
449
+}
450
+
451
+fn parse_slash(input: &str) -> Option<SlashCommand> {
452
+ let trimmed = input.trim();
453
+ if !trimmed.starts_with('/') {
454
+ return None;
455
+ }
456
+ let mut parts = trimmed.splitn(2, char::is_whitespace);
457
+ let command = parts.next().unwrap_or("");
458
+ let argument = parts.next().map(str::trim);
459
+ Some(match command {
460
+ "/help" => SlashCommand::Help,
461
+ "/clear" => SlashCommand::Clear,
462
+ "/status" => SlashCommand::Status,
463
+ "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),
464
+ "/exit" | "/quit" | "/q" => SlashCommand::Exit,
465
+ other => SlashCommand::Unknown(other.to_string()),
466
+ })
467
+}
468
+
469
+fn format_models_list(current_model: &GgufModelConfig) -> String {
470
+ let items = chat::build_model_picker_items();
471
+ if items.is_empty() {
472
+ return "No local models found. siGit will use the platform default model.".to_string();
473
+ }
474
+
475
+ let mut lines = vec!["Available models:".to_string()];
476
+ let mut last_source: Option<&str> = None;
477
+
478
+ for (index, item) in items.iter().enumerate() {
479
+ let source_key = match item.source_label.as_str() {
480
+ "Onde" => "Onde",
481
+ "HuggingFace" => "HuggingFace",
482
+ _ => "Fallback",
483
+ };
484
+
485
+ if last_source != Some(source_key) {
486
+ if last_source.is_some() {
487
+ lines.push(String::new());
488
+ }
489
+ let section = match source_key {
490
+ "Onde" => "Onde Inference",
491
+ "HuggingFace" => "Hugging Face cache",
492
+ _ => "Fallback",
493
+ };
494
+ lines.push(section.to_string());
495
+ last_source = Some(source_key);
496
+ }
497
+
498
+ let number = index + 1;
499
+ let current_badge = if item.config.model_id == current_model.model_id {
500
+ " <- current"
501
+ } else {
502
+ ""
503
+ };
504
+ let tool_badge = if item.tool_calling {
505
+ " tool calling"
506
+ } else {
507
+ ""
508
+ };
509
+ let health_badge = match item.cache_health {
510
+ setup::ModelCacheHealth::Complete => "",
511
+ setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
512
+ };
513
+ let source = match source_key {
514
+ "Onde" => " [Onde]",
515
+ "HuggingFace" => " [HuggingFace]",
516
+ _ => " [default]",
517
+ };
518
+
519
+ lines.push(format!(
520
+ "{number}. {} {}{}{}{}{}",
521
+ item.display_name, item.description, tool_badge, health_badge, current_badge, source,
522
+ ));
523
+ }
524
+
525
+ lines.push(String::new());
526
+ lines.push("Use /models N to switch models.".to_string());
527
+ lines.join("\n")
528
+}
529
+
530
+async fn exec_slash_acp(
531
+ agent: &SiGitAgent,
532
+ session_id: SessionId,
533
+ command: SlashCommand,
534
+) -> agent_client_protocol::Result<PromptResponse> {
535
+ match command {
536
+ SlashCommand::Help => {
537
+ agent
538
+ .send_assistant_message(
539
+ session_id,
540
+ "/help - show this message\n\
541
+ /models - list available models\n\
542
+ /models N - switch to model N\n\
543
+ /clear - wipe conversation history\n\
544
+ /status - show engine status\n\
545
+ /exit - end this turn",
546
+ )
547
+ .await;
548
+ }
549
+ SlashCommand::Clear => {
550
+ let cleared = agent.engine.clear_history().await;
551
+ agent
552
+ .send_assistant_message(
553
+ session_id,
554
+ format!("Cleared {cleared} turn(s). History is empty."),
555
+ )
556
+ .await;
557
+ }
558
+ SlashCommand::Status => {
559
+ let info = agent.engine.info().await;
560
+ let model = info.model_name.as_deref().unwrap_or("(none)");
561
+ let memory = info.approx_memory.as_deref().unwrap_or("unknown");
562
+ agent
563
+ .send_assistant_message(
564
+ session_id,
565
+ format!(
566
+ "status: {:?} model: {} memory: {} history: {} turns",
567
+ info.status, model, memory, info.history_length,
568
+ ),
569
+ )
570
+ .await;
571
+ }
572
+ SlashCommand::Models(None) => {
573
+ let current_model = agent.current_model.lock().unwrap().clone();
574
+ agent
575
+ .send_assistant_message(session_id, format_models_list(¤t_model))
576
+ .await;
577
+ }
578
+ SlashCommand::Models(Some(number)) => {
579
+ let items = chat::build_model_picker_items();
580
+ let index = number.saturating_sub(1);
581
+ match items.get(index).cloned() {
582
+ None => {
583
+ agent
584
+ .send_assistant_message(
585
+ session_id,
586
+ format!("error: no model #{number} - type /models to see the list."),
587
+ )
588
+ .await;
589
+ }
590
+ Some(model) => {
591
+ if model.cache_health == setup::ModelCacheHealth::Incomplete {
592
+ agent
593
+ .send_assistant_message(
594
+ session_id,
595
+ format!(
596
+ "error: {} has an incomplete local cache and cannot be selected yet.",
597
+ model.display_name
598
+ ),
599
+ )
600
+ .await;
601
+ } else {
602
+ agent
603
+ .send_assistant_message(
604
+ session_id.clone(),
605
+ format!("Loading {}...", model.display_name),
606
+ )
607
+ .await;
608
+
609
+ let switched = agent.switch_model_by_id(&model.config.model_id).await?;
610
+ agent.engine.clear_history().await;
611
+
612
+ agent
613
+ .send_assistant_message(
614
+ session_id,
615
+ format!("Switched to {}.", switched.display_name),
616
+ )
617
+ .await;
618
+ }
619
+ }
620
+ }
621
+ }
622
+ SlashCommand::Exit => {
623
+ agent
624
+ .send_assistant_message(
625
+ session_id,
626
+ "Use the panel controls to close or switch threads.",
627
+ )
628
+ .await;
629
+ }
630
+ SlashCommand::Unknown(command) => {
631
+ agent
632
+ .send_assistant_message(session_id, format!("unknown command: {command}"))
633
+ .await;
634
+ }
635
+ }
636
+
637
+ Ok(PromptResponse::new(StopReason::EndTurn))
638
}
639
640
#[async_trait::async_trait(?Send)]
@@ -350,7 +713,12 @@ impl Agent for SiGitAgent {
713
)))
714
.await;
715
353
- Ok(LoadSessionResponse::new())
716
+ let config_options = {
717
+ let guard = self.current_model.lock().unwrap();
718
+ build_model_config_options(&guard)
719
+ };
720
+
721
+ Ok(LoadSessionResponse::new().config_options(config_options))
722
}
723
724
async fn fork_session(
@@ -393,7 +761,12 @@ impl Agent for SiGitAgent {
761
)))
762
.await;
763
396
- Ok(ForkSessionResponse::new(new_id))
764
+ let config_options = {
765
+ let guard = self.current_model.lock().unwrap();
766
+ build_model_config_options(&guard)
767
+ };
768
+
769
+ Ok(ForkSessionResponse::new(new_id).config_options(config_options))
770
}
771
772
async fn new_session(
@@ -433,7 +806,12 @@ impl Agent for SiGitAgent {
806
)))
807
.await;
808
436
- Ok(NewSessionResponse::new(session_id))
809
+ let config_options = {
810
+ let guard = self.current_model.lock().unwrap();
811
+ build_model_config_options(&guard)
812
+ };
813
+
814
+ Ok(NewSessionResponse::new(session_id).config_options(config_options))
815
}
816
817
async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result<PromptResponse> {
@@ -583,6 +961,10 @@ impl Agent for SiGitAgent {
961
return Ok(PromptResponse::new(StopReason::EndTurn));
962
}
963
964
+ if let Some(command) = parse_slash(&user_text) {
965
+ return exec_slash_acp(self, session_id, command).await;
966
+ }
967
+
968
log::info!(
969
"prompt({}): \"{}\"",
970
session_id,
@@ -693,6 +1075,35 @@ impl Agent for SiGitAgent {
1075
log::info!("cancel requested for session {}", args.session_id);
1076
Ok(())
1077
}
1078
+
1079
+ async fn set_session_config_option(
1080
+ &self,
1081
+ args: SetSessionConfigOptionRequest,
1082
+ ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> {
1083
+ log::info!(
1084
+ "set_session_config_option: config_id={}, value={:?}",
1085
+ args.config_id,
1086
+ args.value
1087
+ );
1088
+
1089
+ if args.config_id.0.as_ref() != MODEL_CONFIG_ID {
1090
+ return Err(agent_client_protocol::Error::new(
1091
+ -32602,
1092
+ format!("unknown config option: {}", args.config_id.0),
1093
+ ));
1094
+ }
1095
+
1096
+ let model_id = args.value.0.as_ref();
1097
+ let _new_config = self.switch_model_by_id(model_id).await?;
1098
+
1099
+ let config_options = {
1100
+ let guard = self.current_model.lock().unwrap();
1101
+ build_model_config_options(&guard)
1102
+ };
1103
+
1104
+ log::info!("model switch complete");
1105
+ Ok(SetSessionConfigOptionResponse::new(config_options))
1106
+ }
1107
}
1108
1109
// ── Output capture ────────────────────────────────────────────────────────────
@@ -907,6 +1318,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
1318
1319
log::info!("ACP startup model: {}", config.display_name);
1320
1321
+ let startup_config = config.clone();
1322
engine
1323
.load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), Some(sampling))
1324
.await
@@ -915,7 +1327,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
1327
log::info!("model loaded and ready");
1328
1329
let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);
918
- let agent = SiGitAgent::new(engine, notification_tx);
1330
+ let agent = SiGitAgent::new(engine, notification_tx, startup_config);
1331
1332
// AgentSideConnection wants futures-io, not tokio-io.
1333
let stdin = tokio::io::stdin().compat();
src/setup.rs
+51
-30
@@ -132,18 +132,30 @@ pub enum ModelCacheHealth {
132
/// 2. Standard Hugging Face cache
133
pub fn discover_local_models() -> Vec<DiscoveredModel> {
134
let mut models = Vec::new();
135
+ let mut seen_roots = Vec::new();
136
137
if let Some(app_group_models) = app_group_models_root() {
138
+ seen_roots.push(app_group_models.clone());
139
collect_models_from_cache_root(&app_group_models, true, &mut models);
140
}
141
140
- if let Some(hf_cache) = hf_cache_root() {
142
+ if let Some(hf_cache) = hf_cache_root()
143
+ && !seen_roots.iter().any(|root| root == &hf_cache)
144
+ {
145
+ seen_roots.push(hf_cache.clone());
146
collect_models_from_cache_root(&hf_cache, false, &mut models);
147
}
148
149
+ if let Some(default_hf_cache) = default_hf_cache_root()
150
+ && !seen_roots.iter().any(|root| root == &default_hf_cache)
151
+ {
152
+ collect_models_from_cache_root(&default_hf_cache, false, &mut models);
153
+ }
154
+
155
models.sort_by(|left, right| {
145
- left.cache_health
146
- .cmp(&right.cache_health)
156
+ right
157
+ .from_app_group
158
+ .cmp(&left.from_app_group)
159
.then_with(|| {
160
left.display_name
161
.to_lowercase()
@@ -206,8 +218,6 @@ fn collect_models_from_cache_root(
218
Err(_) => continue,
219
};
220
209
- let mut has_config_json = false;
210
- let mut has_tokenizer = false;
221
let mut gguf_files = Vec::new();
222
223
for file in files.flatten() {
@@ -221,17 +231,6 @@ fn collect_models_from_cache_root(
231
None => continue,
232
};
233
224
- if file_name == "config.json" {
225
- has_config_json = true;
226
- }
227
-
228
- if file_name == "tokenizer.json"
229
- || file_name == "tokenizer.model"
230
- || file_name == "tokenizer_config.json"
231
- {
232
- has_tokenizer = true;
233
- }
234
-
234
let extension = file_path
235
.extension()
236
.and_then(|ext| ext.to_str())
@@ -242,22 +241,35 @@ fn collect_models_from_cache_root(
241
}
242
}
243
245
- let cache_health = if has_config_json && has_tokenizer {
246
- ModelCacheHealth::Complete
247
- } else {
248
- ModelCacheHealth::Incomplete
249
- };
250
-
251
- for (gguf_file, file_path) in gguf_files {
244
+ if gguf_files.is_empty() {
245
+ // No GGUF file found — the snapshot exists on disk (e.g. only
246
+ // metadata arrived, or the download is still in progress).
247
+ // Push a sentinel entry with Incomplete health so the model
248
+ // picker can show it as disabled rather than hiding it entirely.
249
models.push(DiscoveredModel {
253
- display_name: display_name_for_model(&model_id, &gguf_file),
250
+ display_name: display_name_for_model(&model_id, ""),
251
model_id: model_id.clone(),
255
- gguf_file,
252
+ gguf_file: String::new(),
253
snapshot_path: snapshot_path.clone(),
257
- gguf_path: file_path,
254
+ // Point at the snapshot directory itself; this path is
255
+ // never used for loading because Incomplete models are
256
+ // filtered out before any GgufModelConfig is built.
257
+ gguf_path: snapshot_path.clone(),
258
from_app_group,
259
- cache_health,
259
+ cache_health: ModelCacheHealth::Incomplete,
260
});
261
+ } else {
262
+ for (gguf_file, file_path) in gguf_files {
263
+ models.push(DiscoveredModel {
264
+ display_name: display_name_for_model(&model_id, &gguf_file),
265
+ model_id: model_id.clone(),
266
+ gguf_file,
267
+ snapshot_path: snapshot_path.clone(),
268
+ gguf_path: file_path,
269
+ from_app_group,
270
+ cache_health: ModelCacheHealth::Complete,
271
+ });
272
+ }
273
}
274
}
275
}
@@ -298,6 +310,10 @@ fn hf_cache_root() -> Option<PathBuf> {
310
}
311
}
312
313
+ None
314
+}
315
+
316
+fn default_hf_cache_root() -> Option<PathBuf> {
317
let home = std::env::var("HOME").ok()?;
318
let path = PathBuf::from(home)
319
.join(".cache")
@@ -475,11 +491,16 @@ mod tests {
491
let repo_dir = cache_root.join(format!("models--{}", model_id.replace('/', "--")));
492
let snapshot_dir = repo_dir.join("snapshots").join(snapshot_name);
493
std::fs::create_dir_all(&snapshot_dir).expect("create snapshot dir");
478
- std::fs::write(snapshot_dir.join(gguf_file), b"gguf").expect("write gguf");
494
495
+ // Health is determined solely by the presence of a .gguf file.
496
+ // A complete snapshot has one; an incomplete snapshot has none
497
+ // (e.g. a partial download where only metadata files arrived).
498
if complete {
481
- std::fs::write(snapshot_dir.join("config.json"), b"{}").expect("write config");
482
- std::fs::write(snapshot_dir.join("tokenizer.json"), b"{}").expect("write tokenizer");
499
+ std::fs::write(snapshot_dir.join(gguf_file), b"gguf placeholder").expect("write gguf");
500
+ } else {
501
+ // Simulate a snapshot directory that exists but has no GGUF yet.
502
+ std::fs::write(snapshot_dir.join("config.json"), b"{}")
503
+ .expect("write config placeholder");
504
}
505
506
snapshot_dir