fix(cli): ASCII glyph fallback for Windows console mojibake (#168) (#178)

The shimmer progress renderer writes from a worker thread via
`fs.writeSync(1, ...)` to keep the animation smooth while the main
thread is busy in SQLite. That path bypasses Node's TTY-aware
UTF-8->codepage conversion on Windows, so glyphs like `|`/`<>`/`-`
were emitted as raw UTF-8 bytes and reinterpreted by the console's
OEM codepage (CP437, CP936, ...), producing strings like
`鋍?[0m 鉒?[0m Scanning files 鈥?N found`.

Add `src/ui/glyphs.ts` with `supportsUnicode()` detection plus
matched Unicode + ASCII glyph sets, and route all CLI/shimmer
output through `getGlyphs()`. Defaults: ASCII on Windows and on
Linux kernel consoles (`TERM=linux`), Unicode everywhere else.
`CODEGRAPH_UNICODE=1` and `CODEGRAPH_ASCII=1` are escape hatches.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-19 10:45:20 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 36c8dbc404
commit e176062c56
7 changed files with 322 additions and 34 deletions
+91
View File
@@ -0,0 +1,91 @@
/**
* 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.
*
* Detection is intentionally simple:
* - `CODEGRAPH_ASCII=1` -> ASCII (escape hatch for any terminal)
* - `CODEGRAPH_UNICODE=1` -> Unicode (opt-in on Windows)
* - Windows -> ASCII by default
* - Linux kernel console (`TERM=linux`) -> ASCII
* - Everything else -> Unicode
*/
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;
return process.env.TERM !== 'linux';
}
export interface Glyphs {
ok: string;
err: string;
info: string;
warn: string;
spinner: string[];
barFilled: string;
barEmpty: string;
rail: string;
phaseDone: string;
dash: string;
hLine: string;
treeBranch: string;
treeLast: string;
treePipe: string;
}
export const UNICODE_GLYPHS: Glyphs = {
ok: '✓',
err: '✗',
info: '',
warn: '⚠',
spinner: ['·', '✢', '✳', '✶', '✻', '✽'],
barFilled: '█',
barEmpty: '░',
rail: '│',
phaseDone: '◆',
dash: '—',
hLine: '─',
treeBranch: '├── ',
treeLast: '└── ',
treePipe: '│ ',
};
export const ASCII_GLYPHS: Glyphs = {
ok: '[OK]',
err: '[ERR]',
info: '[i]',
warn: '[!]',
spinner: ['.', '*', '+', 'x', 'o', 'O'],
barFilled: '#',
barEmpty: '-',
rail: '|',
phaseDone: '*',
dash: '-',
hLine: '-',
treeBranch: '|-- ',
treeLast: '`-- ',
treePipe: '| ',
};
let cached: Glyphs | null = null;
export function getGlyphs(): Glyphs {
if (cached === null) {
cached = supportsUnicode() ? UNICODE_GLYPHS : ASCII_GLYPHS;
}
return cached;
}
/** Reset the cached glyph set. Test-only; production code should call `getGlyphs()`. */
export function _resetGlyphsCache(): void {
cached = null;
}