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>
124 lines
4.0 KiB
TypeScript
124 lines
4.0 KiB
TypeScript
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().
|
|
// In Node.js worker threads, process.stdout is proxied through the main
|
|
// 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 G = getGlyphs();
|
|
const SPINNER_GLYPHS = G.spinner;
|
|
const ANIM_INTERVAL = 150;
|
|
const FRAMES_PER_GLYPH = 3;
|
|
|
|
const RST = '\x1b[0m';
|
|
const DM = '\x1b[2m';
|
|
const GRN = '\x1b[32m';
|
|
const BOLD = '\x1b[1m';
|
|
|
|
const startTime: number = workerData.startTime;
|
|
|
|
function animFrame(): number {
|
|
return Math.floor((Date.now() - startTime) / ANIM_INTERVAL);
|
|
}
|
|
|
|
function lerp(a: number, b: number, t: number): number {
|
|
return Math.round(a + (b - a) * t);
|
|
}
|
|
|
|
function shimmerColor(frame: number): string {
|
|
const t = (Math.sin(frame * 2 * Math.PI / 13) + 1) / 2;
|
|
const r = lerp(160, 251, t);
|
|
const g = lerp(100, 191, t);
|
|
const b = lerp(9, 36, t);
|
|
return `\x1b[38;2;${r};${g};${b}m${BOLD}`;
|
|
}
|
|
|
|
function formatNumber(n: number): string {
|
|
return n.toLocaleString();
|
|
}
|
|
|
|
function renderBar(frame: number, filled: number, empty: number): string {
|
|
if (filled === 0) return `${DM}${G.barEmpty.repeat(empty)}${RST}`;
|
|
const cycleFrames = 24;
|
|
const shimmerPos = ((frame % cycleFrames) / cycleFrames) * (filled + 6) - 3;
|
|
const shimmerWidth = 3;
|
|
let bar = '';
|
|
for (let i = 0; i < filled; i++) {
|
|
const dist = Math.abs(i - shimmerPos);
|
|
const t = Math.max(0, 1 - dist / shimmerWidth);
|
|
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}${G.barFilled}`;
|
|
}
|
|
bar += `${RST}${DM}${G.barEmpty.repeat(empty)}${RST}`;
|
|
return bar;
|
|
}
|
|
|
|
// Mutable state
|
|
let currentMessage = '';
|
|
let currentPercent = -1;
|
|
let currentCount = 0;
|
|
|
|
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] ?? SPINNER_GLYPHS[0] ?? '.';
|
|
const color = shimmerColor(frame);
|
|
|
|
let line: string;
|
|
if (currentPercent >= 0) {
|
|
const barWidth = 25;
|
|
const filled = Math.round(barWidth * currentPercent / 100);
|
|
const empty = barWidth - filled;
|
|
line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage} ${renderBar(frame, filled, empty)} ${currentPercent}%`;
|
|
} else if (currentCount > 0) {
|
|
line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage}... ${formatNumber(currentCount)} found`;
|
|
} else {
|
|
line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage}...`;
|
|
}
|
|
|
|
writeStdout(`\r\x1b[K${line}`);
|
|
}
|
|
|
|
function finishPhase(): void {
|
|
if (!currentMessage) return;
|
|
writeStdout(`\r\x1b[K`);
|
|
let detail = '';
|
|
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;
|
|
}
|
|
|
|
// Render loop — independent of main thread
|
|
const tickInterval = setInterval(render, 50);
|
|
|
|
parentPort!.on('message', (msg: ShimmerWorkerMessage) => {
|
|
if (msg.type === 'update') {
|
|
currentMessage = msg.phaseName;
|
|
currentPercent = msg.percent;
|
|
currentCount = msg.count;
|
|
} else if (msg.type === 'finish-phase') {
|
|
finishPhase();
|
|
} else if (msg.type === 'stop') {
|
|
clearInterval(tickInterval);
|
|
finishPhase();
|
|
parentPort!.postMessage({ type: 'stopped' });
|
|
}
|
|
});
|