@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
@@ -1603,6 +1603,17 @@ impl SiGitAgent {
1603 /// config option ID for the model picker in Zed's agent panel
1604 const MODEL_CONFIG_ID: &str = "sigit-model";
1605
1606 +/// Replace non-ASCII chars so a downstream byte-index truncation can't split a
1607 +/// multi-byte char. Zed slices the model-picker label at a fixed byte offset
1608 +/// (`agent_ui/src/config_options.rs`) and panics — crashing the whole editor —
1609 +/// when the cut lands mid-glyph (e.g. inside `☁` or `·`). Mapping to `-` keeps
1610 +/// separators readable; ASCII bytes are always char boundaries.
1611 +fn ascii_safe(s: &str) -> String {
1612 + s.chars()
1613 + .map(|c| if c.is_ascii() { c } else { '-' })
1614 + .collect()
1615 +}
1616 +
1617 fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
1618 // The full list, including the siGit Code Cloud tiers, so the panel picker
1619 // 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
1629 }
1630 desc_parts.push(item.description.clone());
1631 if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
1621 - desc_parts.push("↓ download on select".to_string());
1632 + desc_parts.push("download on select".to_string());
1633 }
1623 - let description = desc_parts.join(" - ");
1634 + // ASCII-only for the same reason as the name (see `ascii_safe`).
1635 + let description = ascii_safe(&desc_parts.join(" - "));
1636 + // Keep badges ASCII: Zed truncates the picker label at a fixed byte
1637 + // offset and panics if the cut splits a multi-byte char. See
1638 + // `ascii_safe` below.
1639 let source_badge = if item.cloud_tier.is_some() {
1625 - " [☁ siGit Code Cloud]"
1640 + " [siGit Code Cloud]"
1641 } else if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
1627 - " [↓ Onde]"
1642 + " [Onde]"
1643 } else {
1644 match item.source_label.as_str() {
1630 - "Onde" => " [◉ Onde]",
1631 - "HuggingFace" => " [○ HuggingFace]",
1645 + "Onde" => " [Onde]",
1646 + "HuggingFace" => " [HuggingFace]",
1647 _ => "",
1648 }
1649 };
1635 - let name = format!("{}{}", item.display_name, source_badge);
1650 + // For cloud tiers use just the tier title (e.g. "Balanced") so the
1651 + // label reads "Balanced [siGit Code Cloud]" instead of repeating the
1652 + // brand. The display name can carry non-ASCII (the cloud tier label
1653 + // is "siGit Code Cloud · Balanced"), so sanitize the whole label.
1654 + let base_name = match &item.cloud_tier {
1655 + Some(tier) => crate::provider::tier_title(tier),
1656 + None => item.display_name.clone(),
1657 + };
1658 + let name = ascii_safe(&format!("{base_name}{source_badge}"));
1659 SessionConfigSelectOption::new(
1660 SessionConfigValueId::new(item.config.model_id.as_str()),
1661 name,
@@ -2417,3 +2440,35 @@ async fn main() -> anyhow::Result<()> {
2440 run_acp_server().await
2441 }
2442 }
2443 +
2444 +#[cfg(test)]
2445 +mod tests {
2446 + use super::*;
2447 +
2448 + #[test]
2449 + fn ascii_safe_replaces_multibyte_chars() {
2450 + // The exact label that crashed Zed: the cloud tier name plus the old
2451 + // "[☁ siGit Code Cloud]" badge. After sanitizing it must be pure ASCII so
2452 + // Zed's fixed byte-offset truncation can never split a glyph.
2453 + let crashing = "siGit Code Cloud · Balanced [☁ siGit Code Cloud]";
2454 + let safe = ascii_safe(crashing);
2455 + assert!(safe.is_ascii(), "sanitized label must be ASCII: {safe:?}");
2456 + assert_eq!(safe, "siGit Code Cloud - Balanced [- siGit Code Cloud]");
2457 + }
2458 +
2459 + #[test]
2460 + fn ascii_safe_leaves_ascii_untouched() {
2461 + let plain = "Qwen 2.5 3B [Onde]";
2462 + assert_eq!(ascii_safe(plain), plain);
2463 + }
2464 +
2465 + #[test]
2466 + fn ascii_safe_output_has_only_char_boundaries() {
2467 + // Every byte index in an ASCII string is a valid char boundary, so any
2468 + // downstream truncation is panic-free regardless of where it cuts.
2469 + let safe = ascii_safe("Onde · ◉ ↓ ☁ ○ test");
2470 + for i in 0..=safe.len() {
2471 + assert!(safe.is_char_boundary(i));
2472 + }
2473 + }
2474 +}
src/provider.rs
+1 -1
@@ -79,7 +79,7 @@ pub struct ProviderConfig {
79 }
80
81 /// Title-case a tier name for display (`balanced` → `Balanced`).
82 -fn tier_title(tier: &str) -> String {
82 +pub fn tier_title(tier: &str) -> String {
83 let tier = tier.trim();
84 let mut chars = tier.chars();
85 match chars.next() {