From ba9b44e2341fab8da7ce738900a5ec37ad649dff Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 19 May 2026 10:44:37 -0500 Subject: [PATCH] fix(cli): ASCII glyph fallback for Windows console mojibake (#168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 15 +++ __tests__/glyphs.test.ts | 170 ++++++++++++++++++++++++++++++++++ src/bin/codegraph.ts | 42 +++++---- src/bin/node-version-check.ts | 7 +- src/installer/index.ts | 3 +- src/ui/glyphs.ts | 91 ++++++++++++++++++ src/ui/shimmer-worker.ts | 28 +++--- 7 files changed, 322 insertions(+), 34 deletions(-) create mode 100644 __tests__/glyphs.test.ts create mode 100644 src/ui/glyphs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b0cfce3..50cb1a5a 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/__tests__/glyphs.test.ts b/__tests__/glyphs.test.ts new file mode 100644 index 00000000..db41a105 --- /dev/null +++ b/__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, fn: () => void): void { + const saved: Record = {}; + 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); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index f9b00bd9..2b497b98 100644 --- a/src/bin/codegraph.ts +++ b/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 { 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((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; } diff --git a/src/bin/node-version-check.ts b/src/bin/node-version-check.ts index 6aed1615..4d7539a5 100644 --- a/src/bin/node-version-check.ts +++ b/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'); diff --git a/src/installer/index.ts b/src/installer/index.ts index 32772971..833759da 100644 --- a/src/installer/index.ts +++ b/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({ diff --git a/src/ui/glyphs.ts b/src/ui/glyphs.ts new file mode 100644 index 00000000..22aaeac2 --- /dev/null +++ b/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; +} diff --git a/src/ui/shimmer-worker.ts b/src/ui/shimmer-worker.ts index 46b91192..675408a4 100644 --- a/src/ui/shimmer-worker.ts +++ b/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;