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
@@ -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
|
[@sashanclrp](https://github.com/sashanclrp) for the original report and
|
||||||
detailed reproduction, and [@sgrimm](https://github.com/sgrimm) for the
|
detailed reproduction, and [@sgrimm](https://github.com/sgrimm) for the
|
||||||
decisive wire capture that isolated the actual root cause.
|
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
|
[0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -23,6 +23,7 @@ import * as path from 'path';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { getCodeGraphDir, isInitialized } from '../directory';
|
import { getCodeGraphDir, isInitialized } from '../directory';
|
||||||
import { createShimmerProgress } from '../ui/shimmer-progress';
|
import { createShimmerProgress } from '../ui/shimmer-progress';
|
||||||
|
import { getGlyphs } from '../ui/glyphs';
|
||||||
|
|
||||||
import { buildNode25BlockBanner } from './node-version-check';
|
import { buildNode25BlockBanner } from './node-version-check';
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ async function loadCodeGraph(): Promise<typeof import('../index')> {
|
|||||||
return await import('../index');
|
return await import('../index');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(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 Node: ${process.version} Platform: ${process.platform} ${process.arch}`);
|
||||||
console.error(`\n Error: ${msg}`);
|
console.error(`\n Error: ${msg}`);
|
||||||
console.error('\n Try reinstalling with: npm install -g @colbymchenry/codegraph\n');
|
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
|
// Log every 5% to keep output manageable
|
||||||
if (pct >= lastPct + 5 || progress.current === progress.total) {
|
if (pct >= lastPct + 5 || progress.current === progress.total) {
|
||||||
lastPct = pct;
|
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) {
|
} else if (progress.current > 0) {
|
||||||
// Scanning phase (no total yet) — log periodically
|
// Scanning phase (no total yet) — log periodically
|
||||||
@@ -227,28 +228,28 @@ function createVerboseProgress(): (progress: { phase: string; current: number; t
|
|||||||
* Print success message
|
* Print success message
|
||||||
*/
|
*/
|
||||||
function success(message: string): void {
|
function success(message: string): void {
|
||||||
console.log(chalk.green('✓') + ' ' + message);
|
console.log(chalk.green(getGlyphs().ok) + ' ' + message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print error message
|
* Print error message
|
||||||
*/
|
*/
|
||||||
function error(message: string): void {
|
function error(message: string): void {
|
||||||
console.error(chalk.red('✗') + ' ' + message);
|
console.error(chalk.red(getGlyphs().err) + ' ' + message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print info message
|
* Print info message
|
||||||
*/
|
*/
|
||||||
function info(message: string): void {
|
function info(message: string): void {
|
||||||
console.log(chalk.blue('ℹ') + ' ' + message);
|
console.log(chalk.blue(getGlyphs().info) + ' ' + message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print warning message
|
* Print warning message
|
||||||
*/
|
*/
|
||||||
function warn(message: string): void {
|
function warn(message: string): void {
|
||||||
console.log(chalk.yellow('⚠') + ' ' + message);
|
console.log(chalk.yellow(getGlyphs().warn) + ' ' + message);
|
||||||
}
|
}
|
||||||
|
|
||||||
type IndexResult = {
|
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.
|
// continuing to the misleading "No files found" branch or throwing.
|
||||||
if (!result.success && !hasErrors && result.filesIndexed === 0) {
|
if (!result.success && !hasErrors && result.filesIndexed === 0) {
|
||||||
const generic = result.errors.find((e) => e.severity === 'error');
|
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;
|
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)}`);
|
clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`);
|
||||||
} else if (hasErrors) {
|
} 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 {
|
} else {
|
||||||
clack.log.warn('No files found to index');
|
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) {
|
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) {
|
} else if (projectPath) {
|
||||||
const logPath = path.join(projectPath, '.codegraph', 'errors.log');
|
const logPath = path.join(projectPath, '.codegraph', 'errors.log');
|
||||||
@@ -365,7 +366,7 @@ function writeErrorLog(projectPath: string, errors: Array<{ message: string; fil
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
`CodeGraph Error Log — ${new Date().toISOString()}`,
|
`CodeGraph Error Log - ${new Date().toISOString()}`,
|
||||||
`${errorsByFile.size} files with errors`,
|
`${errorsByFile.size} files with errors`,
|
||||||
'',
|
'',
|
||||||
];
|
];
|
||||||
@@ -445,7 +446,7 @@ program
|
|||||||
verbose: true,
|
verbose: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
process.stdout.write(`${colors.dim}│${colors.reset}\n`);
|
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
|
||||||
const progress = createShimmerProgress();
|
const progress = createShimmerProgress();
|
||||||
result = await cg.indexAll({
|
result = await cg.indexAll({
|
||||||
onProgress: progress.onProgress,
|
onProgress: progress.onProgress,
|
||||||
@@ -488,7 +489,7 @@ program
|
|||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
const answer = await new Promise<string>((resolve) => {
|
const answer = await new Promise<string>((resolve) => {
|
||||||
rl.question(
|
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
|
resolve
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -558,7 +559,7 @@ program
|
|||||||
verbose: true,
|
verbose: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
process.stdout.write(`${colors.dim}│${colors.reset}\n`);
|
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
|
||||||
const progress = createShimmerProgress();
|
const progress = createShimmerProgress();
|
||||||
result = await cg.indexAll({
|
result = await cg.indexAll({
|
||||||
onProgress: progress.onProgress,
|
onProgress: progress.onProgress,
|
||||||
@@ -610,7 +611,7 @@ program
|
|||||||
const clack = await importESM('@clack/prompts');
|
const clack = await importESM('@clack/prompts');
|
||||||
clack.intro('Syncing CodeGraph');
|
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 progress = createShimmerProgress();
|
||||||
|
|
||||||
const result = await cg.sync({
|
const result = await cg.sync({
|
||||||
@@ -629,7 +630,7 @@ program
|
|||||||
if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`);
|
if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`);
|
||||||
if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`);
|
if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`);
|
||||||
if (result.filesRemoved > 0) details.push(`Removed: ${result.filesRemoved}`);
|
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');
|
clack.outro('Done');
|
||||||
@@ -711,7 +712,7 @@ program
|
|||||||
// when the native build fails.
|
// when the native build fails.
|
||||||
const backendLabel = backend === 'native'
|
const backendLabel = backend === 'native'
|
||||||
? chalk.green('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(` Backend: ${backendLabel}`);
|
||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
@@ -1000,8 +1001,9 @@ function printFileTree(
|
|||||||
const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
|
const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
|
||||||
if (maxDepth !== undefined && depth > maxDepth) return;
|
if (maxDepth !== undefined && depth > maxDepth) return;
|
||||||
|
|
||||||
const connector = isLast ? '└── ' : '├── ';
|
const glyphs = getGlyphs();
|
||||||
const childPrefix = isLast ? ' ' : '│ ';
|
const connector = isLast ? glyphs.treeLast : glyphs.treeBranch;
|
||||||
|
const childPrefix = isLast ? ' ' : glyphs.treePipe;
|
||||||
|
|
||||||
if (node.name) {
|
if (node.name) {
|
||||||
let line = prefix + connector + node.name;
|
let line = prefix + connector + node.name;
|
||||||
@@ -1097,7 +1099,7 @@ program
|
|||||||
// Default: show info about MCP mode.
|
// Default: show info about MCP mode.
|
||||||
// Use stderr so stdout stays clean for any piped/stdio usage.
|
// Use stderr so stdout stays clean for any piped/stdio usage.
|
||||||
console.error(chalk.bold('\nCodeGraph MCP Server\n'));
|
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('\nTo use with Claude Code, add to your MCP configuration:');
|
||||||
console.error(chalk.dim(`
|
console.error(chalk.dim(`
|
||||||
{
|
{
|
||||||
@@ -1143,7 +1145,7 @@ program
|
|||||||
const lockPath = path.join(getCodeGraphDir(projectPath), 'codegraph.lock');
|
const lockPath = path.join(getCodeGraphDir(projectPath), 'codegraph.lock');
|
||||||
|
|
||||||
if (!fs.existsSync(lockPath)) {
|
if (!fs.existsSync(lockPath)) {
|
||||||
info('No lock file found — nothing to do');
|
info(`No lock file found ${getGlyphs().dash} nothing to do`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,12 @@
|
|||||||
* unsupported Node.js major version (currently 25+). Pinned via unit
|
* unsupported Node.js major version (currently 25+). Pinned via unit
|
||||||
* test so the recovery commands and override instructions can't be
|
* test so the recovery commands and override instructions can't be
|
||||||
* silently stripped by future edits.
|
* 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 {
|
export function buildNode25BlockBanner(nodeVersion: string): string {
|
||||||
const sep = '─'.repeat(72);
|
const sep = '-'.repeat(72);
|
||||||
return [
|
return [
|
||||||
sep,
|
sep,
|
||||||
`[CodeGraph] Unsupported Node.js version: ${nodeVersion}`,
|
`[CodeGraph] Unsupported Node.js version: ${nodeVersion}`,
|
||||||
@@ -29,7 +32,7 @@ export function buildNode25BlockBanner(nodeVersion: string): string {
|
|||||||
' nvm install 22 && nvm use 22 # nvm',
|
' nvm install 22 && nvm use 22 # nvm',
|
||||||
' brew install node@22 && brew link --overwrite --force node@22 # Homebrew',
|
' 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 ...',
|
' CODEGRAPH_ALLOW_UNSAFE_NODE=1 codegraph ...',
|
||||||
sep,
|
sep,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
resolveTargetFlag,
|
resolveTargetFlag,
|
||||||
} from './targets/registry';
|
} from './targets/registry';
|
||||||
import type { AgentTarget, Location, WriteResult } from './targets/types';
|
import type { AgentTarget, Location, WriteResult } from './targets/types';
|
||||||
|
import { getGlyphs } from '../ui/glyphs';
|
||||||
|
|
||||||
// Backwards-compat: keep these named exports — downstream code may
|
// Backwards-compat: keep these named exports — downstream code may
|
||||||
// import them. The shim in `config-writer.ts` continues to re-export
|
// 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)
|
// Index the project with shimmer progress (worker thread for smooth animation)
|
||||||
const { createShimmerProgress } = await import('../ui/shimmer-progress');
|
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 progress = createShimmerProgress();
|
||||||
|
|
||||||
const result = await cg.indexAll({
|
const result = await cg.indexAll({
|
||||||
|
|||||||
@@ -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 { parentPort, workerData } from 'worker_threads';
|
||||||
import { writeSync } from 'fs';
|
import { writeSync } from 'fs';
|
||||||
|
import { getGlyphs } from './glyphs';
|
||||||
import type { ShimmerWorkerMessage } from './types';
|
import type { ShimmerWorkerMessage } from './types';
|
||||||
|
|
||||||
// Write directly to fd 1 (stdout) instead of writeStdout().
|
// 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),
|
// 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.
|
// stdout writes from the worker queue up and the animation freezes.
|
||||||
// fs.writeSync(1, ...) is a direct kernel syscall that bypasses this.
|
// 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 {
|
function writeStdout(s: string): void {
|
||||||
writeSync(1, s);
|
writeSync(1, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'];
|
const G = getGlyphs();
|
||||||
|
const SPINNER_GLYPHS = G.spinner;
|
||||||
const ANIM_INTERVAL = 150;
|
const ANIM_INTERVAL = 150;
|
||||||
const FRAMES_PER_GLYPH = 3;
|
const FRAMES_PER_GLYPH = 3;
|
||||||
|
|
||||||
@@ -43,7 +49,7 @@ function formatNumber(n: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBar(frame: number, filled: number, empty: 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 cycleFrames = 24;
|
||||||
const shimmerPos = ((frame % cycleFrames) / cycleFrames) * (filled + 6) - 3;
|
const shimmerPos = ((frame % cycleFrames) / cycleFrames) * (filled + 6) - 3;
|
||||||
const shimmerWidth = 3;
|
const shimmerWidth = 3;
|
||||||
@@ -54,9 +60,9 @@ function renderBar(frame: number, filled: number, empty: number): string {
|
|||||||
const r = lerp(160, 251, t);
|
const r = lerp(160, 251, t);
|
||||||
const g = lerp(100, 191, t);
|
const g = lerp(100, 191, t);
|
||||||
const b = lerp(9, 36, 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;
|
return bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +75,7 @@ function render(): void {
|
|||||||
if (!currentMessage) return;
|
if (!currentMessage) return;
|
||||||
const frame = animFrame();
|
const frame = animFrame();
|
||||||
const glyphIdx = Math.floor(frame / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length;
|
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);
|
const color = shimmerColor(frame);
|
||||||
|
|
||||||
let line: string;
|
let line: string;
|
||||||
@@ -77,11 +83,11 @@ function render(): void {
|
|||||||
const barWidth = 25;
|
const barWidth = 25;
|
||||||
const filled = Math.round(barWidth * currentPercent / 100);
|
const filled = Math.round(barWidth * currentPercent / 100);
|
||||||
const empty = barWidth - filled;
|
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) {
|
} 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 {
|
} else {
|
||||||
line = `${DM}│${RST} ${color}${glyph}${RST} ${currentMessage}...`;
|
line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
writeStdout(`\r\x1b[K${line}`);
|
writeStdout(`\r\x1b[K${line}`);
|
||||||
@@ -91,9 +97,9 @@ function finishPhase(): void {
|
|||||||
if (!currentMessage) return;
|
if (!currentMessage) return;
|
||||||
writeStdout(`\r\x1b[K`);
|
writeStdout(`\r\x1b[K`);
|
||||||
let detail = '';
|
let detail = '';
|
||||||
if (currentPercent >= 0) detail = ' — done';
|
if (currentPercent >= 0) detail = ` ${G.dash} done`;
|
||||||
else if (currentCount > 0) detail = ` — ${formatNumber(currentCount)} found`;
|
else if (currentCount > 0) detail = ` ${G.dash} ${formatNumber(currentCount)} found`;
|
||||||
writeStdout(`${DM}│${RST} ${GRN}◆${RST} ${currentMessage}${detail}\n`);
|
writeStdout(`${DM}${G.rail}${RST} ${GRN}${G.phaseDone}${RST} ${currentMessage}${detail}\n`);
|
||||||
currentMessage = '';
|
currentMessage = '';
|
||||||
currentPercent = -1;
|
currentPercent = -1;
|
||||||
currentCount = 0;
|
currentCount = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user