@hej / sigit / commits / 3b673ef

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(&current_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