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.
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,
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
+}