//! Legacy-console fallbacks for chrome glyphs that don't ship in the //! legacy Windows ConHost default font (Consolas / Lucida Console). //! //! Fallbacks are ASCII where possible (`x`, `o`, `c`, `*`), or a CP437 //! glyph when one reads better and still renders on the raster font //! (`✓` → `√` U+221A, `⇣` → `↓` U+2193). //! //! ConHost does no font fallback, so missing glyphs render as tofu. //! Windows Terminal, VS Code, and modern emulators bundle fonts (or //! fall back to one) that cover the Dingbats / symbol glyphs we use as //! chrome — `❯` (U+276F), `❙` (U+2759), `✗` (U+2717), `✓` (U+2713), //! `↗` (U+2197), `⧉` (U+29C9), `⇣` (U+21E3), the diamonds `◆`/`◇`/`◈` //! (U+25C6 / U+25C7 / U+25C8), and the braille / dot progress spinners — //! so the substitution only fires for legacy `cmd.exe` / `powershell.exe`. use std::borrow::Cow; use std::sync::OnceLock; use crate::host::HostOs; use crate::terminal::{TerminalName, terminal_context}; /// `"❯ "` normally, `"> "` on legacy ConHost. Always 2 columns wide. pub fn prompt_arrow() -> &'static str { if is_legacy_windows_console() { "> " } else { "\u{276F} " } } /// Display width of [`prompt_arrow`] in columns. pub const PROMPT_ARROW_WIDTH: u16 = 2; /// Record indicator glyph shown above the prompt while voice capture is /// active — a dot inside a ring. Two states swapped on the pulse cadence: /// FISHEYE (`◉`, filled center) on the bright half and BULLSEYE (`◎`, open /// center) on the dim half, which together with a smooth color fade reads /// like a studio recording light. ASCII fallback (`*`/`o`) on legacy /// ConHost. Always 1 column wide. pub fn record_dot(filled: bool) -> &'static str { if is_legacy_windows_console() { if filled { "*" } else { "o" } } else if filled { "\u{25C9}" } else { "\u{25CE}" } } /// `"❙"` normally, `"|"` on legacy ConHost. Always 1 column wide. pub fn collapsed_accent() -> &'static str { if is_legacy_windows_console() { "|" } else { "\u{2759}" } } /// `"✗"` (U+2717 BALLOT X) normally, `"x"` on legacy ConHost. Always 1 /// column wide. /// /// Used for close / cancel / kill buttons and failure status markers. The /// Dingbats `✗` is not covered by Consolas / Lucida Console, so it renders /// as tofu on legacy `cmd.exe` / `powershell.exe` — same coverage gap as /// the chrome glyphs above. pub fn ballot_x() -> &'static str { if is_legacy_windows_console() { "x" } else { "\u{2717}" } } /// `"✓"` (U+2713 CHECK MARK) normally, `"√"` (U+221A SQUARE ROOT) on legacy /// ConHost. Always 1 column wide. /// /// The success / done sibling of [`ballot_x`]; the Dingbats `✓` shares the /// same coverage gap on legacy consoles. The fallback `√` is a CP437 glyph /// (code 0xFB), so it renders even on the stripped-down raster font and /// reads as a checkmark — pairing with the `x` failure mark. pub fn check_mark() -> &'static str { if is_legacy_windows_console() { "\u{221A}" } else { "\u{2713}" } } /// `"↗"` (U+2197 NORTH EAST ARROW) normally, `"o"` on legacy ConHost. /// Always 1 column wide. /// /// The enlarge / view / fullscreen button glyph. The previous glyph /// (`⛶` U+26F6 SQUARE FOUR CORNERS) lives in the Miscellaneous Symbols /// block and is missing from many modern monospace fonts too — not just /// legacy Windows consoles — so it rendered as tofu (`□`) in common /// macOS/Linux terminals. U+2197 lives in the well-covered core Arrows /// block and reads as the standard "open / maximize" affordance. pub fn enlarge() -> &'static str { if is_legacy_windows_console() { "o" } else { "\u{2197}" } } /// `"⧉"` (U+29C9 TWO JOINED SQUARES) normally, `"c"` on legacy ConHost. /// Always 1 column wide. /// /// The copy button glyph on the scrollback selection box (pairs with /// [`enlarge`]). U+29C9 lives in Miscellaneous Mathematical Symbols-B and /// is absent from legacy console fonts. pub fn copy_icon() -> &'static str { if is_legacy_windows_console() { "c" } else { "\u{29C9}" } } /// `"⇣"` (U+21E3 DOWNWARDS DASHED ARROW) normally, `"↓"` (U+2193) on legacy /// ConHost. Always 1 column wide. /// /// Used for the context-token count in the turn-status line. The dashed /// arrow is missing from legacy console fonts, but the plain down arrow /// (present in CP437) is a faithful, always-renderable stand-in. pub fn token_arrow() -> &'static str { if is_legacy_windows_console() { "\u{2193}" } else { "\u{21E3}" } } /// Pulsing monitor-indicator frames (`○ ◎ ◉ ◎` — U+25CB WHITE CIRCLE, /// U+25CE BULLSEYE, U+25C9 FISHEYE, U+25CE BULLSEYE) normally; a 1-column /// dot pulse (`·`, `○`, `•`, `○`) on legacy ConHost. /// /// Animates the "watching · N monitors" cue in the turn-status line: a /// concentric circle that breathes open → shut like a scanning scope. Of /// the fancy frames only the white circle `○` (U+25CB, CP437 `0x09`) is /// part of CP437 — the bullseye `◎` and fisheye `◉` live in the Geometric /// Shapes block and render as tofu on the legacy raster font. The legacy /// fallback keeps the same fixed-size breath using only CP437 dots — /// middle dot `·` (U+00B7, `0xFA`), white circle `○` (`0x09`), bullet `•` /// (U+2022, `0x07`) — so it pulses by fill (faint → ring → solid → ring) /// rather than by size. Every frame in both sets is exactly 1 column so the /// trailing label never shifts as the icon animates. pub fn monitor_icon_frames() -> &'static [&'static str] { const FANCY: &[&str] = &["\u{25CB}", "\u{25CE}", "\u{25C9}", "\u{25CE}"]; const FALLBACK: &[&str] = &["\u{00B7}", "\u{25CB}", "\u{2022}", "\u{25CB}"]; if is_legacy_windows_console() { FALLBACK } else { FANCY } } /// `"◆"` (U+25C6 BLACK DIAMOND) normally, `"♦"` (U+2666 BLACK DIAMOND /// SUIT) on legacy ConHost. Always 1 column wide. /// /// The filled diamond used for the scrollback block bullet, the /// "waiting on you" status cues (turn-status + plan-approval), the /// `/context` usage bar (system / messages categories), picker leaf / /// fold indicators, and the dashboard's non-idle row markers. U+25C6 is /// absent from the legacy console raster font, but U+2666 is a CP437 /// glyph (code `0x04`) so it renders and still reads as a filled diamond. pub fn diamond_filled() -> &'static str { if is_legacy_windows_console() { "\u{2666}" } else { "\u{25C6}" } } /// `"◇"` (U+25C7 WHITE DIAMOND) normally, `"○"` (U+25CB WHITE CIRCLE) on /// legacy ConHost. Always 1 column wide. /// /// The hollow sibling of [`diamond_filled`] — the `/context` bar's free /// (unused) cells and the dashboard's idle row marker. U+25C7 is absent /// from the raster font; U+25CB is a CP437 glyph (code `0x09`) that /// renders and reads as an empty / outline marker. pub fn diamond_hollow() -> &'static str { if is_legacy_windows_console() { "\u{25CB}" } else { "\u{25C7}" } } /// `"◈"` (U+25C8 WHITE DIAMOND CONTAINING BLACK SMALL DIAMOND) normally, /// `"♦"` (U+2666) on legacy ConHost. Always 1 column wide. /// /// Used for the `/context` bar's tool-definitions category and the /// collapsed-group scrollback header. Falls back to the same CP437 /// filled diamond as [`diamond_filled`]; both call sites already /// distinguish this category by color, so collapsing the glyph on /// legacy consoles is lossless in practice. pub fn diamond_dotted() -> &'static str { if is_legacy_windows_console() { "\u{2666}" } else { "\u{25C8}" } } /// Filled-diamond glyph as a [`char`] (see [`diamond_filled`]), for the /// tool-usage sequence bar which builds its row from single `char`s. pub fn diamond_filled_char() -> char { diamond_filled().chars().next().unwrap_or('\u{25C6}') } /// Hollow-diamond glyph as a [`char`] (see [`diamond_hollow`]). pub fn diamond_hollow_char() -> char { diamond_hollow().chars().next().unwrap_or('\u{25C7}') } /// Rotating braille progress-spinner frames (`⠋⠙⠹⠸⠼⠴⠦⠧`) normally; a /// 1-column ASCII spinner (`|`, `/`, `-`, `\`) on legacy ConHost. /// /// The U+2800 Braille Patterns block is not part of CP437 and renders as /// tofu on the legacy console raster font, so the starting-session row, /// the MCP-connecting chip, the image-viewer loader, and the `/btw` /// overlay all fall back to the classic ASCII spinner there. Every frame /// in both sets is exactly 1 column so the surrounding layout never /// shifts. (The turn-status "Waiting for response…" line uses /// [`moon_spinner_frames`] instead.) pub fn braille_spinner_frames() -> &'static [&'static str] { const FANCY: &[&str] = &[ "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}", ]; const FALLBACK: &[&str] = &["|", "/", "-", "\\"]; if is_legacy_windows_console() { FALLBACK } else { FANCY } } /// Moon-phase spinner frames (`🌑🌒🌓🌔🌕🌖🌗🌘`) normally; the ASCII /// spinner on legacy ConHost. /// /// The turn-status "Waiting for response…" line reuses the official /// kimi-cli's lunation animation (rich's `moon` spinner) — one full /// lunation per cycle, matching the welcome-screen moon logo. Emoji /// frames are 2 columns wide; the caller measures the rendered frame /// (`spinner_str.width()`) so the layout adapts. Legacy ConHost's raster /// font has no emoji, so it falls back to the 1-column ASCII spinner. pub fn moon_spinner_frames() -> &'static [&'static str] { const FANCY: &[&str] = &[ "\u{1f311}", "\u{1f312}", "\u{1f313}", "\u{1f314}", "\u{1f315}", "\u{1f316}", "\u{1f317}", "\u{1f318}", ]; const FALLBACK: &[&str] = &["|", "/", "-", "\\"]; if is_legacy_windows_console() { FALLBACK } else { FANCY } } /// Pulsing dot progress-spinner frames (`⋅ : ⸬ ⁙`) normally; a quiet /// 1-column dot cycle (`.`, `:`, `·`) on legacy ConHost. /// /// U+22C5 / U+2E2C / U+2059 are absent from the CP437 raster font, so the /// running-subagent / task rows (Tasks pane + Dashboard), the dashboard /// status chips, and the active-goal indicators fall back to a quiet dot /// cycle there — period, colon, and `·` (U+00B7, CP437 `0xFA`) all render /// on the raster font. Every frame in both sets is exactly 1 column. pub fn dot_spinner_frames() -> &'static [&'static str] { const FANCY: &[&str] = &[ "\u{22c5}", ":", "\u{2e2c}", "\u{2059}", "\u{22c5}", ":", "\u{2e2c}", "\u{2059}", ]; const FALLBACK: &[&str] = &[".", ":", "\u{00b7}"]; if is_legacy_windows_console() { FALLBACK } else { FANCY } } /// `"┃"` (U+2503 HEAVY VERTICAL) normally, `"│"` (U+2502 LIGHT VERTICAL, /// CP437 `0xB3`) on legacy ConHost. Always 1 column wide. /// /// The left accent rail painted beside scrollback blocks and modal /// panels. The heavy box-drawing vertical is absent from CP437 (which /// ships only the light `│` and double `║` verticals), so it falls back /// to the light vertical that the raster font does render. pub fn accent_bar() -> &'static str { if is_legacy_windows_console() { "\u{2502}" } else { "\u{2503}" } } /// `"▴"` (U+25B4 SMALL UP-POINTING TRIANGLE) normally, `"▲"` (U+25B2, /// CP437 `0x1E`) on legacy ConHost. Always 1 column wide. /// /// The timeline sidebar's previous-turn chevron. The small triangles are /// absent from CP437; the full-size ones are control-picture glyphs the /// raster font renders. pub fn timeline_chevron_up() -> &'static str { if is_legacy_windows_console() { "\u{25B2}" } else { "\u{25B4}" } } /// `"▾"` (U+25BE SMALL DOWN-POINTING TRIANGLE) normally, `"▼"` (U+25BC, /// CP437 `0x1F`) on legacy ConHost. Always 1 column wide. /// /// The timeline sidebar's next-turn chevron; see [`timeline_chevron_up`]. pub fn timeline_chevron_down() -> &'static str { if is_legacy_windows_console() { "\u{25BC}" } else { "\u{25BE}" } } /// `"━"` (U+2501 HEAVY HORIZONTAL) normally, `"─"` (U+2500 LIGHT /// HORIZONTAL, CP437 `0xC4`) on legacy ConHost. Always 1 column wide. /// /// Prefer [`timeline_tick_active`] for the sidebar rail — on legacy ConHost /// this falls back to the same light stroke used for hover. pub fn heavy_horizontal() -> &'static str { if is_legacy_windows_console() { "\u{2500}" } else { "\u{2501}" } } /// `"─"` (U+2500 LIGHT HORIZONTAL, CP437 `0xC4`). Always 1 column wide and /// present on every target, but exposed here so the timeline sidebar's /// inactive ticks share one glyph source with [`heavy_horizontal`] instead /// of hardcoding the codepoint. pub fn light_horizontal() -> &'static str { "\u{2500}" } /// Precomposed 2-col active tick for the timeline rail: `"━━"` normally, /// `"══"` (U+2550 BOX DRAWINGS DOUBLE HORIZONTAL, CP437 `0xCD`) on legacy /// ConHost — distinct from the light hover/idle stroke there. pub fn timeline_tick_active() -> &'static str { if is_legacy_windows_console() { "\u{2550}\u{2550}" } else { "\u{2501}\u{2501}" } } /// Precomposed 2-col hover tick for the timeline rail: `"──"` (light /// horizontal). Idle ticks reuse a single light cell; this is the wide /// bright hover form. pub fn timeline_tick_hover() -> &'static str { "\u{2500}\u{2500}" } /// `"●"` (U+25CF BLACK CIRCLE) normally, `"•"` (U+2022 BULLET, CP437 /// `0x07`) on legacy ConHost. Always 1 column wide. /// /// The filled status / selection dot used in pickers, the settings and /// permission modals, the session list, and the file-search view. Its /// hollow partner `○` (U+25CB) is already a CP437 glyph (`0x09`) and /// renders `unchanged`, so only the filled variant needs a stand-in. pub fn filled_dot() -> &'static str { if is_legacy_windows_console() { "\u{2022}" } else { "\u{25CF}" } } /// `"▏"` (U+258F LEFT ONE EIGHTH BLOCK) normally, `"│"` (U+2502, CP437 /// `0xB3`) on legacy ConHost. Always 1 column wide. /// /// The thin left bar marking the selected row in the dashboard and the /// settings panes. The eighth-width block glyphs are absent from CP437, /// so it falls back to the light vertical. pub fn selection_bar() -> &'static str { if is_legacy_windows_console() { "\u{2502}" } else { "\u{258F}" } } /// `"›"` (U+203A SINGLE RIGHT-POINTING ANGLE QUOTATION MARK) normally, /// `">"` (ASCII) on legacy ConHost. Always 1 column wide. /// /// The chevron used for collapsed fold indicators, settings breadcrumbs, /// the integer-stepper increment affordance, and the dashboard "next" /// button. U+203A is absent from CP437, so it falls back to the ASCII /// greater-than sign. pub fn chevron() -> &'static str { if is_legacy_windows_console() { ">" } else { "\u{203A}" } } /// `"‹"` (U+2039 SINGLE LEFT-POINTING ANGLE QUOTATION MARK) normally, /// `"<"` (ASCII) on legacy ConHost. Always 1 column wide. /// /// The mirror of [`chevron`] — the integer-stepper decrement affordance /// and the dashboard "prev" button. Kept in lockstep so a fixed `›`/`>` /// never sits next to a tofu `‹`. pub fn chevron_left() -> &'static str { if is_legacy_windows_console() { "<" } else { "\u{2039}" } } /// `"⌄"` (U+2304 DOWN ARROWHEAD) normally, `"v"` (ASCII) on legacy /// ConHost. Always 1 column wide. /// /// The downward member of the [`chevron`] family — matches `›`'s light /// visual weight (unlike the solid `▾` disclosure triangle). Used by the /// scrollback expandable indicator when the selected row is an expanded /// verb-group header. U+2304 is absent from CP437, so it falls back to a /// lowercase `v`. pub fn chevron_down() -> &'static str { if is_legacy_windows_console() { "v" } else { "\u{2304}" } } /// `"▾"` (U+25BE BLACK DOWN-POINTING SMALL TRIANGLE) normally, `"v"` /// (ASCII) on legacy ConHost. Always 1 column wide. /// /// The "expanded" disclosure indicator for a collapsible dashboard /// section header (the section's rows are visible below it). U+25BE is /// absent from CP437, so it falls back to a lowercase `v`. pub fn disclosure_open() -> &'static str { if is_legacy_windows_console() { "v" } else { "\u{25BE}" } } /// `"▸"` (U+25B8 BLACK RIGHT-POINTING SMALL TRIANGLE) normally, `">"` /// (ASCII) on legacy ConHost. Always 1 column wide. /// /// The "collapsed" disclosure indicator for a collapsible dashboard /// section header (the section's rows are hidden). Pairs with /// [`disclosure_open`]; U+25B8 is absent from CP437, so it falls back to /// the ASCII greater-than sign. pub fn disclosure_closed() -> &'static str { if is_legacy_windows_console() { ">" } else { "\u{25B8}" } } /// `"[✗]"` normally, `"[x]"` on legacy ConHost. Always 3 columns wide. /// /// Pre-composed bracketed form of [`ballot_x`] so per-frame render paths /// (bg-task kill button, picker / dashboard close) reuse a `&'static str` /// instead of allocating a `format!` each frame. pub fn ballot_x_button() -> &'static str { if is_legacy_windows_console() { "[x]" } else { "[\u{2717}]" } } /// `"[↗]"` normally, `"[o]"` on legacy ConHost. Always 3 columns wide. /// /// Pre-composed bracketed sibling of [`ballot_x_button`] for the bg-task /// view / enlarge button. pub fn enlarge_button() -> &'static str { if is_legacy_windows_console() { "[o]" } else { "[\u{2197}]" } } /// Substitute the chrome glyphs that legacy ConHost can't render with /// legacy-console-safe equivalents (`✓` → `√`, `✗` → `x`, `⚠` → `!`) in /// free-flowing status text such as toasts. /// /// Unlike the fixed-width button helpers above, toasts are right-aligned /// flowing text assembled in ~25 call sites, so a single funnel at the /// point the toast enters view state is cleaner than threading a helper /// through every builder. Returns a borrow `unchanged` on every non-legacy /// platform, so toast strings stay byte-identical there. pub fn legacy_glyph_fallback(s: &str) -> Cow<'_, str> { if !is_legacy_windows_console() { return Cow::Borrowed(s); } if !s.contains(['\u{2713}', '\u{2717}', '\u{26A0}']) { return Cow::Borrowed(s); } Cow::Owned(to_legacy_glyphs(s)) } /// Pure glyph → legacy-safe mapping behind [`legacy_glyph_fallback`], split /// out so tests can exercise the substitution without faking the host probe. /// `√` matches [`check_mark`]'s fallback; `x` matches [`ballot_x`]'s. fn to_legacy_glyphs(s: &str) -> String { s.chars() .map(|c| match c { '\u{2713}' => '\u{221A}', '\u{2717}' => 'x', '\u{26A0}' => '!', other => other, }) .collect() } /// True when running on native Windows in a console host whose default /// font is known not to ship the Dingbats glyphs we use as chrome. /// Cached for process lifetime. /// /// `KIGI_FORCE_LEGACY_CONSOLE=1` (or `true`) forces this on regardless of /// host/terminal, and `=0` (or `false`) forces it off — a QA aid for /// eyeballing the ASCII fallbacks (or confirming the fancy glyphs) on any /// platform without a real ConHost. pub fn is_legacy_windows_console() -> bool { static CACHE: OnceLock = OnceLock::new(); *CACHE.get_or_init(|| { forced_legacy_console_override().unwrap_or_else(|| { // `env_brand`, not `brand`: a bare ConHost is detected as // `Unknown`, but `brand` optimistically becomes `WindowsTerminal` // on native Windows. Font capability needs the raw detection so // legacy consoles still get the ASCII glyph fallback. decide_legacy_windows_console(HostOs::current(), terminal_context().env_brand) }) }) } /// Read the `KIGI_FORCE_LEGACY_CONSOLE` escape hatch from the environment. fn forced_legacy_console_override() -> Option { parse_forced_legacy_console(std::env::var("KIGI_FORCE_LEGACY_CONSOLE").ok().as_deref()) } /// Pure parse of the override value so tests don't touch the environment. /// `"1"` / `"true"` → force on, `"0"` / `"false"` → force off, anything /// else (including unset) → `None` so normal host/brand detection runs. fn parse_forced_legacy_console(value: Option<&str>) -> Option { match value { Some("1" | "true") => Some(true), Some("0" | "false") => Some(false), _ => None, } } /// Pure decision function so tests can drive (host, brand) pairs /// without touching ambient state. Default-deny on Windows: an unknown /// brand is treated as legacy, since bare `cmd.exe` / `powershell.exe` /// in ConHost sets no terminal env vars and the brand probe returns /// `Unknown` in exactly the case we need to catch. fn decide_legacy_windows_console(host: HostOs, brand: TerminalName) -> bool { if host != HostOs::Windows { return false; } !matches!( brand, TerminalName::WindowsTerminal | TerminalName::VsCode | TerminalName::Cursor | TerminalName::Windsurf | TerminalName::Zed | TerminalName::WezTerm | TerminalName::Kitty | TerminalName::Alacritty | TerminalName::Ghostty | TerminalName::Rio | TerminalName::KigiDesktop ) } #[cfg(test)] mod tests { use super::*; use unicode_width::UnicodeWidthStr; // Both variants must match `PROMPT_ARROW_WIDTH` so callers using the // constant for layout math don't drift between platforms. #[test] fn prompt_arrow_variants_are_two_columns() { assert_eq!("\u{276F} ".width(), PROMPT_ARROW_WIDTH as usize); assert_eq!("> ".width(), PROMPT_ARROW_WIDTH as usize); } // Both record-dot states must be exactly 1 column so the "Recording" // label position is stable as the indicator pulses. #[test] fn record_dot_states_are_one_column() { assert_eq!(record_dot(true).width(), 1); assert_eq!(record_dot(false).width(), 1); assert_eq!("\u{25C9}".width(), 1); assert_eq!("\u{25CE}".width(), 1); } #[test] fn collapsed_accent_variants_are_one_column() { assert_eq!("\u{2759}".width(), 1); assert_eq!("|".width(), 1); } // Every icon and its fallback must be exactly one column so fixed-width // button layouts (`[✗]` / `[↗]`, the bg-task overlay, the status badges) // don't shift between platforms. #[test] fn icon_fallback_variants_are_one_column() { for (fancy, fallback) in [ ("\u{2717}", "x"), ("\u{2713}", "\u{221A}"), ("\u{2197}", "o"), ("\u{29C9}", "c"), ("\u{21E3}", "\u{2193}"), ] { assert_eq!(fancy.width(), 1, "icon {fancy:?} must be 1 column"); assert_eq!( fallback.width(), 1, "fallback {fallback:?} must be 1 column" ); } } // Every diamond glyph and its legacy fallback must be exactly one // column so the `/context` usage bar, scrollback bullets, picker fold // indicators, and dashboard row markers keep their layout on every // platform. #[test] fn diamond_variants_are_one_column() { for (fancy, fallback) in [ ("\u{25C6}", "\u{2666}"), ("\u{25C7}", "\u{25CB}"), ("\u{25C8}", "\u{2666}"), ] { assert_eq!(fancy.width(), 1, "diamond {fancy:?} must be 1 column"); assert_eq!( fallback.width(), 1, "fallback {fallback:?} must be 1 column" ); } } // Each chrome glyph and its legacy fallback must be exactly one column // so the accent rail, status dots, selection bars, and chevrons keep // their layout on every platform. #[test] fn chrome_glyph_variants_are_one_column() { for (fancy, fallback) in [ ("\u{2503}", "\u{2502}"), ("\u{25CF}", "\u{2022}"), ("\u{258F}", "\u{2502}"), ("\u{203A}", ">"), ("\u{2039}", "<"), ("\u{2304}", "v"), ] { assert_eq!(fancy.width(), 1, "glyph {fancy:?} must be 1 column"); assert_eq!( fallback.width(), 1, "fallback {fallback:?} must be 1 column" ); } } // Both the fancy frames and the fallbacks of each spinner and the // monitor pulse must be 1 column so animating them never shifts the // label / timer that follows. #[test] fn spinner_frames_are_one_column() { for frame in braille_spinner_frames() .iter() .chain(dot_spinner_frames().iter()) .chain(monitor_icon_frames().iter()) .chain( [ "|", "/", "-", "\\", ".", ":", "\u{00b7}", "\u{25cb}", "\u{2022}", ] .iter(), ) { assert_eq!(frame.width(), 1, "spinner frame {frame:?} must be 1 column"); } } // On the (non-Windows) test host the helpers must return the fancy // glyphs, and the `char` helpers must agree with their `&str` siblings. #[test] fn glyph_helpers_return_fancy_on_non_legacy() { assert!(!is_legacy_windows_console()); assert_eq!(diamond_filled(), "\u{25C6}"); assert_eq!(diamond_hollow(), "\u{25C7}"); assert_eq!(diamond_dotted(), "\u{25C8}"); assert_eq!(diamond_filled_char(), '\u{25C6}'); assert_eq!(diamond_hollow_char(), '\u{25C7}'); assert_eq!(braille_spinner_frames()[0], "\u{280b}"); assert_eq!(dot_spinner_frames()[2], "\u{2e2c}"); assert_eq!( monitor_icon_frames(), ["\u{25CB}", "\u{25CE}", "\u{25C9}", "\u{25CE}"] ); } // Both variants of each pre-composed button must keep a fixed column // width so the right-aligned chrome (cancel button, bg-task overlay, // close affordances) lands in the same cells on every platform. #[test] fn button_variants_have_stable_width() { for (fancy, fallback, cols) in [ // ballot_x_button ("[\u{2717}]", "[x]", 3), ("[\u{2197}]", "[o]", 3), ] { assert_eq!(fancy.width(), cols, "button {fancy:?} must be {cols} cols"); assert_eq!( fallback.width(), cols, "fallback {fallback:?} must be {cols} cols" ); } } // The toast scrubber maps every legacy-tofu chrome glyph to a 1-column // legacy-safe stand-in and leaves all other text untouched. #[test] fn to_legacy_glyphs_maps_known_glyphs() { assert_eq!(to_legacy_glyphs("\u{2713}\u{2717}\u{26A0}"), "\u{221A}x!"); assert_eq!( to_legacy_glyphs("\u{2713} Saved: on"), "\u{221A} Saved: on", "only the glyph is replaced; surrounding text is preserved" ); // Glyphs this module doesn't own (em dash, CJK) pass through verbatim. assert_eq!( to_legacy_glyphs("a \u{2014} \u{4e2d}"), "a \u{2014} \u{4e2d}" ); } // On the (non-Windows) test host the funnel must be a zero-copy borrow // so non-legacy toasts are byte-identical to the input. #[test] fn legacy_glyph_fallback_is_borrow_on_non_legacy() { assert!(!is_legacy_windows_console()); assert!(matches!( legacy_glyph_fallback("\u{2713} Saved"), Cow::Borrowed("\u{2713} Saved") )); } #[test] fn forced_legacy_console_override_parses_known_values() { assert_eq!(parse_forced_legacy_console(Some("1")), Some(true)); assert_eq!(parse_forced_legacy_console(Some("true")), Some(true)); assert_eq!(parse_forced_legacy_console(Some("0")), Some(false)); assert_eq!(parse_forced_legacy_console(Some("false")), Some(false)); // Unset or unrecognized → defer to normal host/brand detection. assert_eq!(parse_forced_legacy_console(None), None); assert_eq!(parse_forced_legacy_console(Some("")), None); assert_eq!(parse_forced_legacy_console(Some("yes")), None); } #[test] fn non_windows_is_never_legacy() { for brand in [ TerminalName::Unknown, TerminalName::AppleTerminal, TerminalName::Vte, TerminalName::WindowsTerminal, ] { assert!(!decide_legacy_windows_console(HostOs::Macos, brand)); assert!(!decide_legacy_windows_console(HostOs::Linux, brand)); assert!(!decide_legacy_windows_console(HostOs::Other, brand)); } } #[test] fn windows_unknown_is_legacy() { // Realistic ConHost case: no terminal env vars set. assert!(decide_legacy_windows_console( HostOs::Windows, TerminalName::Unknown )); } #[test] fn windows_terminal_is_not_legacy() { assert!(!decide_legacy_windows_console( HostOs::Windows, TerminalName::WindowsTerminal )); } #[test] fn vscode_family_on_windows_is_not_legacy() { for brand in [ TerminalName::VsCode, TerminalName::Cursor, TerminalName::Windsurf, TerminalName::Zed, ] { assert!(!decide_legacy_windows_console(HostOs::Windows, brand)); } } #[test] fn modern_emulators_on_windows_are_not_legacy() { for brand in [ TerminalName::WezTerm, TerminalName::Kitty, TerminalName::Alacritty, TerminalName::Ghostty, TerminalName::Rio, TerminalName::KigiDesktop, ] { assert!(!decide_legacy_windows_console(HostOs::Windows, brand)); } } // AppleTerminal/VTE can't actually be probed on Windows; the // assertion is the default-deny safety net for unfamiliar brands. #[test] fn unfamiliar_brands_on_windows_default_to_legacy() { for brand in [ TerminalName::AppleTerminal, TerminalName::Vte, TerminalName::Iterm2, TerminalName::WarpTerminal, ] { assert!(decide_legacy_windows_console(HostOs::Windows, brand)); } } }