fix(ui): consistent frame glyphs on Windows — agree with clack, keep raw path ASCII (#1307)

codegraph's glyphs were ASCII on every Windows console while
@clack/prompts drew its Unicode frame around them, so one index block
mixed `|` and `│` rails (#398). supportsUnicode() now mirrors the
is-unicode-supported detection clack bundles (Windows Terminal, VS
Code, ConEmu/Cmder, Alacritty, xterm-256color, JetBrains, CI), so both
systems always pick the same glyph family.

The shimmer worker's raw fs.writeSync(1) bytes still decode through the
console codepage (OEM codepages mojibake UTF-8 even under Windows
Terminal — the #168 regression to avoid), so:

- the raw path gets its own supportsUnicodeRawWrites() that stays ASCII
  on win32 unless CODEGRAPH_UNICODE=1, and
- the persistent "phase done" lines move from the worker to the parent,
  written via process.stdout (wide-char console API, codepage-immune) at
  phase transitions — the main thread is alive there, it's delivering
  the progress callback. Only transient, self-erasing animation frames
  remain on the raw path, so ASCII never lands in scrollback.

Fixes #398

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-16 14:52:11 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent d6efd437b3
commit 30421953ac
6 changed files with 209 additions and 32 deletions
+58 -11
View File
@@ -2,18 +2,30 @@
* Glyph selection for CLI output.
*
* On Windows, console output is interpreted via the active output
* codepage. PowerShell 5.1 and cmd.exe default to OEM codepages
* (CP437, CP936, ...), so UTF-8 bytes written to the console render
* as mojibake (see #168). The shimmer worker is hit hardest because
* it uses `fs.writeSync(1, ...)` (raw bytes, no TTY-aware encoding
* conversion) to keep animation smooth while the main thread is
* blocked in SQLite. To stay readable everywhere, we fall back to
* ASCII glyphs whenever the terminal is not known to handle UTF-8.
* codepage. PowerShell 5.1 and cmd.exe in legacy conhost default to
* OEM codepages (CP437, CP936, ...), so UTF-8 bytes written to the
* console render as mojibake (see #168). The shimmer worker is hit
* hardest because it uses `fs.writeSync(1, ...)` (raw bytes, no
* TTY-aware encoding conversion) to keep animation smooth while the
* main thread is blocked in SQLite. To stay readable everywhere, we
* fall back to ASCII glyphs whenever the terminal is not known to
* handle UTF-8.
*
* Detection is intentionally simple:
* The Windows branch must agree with @clack/prompts (which bundles
* `is-unicode-supported`): clack draws the outer `┌ │ └` frame around
* init/index/sync output, and if it decides Unicode while we decide
* ASCII, one block mixes `│` and `|` rails (#398). The terminals the
* list recognizes (Windows Terminal, VS Code, ConEmu/Cmder, Alacritty,
* JetBrains, Terminus, CI log viewers) all run with a UTF-8-capable
* output path, so the raw-byte shimmer writes render correctly there
* too; unrecognized Windows consoles keep the safe ASCII fallback —
* and clack falls back to ASCII in those as well, so output stays
* consistent in both directions.
*
* Detection:
* - `CODEGRAPH_ASCII=1` -> ASCII (escape hatch for any terminal)
* - `CODEGRAPH_UNICODE=1` -> Unicode (opt-in on Windows)
* - Windows -> ASCII by default
* - `CODEGRAPH_UNICODE=1` -> Unicode (opt-in on any terminal)
* - Windows -> mirror is-unicode-supported (see above)
* - Linux kernel console (`TERM=linux`) -> ASCII
* - Everything else -> Unicode
*/
@@ -21,7 +33,20 @@
export function supportsUnicode(): boolean {
if (process.env.CODEGRAPH_ASCII === '1') return false;
if (process.env.CODEGRAPH_UNICODE === '1') return true;
if (process.platform === 'win32') return false;
if (process.platform === 'win32') {
const env = process.env;
return Boolean(
env.CI ||
env.WT_SESSION || // Windows Terminal
env.TERMINUS_SUBLIME ||
env.ConEmuTask === '{cmd::Cmder}' || // ConEmu and cmder
env.TERM_PROGRAM === 'Terminus-Sublime' ||
env.TERM_PROGRAM === 'vscode' ||
env.TERM === 'xterm-256color' ||
env.TERM === 'alacritty' ||
env.TERMINAL_EMULATOR === 'JetBrains-JediTerm'
);
}
return process.env.TERM !== 'linux';
}
@@ -85,6 +110,28 @@ export function getGlyphs(): Glyphs {
return cached;
}
/**
* Unicode support for the RAW console write path — `fs.writeSync(1, ...)`,
* used only by the shimmer worker's transient animation frames. Raw bytes
* bypass Node's TTY-aware conversion and get decoded by the ACTIVE CONSOLE
* CODEPAGE on Windows; OEM codepages (CP437, CP936, ...) mojibake UTF-8
* there even inside Windows Terminal, whose ConPTY still decodes app output
* with the session codepage (#168). So the raw path stays ASCII on every
* Windows terminal unless the user opts in via CODEGRAPH_UNICODE=1 —
* independent of `supportsUnicode()`, which governs the codepage-immune
* main-thread writes (`process.stdout` uses the wide-char console API).
*/
export function supportsUnicodeRawWrites(): boolean {
if (process.env.CODEGRAPH_ASCII === '1') return false;
if (process.env.CODEGRAPH_UNICODE === '1') return true;
if (process.platform === 'win32') return false;
return process.env.TERM !== 'linux';
}
export function getRawWriteGlyphs(): Glyphs {
return supportsUnicodeRawWrites() ? UNICODE_GLYPHS : ASCII_GLYPHS;
}
/** Reset the cached glyph set. Test-only; production code should call `getGlyphs()`. */
export function _resetGlyphsCache(): void {
cached = null;