소스 검색

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>
Colby Mchenry 1 개월 전
부모
커밋
e176062c56
7개의 변경된 파일322개의 추가작업 그리고 34개의 파일을 삭제
  1. 15 0
      CHANGELOG.md
  2. 170 0
      __tests__/glyphs.test.ts
  3. 22 20
      src/bin/codegraph.ts
  4. 5 2
      src/bin/node-version-check.ts
  5. 2 1
      src/installer/index.ts
  6. 91 0
      src/ui/glyphs.ts
  7. 17 11
      src/ui/shimmer-worker.ts

+ 15 - 0
CHANGELOG.md

@@ -22,6 +22,21 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   [@sashanclrp](https://github.com/sashanclrp) for the original report and
   detailed reproduction, and [@sgrimm](https://github.com/sgrimm) for the
   decisive wire capture that isolated the actual root cause.
+- **CLI**: terminal output no longer mojibakes on Windows PowerShell /
+  cmd.exe during `codegraph index` and `codegraph sync`. 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,
+  which bypasses Node's TTY-aware UTF-8→codepage conversion — so glyphs
+  like `│ ◆ —` were emitted as raw UTF-8 bytes and reinterpreted as the
+  console's OEM codepage (CP437, CP936, …), producing strings like
+  `鋍?[0m 鉒?[0m Scanning files 鈥?N found`. CodeGraph now picks an ASCII
+  glyph set on Windows by default (`| * -` instead of `│ ◆ —`); set
+  `CODEGRAPH_UNICODE=1` to opt back into the Unicode glyphs (e.g. on
+  pwsh 7 with UTF-8 codepage), or `CODEGRAPH_ASCII=1` on any platform to
+  force ASCII (useful for log collectors / non-TTY pipelines). Closes
+  [#168](https://github.com/colbymchenry/codegraph/issues/168). Thanks to
+  [@starkleek](https://github.com/starkleek) for the report and to
+  [@Bortlesboat](https://github.com/Bortlesboat) for the initial PR.
 
 [0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10
 

+ 170 - 0
__tests__/glyphs.test.ts

@@ -0,0 +1,170 @@
+/**
+ * Glyph fallback / Unicode-support detection.
+ *
+ * Pinned because the matrix is small and the consequence of regression
+ * is highly visible: shimmer-worker output on Windows mojibakes when
+ * UTF-8 glyphs are written via `fs.writeSync` (see #168). The detection
+ * + ASCII fallback is the contract that prevents this.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import {
+  supportsUnicode,
+  getGlyphs,
+  UNICODE_GLYPHS,
+  ASCII_GLYPHS,
+  _resetGlyphsCache,
+} from '../src/ui/glyphs';
+
+function withEnv(patch: Record<string, string | undefined>, fn: () => void): void {
+  const saved: Record<string, string | undefined> = {};
+  const savedPlatform = process.platform;
+  for (const key of Object.keys(patch)) {
+    saved[key] = process.env[key];
+    if (patch[key] === undefined) delete process.env[key];
+    else process.env[key] = patch[key];
+  }
+  _resetGlyphsCache();
+  try {
+    fn();
+  } finally {
+    for (const key of Object.keys(saved)) {
+      if (saved[key] === undefined) delete process.env[key];
+      else process.env[key] = saved[key];
+    }
+    Object.defineProperty(process, 'platform', { value: savedPlatform });
+    _resetGlyphsCache();
+  }
+}
+
+function setPlatform(value: NodeJS.Platform): void {
+  Object.defineProperty(process, 'platform', { value });
+}
+
+describe('supportsUnicode', () => {
+  let originalPlatform: NodeJS.Platform;
+
+  beforeEach(() => {
+    originalPlatform = process.platform;
+    _resetGlyphsCache();
+  });
+
+  afterEach(() => {
+    Object.defineProperty(process, 'platform', { value: originalPlatform });
+    _resetGlyphsCache();
+  });
+
+  it('returns false on Windows by default (mojibake-prone consoles)', () => {
+    withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
+      setPlatform('win32');
+      expect(supportsUnicode()).toBe(false);
+    });
+  });
+
+  it('returns true on macOS by default', () => {
+    withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
+      setPlatform('darwin');
+      expect(supportsUnicode()).toBe(true);
+    });
+  });
+
+  it('returns true on Linux by default', () => {
+    withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
+      setPlatform('linux');
+      expect(supportsUnicode()).toBe(true);
+    });
+  });
+
+  it('returns false on Linux kernel console (TERM=linux)', () => {
+    withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: 'linux' }, () => {
+      setPlatform('linux');
+      expect(supportsUnicode()).toBe(false);
+    });
+  });
+
+  it('respects CODEGRAPH_UNICODE=1 on Windows (opt-in escape hatch)', () => {
+    withEnv({ CODEGRAPH_UNICODE: '1', CODEGRAPH_ASCII: undefined }, () => {
+      setPlatform('win32');
+      expect(supportsUnicode()).toBe(true);
+    });
+  });
+
+  it('respects CODEGRAPH_ASCII=1 on macOS (opt-out escape hatch)', () => {
+    withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: undefined }, () => {
+      setPlatform('darwin');
+      expect(supportsUnicode()).toBe(false);
+    });
+  });
+
+  it('CODEGRAPH_ASCII takes precedence over CODEGRAPH_UNICODE', () => {
+    withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: '1' }, () => {
+      setPlatform('darwin');
+      expect(supportsUnicode()).toBe(false);
+    });
+  });
+});
+
+describe('getGlyphs', () => {
+  let originalPlatform: NodeJS.Platform;
+
+  beforeEach(() => {
+    originalPlatform = process.platform;
+    _resetGlyphsCache();
+  });
+
+  afterEach(() => {
+    Object.defineProperty(process, 'platform', { value: originalPlatform });
+    _resetGlyphsCache();
+  });
+
+  it('returns ASCII glyphs on Windows', () => {
+    withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
+      setPlatform('win32');
+      const g = getGlyphs();
+      expect(g).toBe(ASCII_GLYPHS);
+      expect(g.ok).toBe('[OK]');
+      expect(g.rail).toBe('|');
+      expect(g.phaseDone).toBe('*');
+      expect(g.dash).toBe('-');
+    });
+  });
+
+  it('returns Unicode glyphs on macOS', () => {
+    withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
+      setPlatform('darwin');
+      const g = getGlyphs();
+      expect(g).toBe(UNICODE_GLYPHS);
+      expect(g.ok).toBe('✓');
+      expect(g.rail).toBe('│');
+      expect(g.phaseDone).toBe('◆');
+      expect(g.dash).toBe('—');
+    });
+  });
+
+  it('caches the result so repeated calls return the same object', () => {
+    withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
+      setPlatform('darwin');
+      expect(getGlyphs()).toBe(getGlyphs());
+    });
+  });
+});
+
+describe('Glyph sets', () => {
+  it('ASCII and Unicode sets cover the same keys', () => {
+    expect(Object.keys(ASCII_GLYPHS).sort()).toEqual(Object.keys(UNICODE_GLYPHS).sort());
+  });
+
+  it('ASCII glyphs are all 7-bit ASCII', () => {
+    for (const [key, value] of Object.entries(ASCII_GLYPHS)) {
+      const flat = Array.isArray(value) ? value.join('') : value;
+      for (let i = 0; i < flat.length; i++) {
+        const codepoint = flat.charCodeAt(i);
+        expect(codepoint, `ASCII_GLYPHS.${key} contains non-ASCII char U+${codepoint.toString(16).toUpperCase().padStart(4, '0')}`).toBeLessThan(128);
+      }
+    }
+  });
+
+  it('ASCII spinner has the same frame count as the Unicode spinner', () => {
+    expect(ASCII_GLYPHS.spinner.length).toBe(UNICODE_GLYPHS.spinner.length);
+  });
+});

+ 22 - 20
src/bin/codegraph.ts

@@ -23,6 +23,7 @@ import * as path from 'path';
 import * as fs from 'fs';
 import { getCodeGraphDir, isInitialized } from '../directory';
 import { createShimmerProgress } from '../ui/shimmer-progress';
+import { getGlyphs } from '../ui/glyphs';
 
 import { buildNode25BlockBanner } from './node-version-check';
 
@@ -32,7 +33,7 @@ async function loadCodeGraph(): Promise<typeof import('../index')> {
     return await import('../index');
   } catch (err) {
     const msg = err instanceof Error ? err.message : String(err);
-    console.error('\x1b[31m✗\x1b[0m Failed to load CodeGraph modules.');
+    console.error(`\x1b[31m${getGlyphs().err}\x1b[0m Failed to load CodeGraph modules.`);
     console.error(`\n  Node: ${process.version}  Platform: ${process.platform} ${process.arch}`);
     console.error(`\n  Error: ${msg}`);
     console.error('\n  Try reinstalling with: npm install -g @colbymchenry/codegraph\n');
@@ -212,7 +213,7 @@ function createVerboseProgress(): (progress: { phase: string; current: number; t
       // Log every 5% to keep output manageable
       if (pct >= lastPct + 5 || progress.current === progress.total) {
         lastPct = pct;
-        console.log(`[${elapsed}s]   ${progress.current}/${progress.total} (${pct}%)${progress.currentFile ? `  ${progress.currentFile}` : ''}`);
+        console.log(`[${elapsed}s]   ${progress.current}/${progress.total} (${pct}%)${progress.currentFile ? ` ${getGlyphs().dash} ${progress.currentFile}` : ''}`);
       }
     } else if (progress.current > 0) {
       // Scanning phase (no total yet) — log periodically
@@ -227,28 +228,28 @@ function createVerboseProgress(): (progress: { phase: string; current: number; t
  * Print success message
  */
 function success(message: string): void {
-  console.log(chalk.green('✓') + ' ' + message);
+  console.log(chalk.green(getGlyphs().ok) + ' ' + message);
 }
 
 /**
  * Print error message
  */
 function error(message: string): void {
-  console.error(chalk.red('✗') + ' ' + message);
+  console.error(chalk.red(getGlyphs().err) + ' ' + message);
 }
 
 /**
  * Print info message
  */
 function info(message: string): void {
-  console.log(chalk.blue('ℹ') + ' ' + message);
+  console.log(chalk.blue(getGlyphs().info) + ' ' + message);
 }
 
 /**
  * Print warning message
  */
 function warn(message: string): void {
-  console.log(chalk.yellow('⚠') + ' ' + message);
+  console.log(chalk.yellow(getGlyphs().warn) + ' ' + message);
 }
 
 type IndexResult = {
@@ -281,7 +282,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR
   // continuing to the misleading "No files found" branch or throwing.
   if (!result.success && !hasErrors && result.filesIndexed === 0) {
     const generic = result.errors.find((e) => e.severity === 'error');
-    clack.log.error(generic?.message ?? 'Indexing failed — no further details available');
+    clack.log.error(generic?.message ?? `Indexing failed ${getGlyphs().dash} no further details available`);
     return;
   }
 
@@ -293,7 +294,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR
     }
     clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`);
   } else if (hasErrors) {
-    clack.log.error(`Indexing failed  all ${formatNumber(result.filesErrored)} files had errors`);
+    clack.log.error(`Indexing failed ${getGlyphs().dash} all ${formatNumber(result.filesErrored)} files had errors`);
   } else {
     clack.log.warn('No files found to index');
   }
@@ -327,7 +328,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR
     }
 
     if (result.filesIndexed > 0) {
-      clack.log.info('The index is fully usable — only the failed files are missing.');
+      clack.log.info(`The index is fully usable ${getGlyphs().dash} only the failed files are missing.`);
     }
   } else if (projectPath) {
     const logPath = path.join(projectPath, '.codegraph', 'errors.log');
@@ -365,7 +366,7 @@ function writeErrorLog(projectPath: string, errors: Array<{ message: string; fil
   }
 
   const lines: string[] = [
-    `CodeGraph Error Log  ${new Date().toISOString()}`,
+    `CodeGraph Error Log - ${new Date().toISOString()}`,
     `${errorsByFile.size} files with errors`,
     '',
   ];
@@ -445,7 +446,7 @@ program
             verbose: true,
           });
         } else {
-          process.stdout.write(`${colors.dim}${colors.reset}\n`);
+          process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
           const progress = createShimmerProgress();
           result = await cg.indexAll({
             onProgress: progress.onProgress,
@@ -488,7 +489,7 @@ program
         const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
         const answer = await new Promise<string>((resolve) => {
           rl.question(
-            chalk.yellow('⚠ This will permanently delete all CodeGraph data. Continue? (y/N) '),
+            chalk.yellow(`${getGlyphs().warn} This will permanently delete all CodeGraph data. Continue? (y/N) `),
             resolve
           );
         });
@@ -558,7 +559,7 @@ program
           verbose: true,
         });
       } else {
-        process.stdout.write(`${colors.dim}${colors.reset}\n`);
+        process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
         const progress = createShimmerProgress();
         result = await cg.indexAll({
           onProgress: progress.onProgress,
@@ -610,7 +611,7 @@ program
       const clack = await importESM('@clack/prompts');
       clack.intro('Syncing CodeGraph');
 
-      process.stdout.write(`${colors.dim}${colors.reset}\n`);
+      process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
       const progress = createShimmerProgress();
 
       const result = await cg.sync({
@@ -629,7 +630,7 @@ program
         if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`);
         if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`);
         if (result.filesRemoved > 0) details.push(`Removed: ${result.filesRemoved}`);
-        clack.log.info(`${details.join(', ')}  ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`);
+        clack.log.info(`${details.join(', ')} ${getGlyphs().dash} ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`);
       }
 
       clack.outro('Done');
@@ -711,7 +712,7 @@ program
       // when the native build fails.
       const backendLabel = backend === 'native'
         ? chalk.green('native')
-        : chalk.yellow('wasm — slower fallback; run `npm rebuild better-sqlite3`');
+        : chalk.yellow(`wasm ${getGlyphs().dash} slower fallback; run \`npm rebuild better-sqlite3\``);
       console.log(`  Backend:   ${backendLabel}`);
       console.log();
 
@@ -1000,8 +1001,9 @@ function printFileTree(
   const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
     if (maxDepth !== undefined && depth > maxDepth) return;
 
-    const connector = isLast ? '└── ' : '├── ';
-    const childPrefix = isLast ? '    ' : '│   ';
+    const glyphs = getGlyphs();
+    const connector = isLast ? glyphs.treeLast : glyphs.treeBranch;
+    const childPrefix = isLast ? '    ' : glyphs.treePipe;
 
     if (node.name) {
       let line = prefix + connector + node.name;
@@ -1097,7 +1099,7 @@ program
         // Default: show info about MCP mode.
         // Use stderr so stdout stays clean for any piped/stdio usage.
         console.error(chalk.bold('\nCodeGraph MCP Server\n'));
-        console.error(chalk.blue('ℹ') + ' Use --mcp flag to start the MCP server');
+        console.error(chalk.blue(getGlyphs().info) + ' Use --mcp flag to start the MCP server');
         console.error('\nTo use with Claude Code, add to your MCP configuration:');
         console.error(chalk.dim(`
 {
@@ -1143,7 +1145,7 @@ program
       const lockPath = path.join(getCodeGraphDir(projectPath), 'codegraph.lock');
 
       if (!fs.existsSync(lockPath)) {
-        info('No lock file found — nothing to do');
+        info(`No lock file found ${getGlyphs().dash} nothing to do`);
         return;
       }
 

+ 5 - 2
src/bin/node-version-check.ts

@@ -13,9 +13,12 @@
  * unsupported Node.js major version (currently 25+). Pinned via unit
  * test so the recovery commands and override instructions can't be
  * silently stripped by future edits.
+ *
+ * Uses ASCII glyphs to stay readable on Windows OEM-codepage consoles
+ * (see ../ui/glyphs.ts for the rationale).
  */
 export function buildNode25BlockBanner(nodeVersion: string): string {
-  const sep = '─'.repeat(72);
+  const sep = '-'.repeat(72);
   return [
     sep,
     `[CodeGraph] Unsupported Node.js version: ${nodeVersion}`,
@@ -29,7 +32,7 @@ export function buildNode25BlockBanner(nodeVersion: string): string {
     '  nvm install 22 && nvm use 22                          # nvm',
     '  brew install node@22 && brew link --overwrite --force node@22  # Homebrew',
     '',
-    'To override (NOT recommended  you will likely OOM):',
+    'To override (NOT recommended - you will likely OOM):',
     '  CODEGRAPH_ALLOW_UNSAFE_NODE=1 codegraph ...',
     sep,
   ].join('\n');

+ 2 - 1
src/installer/index.ts

@@ -21,6 +21,7 @@ import {
   resolveTargetFlag,
 } from './targets/registry';
 import type { AgentTarget, Location, WriteResult } from './targets/types';
+import { getGlyphs } from '../ui/glyphs';
 
 // Backwards-compat: keep these named exports — downstream code may
 // import them. The shim in `config-writer.ts` continues to re-export
@@ -331,7 +332,7 @@ async function initializeLocalProject(clack: typeof import('@clack/prompts')): P
 
   // Index the project with shimmer progress (worker thread for smooth animation)
   const { createShimmerProgress } = await import('../ui/shimmer-progress');
-  process.stdout.write(`\x1b[2m\x1b[0m\n`);
+  process.stdout.write(`\x1b[2m${getGlyphs().rail}\x1b[0m\n`);
   const progress = createShimmerProgress();
 
   const result = await cg.indexAll({

+ 91 - 0
src/ui/glyphs.ts

@@ -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
src/ui/shimmer-worker.ts

@@ -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;