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:
co-authored by
Claude Opus 4.7
parent
36c8dbc404
commit
e176062c56
@@ -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;
|
||||
}
|
||||
+17
-11
@@ -1,5 +1,6 @@
|
||||
import { parentPort, workerData } from 'worker_threads';
|
||||
import { writeSync } from 'fs';
|
||||
import { getGlyphs } from './glyphs';
|
||||
import type { ShimmerWorkerMessage } from './types';
|
||||
|
||||
// Write directly to fd 1 (stdout) instead of writeStdout().
|
||||
@@ -7,11 +8,16 @@ import type { ShimmerWorkerMessage } from './types';
|
||||
// thread's event loop — so if the main thread is blocked (e.g. SQLite),
|
||||
// stdout writes from the worker queue up and the animation freezes.
|
||||
// fs.writeSync(1, ...) is a direct kernel syscall that bypasses this.
|
||||
//
|
||||
// Side effect: bypasses Node's TTY-aware encoding conversion on Windows,
|
||||
// so UTF-8 bytes hit the console raw and mojibake on OEM codepages.
|
||||
// `getGlyphs()` returns ASCII fallbacks on Windows to avoid this (#168).
|
||||
function writeStdout(s: string): void {
|
||||
writeSync(1, s);
|
||||
}
|
||||
|
||||
const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'];
|
||||
const G = getGlyphs();
|
||||
const SPINNER_GLYPHS = G.spinner;
|
||||
const ANIM_INTERVAL = 150;
|
||||
const FRAMES_PER_GLYPH = 3;
|
||||
|
||||
@@ -43,7 +49,7 @@ function formatNumber(n: number): string {
|
||||
}
|
||||
|
||||
function renderBar(frame: number, filled: number, empty: number): string {
|
||||
if (filled === 0) return `${DM}${'░'.repeat(empty)}${RST}`;
|
||||
if (filled === 0) return `${DM}${G.barEmpty.repeat(empty)}${RST}`;
|
||||
const cycleFrames = 24;
|
||||
const shimmerPos = ((frame % cycleFrames) / cycleFrames) * (filled + 6) - 3;
|
||||
const shimmerWidth = 3;
|
||||
@@ -54,9 +60,9 @@ function renderBar(frame: number, filled: number, empty: number): string {
|
||||
const r = lerp(160, 251, t);
|
||||
const g = lerp(100, 191, t);
|
||||
const b = lerp(9, 36, t);
|
||||
bar += `\x1b[38;2;${r};${g};${b}m${BOLD}█`;
|
||||
bar += `\x1b[38;2;${r};${g};${b}m${BOLD}${G.barFilled}`;
|
||||
}
|
||||
bar += `${RST}${DM}${'░'.repeat(empty)}${RST}`;
|
||||
bar += `${RST}${DM}${G.barEmpty.repeat(empty)}${RST}`;
|
||||
return bar;
|
||||
}
|
||||
|
||||
@@ -69,7 +75,7 @@ function render(): void {
|
||||
if (!currentMessage) return;
|
||||
const frame = animFrame();
|
||||
const glyphIdx = Math.floor(frame / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length;
|
||||
const glyph = SPINNER_GLYPHS[glyphIdx] ?? '·';
|
||||
const glyph = SPINNER_GLYPHS[glyphIdx] ?? SPINNER_GLYPHS[0] ?? '.';
|
||||
const color = shimmerColor(frame);
|
||||
|
||||
let line: string;
|
||||
@@ -77,11 +83,11 @@ function render(): void {
|
||||
const barWidth = 25;
|
||||
const filled = Math.round(barWidth * currentPercent / 100);
|
||||
const empty = barWidth - filled;
|
||||
line = `${DM}│${RST} ${color}${glyph}${RST} ${currentMessage} ${renderBar(frame, filled, empty)} ${currentPercent}%`;
|
||||
line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage} ${renderBar(frame, filled, empty)} ${currentPercent}%`;
|
||||
} else if (currentCount > 0) {
|
||||
line = `${DM}│${RST} ${color}${glyph}${RST} ${currentMessage}... ${formatNumber(currentCount)} found`;
|
||||
line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage}... ${formatNumber(currentCount)} found`;
|
||||
} else {
|
||||
line = `${DM}│${RST} ${color}${glyph}${RST} ${currentMessage}...`;
|
||||
line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage}...`;
|
||||
}
|
||||
|
||||
writeStdout(`\r\x1b[K${line}`);
|
||||
@@ -91,9 +97,9 @@ function finishPhase(): void {
|
||||
if (!currentMessage) return;
|
||||
writeStdout(`\r\x1b[K`);
|
||||
let detail = '';
|
||||
if (currentPercent >= 0) detail = ' — done';
|
||||
else if (currentCount > 0) detail = ` — ${formatNumber(currentCount)} found`;
|
||||
writeStdout(`${DM}│${RST} ${GRN}◆${RST} ${currentMessage}${detail}\n`);
|
||||
if (currentPercent >= 0) detail = ` ${G.dash} done`;
|
||||
else if (currentCount > 0) detail = ` ${G.dash} ${formatNumber(currentCount)} found`;
|
||||
writeStdout(`${DM}${G.rail}${RST} ${GRN}${G.phaseDone}${RST} ${currentMessage}${detail}\n`);
|
||||
currentMessage = '';
|
||||
currentPercent = -1;
|
||||
currentCount = 0;
|
||||
|
||||
Reference in New Issue
Block a user