@setoelkahfi / sigit / commits / 98677fb

fix(acp): keep model-picker labels ASCII so Zed stops crashing

Zed cuts the model-picker label in its agent panel at a fixed byte offset. If that cut lands in the middle of a multi-byte character it panics, and the panic takes the whole editor with it. Our labels had a cloud glyph and a middle-dot in them, so picking those models killed Zed. Now we sanitize the name and description to plain ASCII before sending them. The emoji badges are gone too, and cloud entries just use the tier title, so you get "Balanced [siGit Code Cloud]" instead of the brand name twice. Every byte in an ASCII string is a char boundary, so it no longer matters where Zed makes the cut. Tests cover the label that actually crashed plus a couple of char-boundary checks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

paydii committed Jun 24, 2026 at 21:31 UTC 98677fb37c0c35b5fb64802920e7293ef435974b
2 files changed +63 -8
src/main.rs
+62 -7
index 2d83591..fe29d77 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1603,6 +1603,17 @@ impl SiGitAgent { /// config option ID for the model picker in Zed's agent panel const MODEL_CONFIG_ID: &str = "sigit-model"; +/// 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 — +/// when the cut lands mid-glyph (e.g. inside `☁` or `·`). Mapping to `-` keeps +/// separators readable; ASCII bytes are always char boundaries. +fn ascii_safe(s: &str) -> String { + s.chars() + .map(|c| if c.is_ascii() { c } else { '-' }) + .collect() +} + fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> { // 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. @@ -1618,21 +1629,33 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon } desc_parts.push(item.description.clone()); if item.cache_health == setup::ModelCacheHealth::NotDownloaded { - desc_parts.push("↓ download on select".to_string()); + desc_parts.push("download on select".to_string()); } - let description = desc_parts.join(" - "); + // ASCII-only for the same reason as the name (see `ascii_safe`). + let description = ascii_safe(&desc_parts.join(" - ")); + // Keep badges ASCII: Zed truncates the picker label at a fixed byte + // offset and panics if the cut splits a multi-byte char. See + // `ascii_safe` below. let source_badge = if item.cloud_tier.is_some() { - " [☁ siGit Code Cloud]" + " [siGit Code Cloud]" } else if item.cache_health == setup::ModelCacheHealth::NotDownloaded { - " [↓ Onde]" + " [Onde]" } else { match item.source_label.as_str() { - "Onde" => " [◉ Onde]", - "HuggingFace" => " [○ HuggingFace]", + "Onde" => " [Onde]", + "HuggingFace" => " [HuggingFace]", _ => "", } }; - let name = format!("{}{}", item.display_name, source_badge); + // For cloud tiers use just the tier title (e.g. "Balanced") so the + // label reads "Balanced [siGit Code Cloud]" instead of repeating the + // brand. The display name can carry non-ASCII (the cloud tier label + // is "siGit Code Cloud · Balanced"), so sanitize the whole label. + let base_name = match &item.cloud_tier { + Some(tier) => crate::provider::tier_title(tier), + None => item.display_name.clone(), + }; + let name = ascii_safe(&format!("{base_name}{source_badge}")); SessionConfigSelectOption::new( SessionConfigValueId::new(item.config.model_id.as_str()), name, @@ -2417,3 +2440,35 @@ async fn main() -> anyhow::Result<()> { run_acp_server().await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ascii_safe_replaces_multibyte_chars() { + // The exact label that crashed Zed: the cloud tier name plus the old + // "[☁ siGit Code Cloud]" badge. After sanitizing it must be pure ASCII so + // Zed's fixed byte-offset truncation can never split a glyph. + let crashing = "siGit Code Cloud · Balanced [☁ siGit Code Cloud]"; + let safe = ascii_safe(crashing); + assert!(safe.is_ascii(), "sanitized label must be ASCII: {safe:?}"); + assert_eq!(safe, "siGit Code Cloud - Balanced [- siGit Code Cloud]"); + } + + #[test] + fn ascii_safe_leaves_ascii_untouched() { + let plain = "Qwen 2.5 3B [Onde]"; + assert_eq!(ascii_safe(plain), plain); + } + + #[test] + fn ascii_safe_output_has_only_char_boundaries() { + // Every byte index in an ASCII string is a valid char boundary, so any + // downstream truncation is panic-free regardless of where it cuts. + let safe = ascii_safe("Onde · ◉ ↓ ☁ ○ test"); + for i in 0..=safe.len() { + assert!(safe.is_char_boundary(i)); + } + } +}
src/provider.rs
+1 -1
index 0897686..debd292 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -79,7 +79,7 @@ pub struct ProviderConfig { } /// Title-case a tier name for display (`balanced` → `Balanced`). -fn tier_title(tier: &str) -> String { +pub fn tier_title(tier: &str) -> String { let tier = tier.trim(); let mut chars = tier.chars(); match chars.next() {