Przeglądaj źródła

feat: Move parsing to worker threads for smooth progress animation

Offloads tree-sitter parsing to a dedicated worker thread, keeping the main thread unblocked so shimmer progress animations render smoothly during indexing. Refactors shimmer progress renderer into separate worker for consistent 50ms animation updates. Falls back to in-process parsing when worker compilation unavailable (e.g., tests).
Colby McHenry 2 miesięcy temu
rodzic
commit
64d844c938

+ 6 - 140
src/bin/codegraph.ts

@@ -28,6 +28,7 @@ import * as path from 'path';
 import * as fs from 'fs';
 import { spawn } from 'child_process';
 import { getCodeGraphDir, findNearestCodeGraphRoot, isInitialized } from '../directory';
+import { createShimmerProgress } from '../ui/shimmer-progress';
 
 // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
 async function loadCodeGraph(): Promise<typeof import('../index')> {
@@ -43,8 +44,6 @@ async function loadCodeGraph(): Promise<typeof import('../index')> {
   }
 }
 
-type IndexProgress = import('../index').IndexProgress;
-
 // Dynamic import helper — tsc compiles import() to require() in CJS mode,
 // which fails for ESM-only packages. This bypasses the transformation.
 // eslint-disable-next-line @typescript-eslint/no-implied-eval
@@ -174,141 +173,8 @@ function formatDuration(ms: number): string {
   return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
 }
 
-// =============================================================================
-// Shimmer Progress Renderer (devpit-style animation)
-// =============================================================================
-
-const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'];
-const ANIM_INTERVAL = 150;
-const FRAMES_PER_GLYPH = 3;
-
-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\x1b[1m`;
-}
-
-const PHASE_NAMES: Record<string, string> = {
-  scanning: 'Scanning files',
-  parsing: 'Parsing code',
-  storing: 'Storing data',
-  resolving: 'Resolving refs',
-};
-
-interface ShimmerProgress {
-  onProgress: (progress: IndexProgress) => void;
-  stop: () => void;
-}
-
-function createShimmerProgress(): ShimmerProgress {
-  const startTime = Date.now();
-  let interval: ReturnType<typeof setInterval> | null = null;
-  let currentMessage = '';
-  let currentPercent = -1;
-  let currentCount = 0;
-  let lastPhase = '';
-
-  function animFrame(): number {
-    return Math.floor((Date.now() - startTime) / ANIM_INTERVAL);
-  }
-
-  function spinnerGlyph(): string {
-    const idx = Math.floor(animFrame() / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length;
-    return SPINNER_GLYPHS[idx] ?? '·';
-  }
-
-  function renderBar(filled: number, empty: number): string {
-    if (filled === 0) return `${colors.dim}${'░'.repeat(empty)}${colors.reset}`;
-    // Shimmer sweeps left-to-right across the filled portion
-    const cycleFrames = 24;
-    const shimmerPos = ((animFrame() % 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\x1b[1m█`;
-    }
-    bar += `${colors.reset}${colors.dim}${'░'.repeat(empty)}${colors.reset}`;
-    return bar;
-  }
-
-  function render() {
-    const glyph = spinnerGlyph();
-    const frame = animFrame();
-    const color = shimmerColor(frame);
-    const rst = colors.reset;
-    const dm = colors.dim;
-
-    let line: string;
-    if (currentPercent >= 0) {
-      const barWidth = 25;
-      const filled = Math.round(barWidth * currentPercent / 100);
-      const empty = barWidth - filled;
-      line = `${dm}│${rst}  ${color}${glyph}${rst} ${currentMessage}  ${renderBar(filled, empty)}  ${currentPercent}%`;
-    } else if (currentCount > 0) {
-      line = `${dm}│${rst}  ${color}${glyph}${rst} ${currentMessage}... ${formatNumber(currentCount)} found`;
-    } else {
-      line = `${dm}│${rst}  ${color}${glyph}${rst} ${currentMessage}...`;
-    }
-
-    process.stdout.write(`\r\x1b[K${line}`);
-  }
-
-  function finishPhase() {
-    if (!currentMessage) return;
-    process.stdout.write(`\r\x1b[K`);
-    let detail = '';
-    if (currentPercent >= 0) detail = ' — done';
-    else if (currentCount > 0) detail = ` — ${formatNumber(currentCount)} found`;
-    process.stdout.write(`${colors.dim}│${colors.reset}  ${colors.green}◆${colors.reset} ${currentMessage}${detail}\n`);
-  }
-
-  // Start animation loop
-  interval = setInterval(render, ANIM_INTERVAL);
-
-  return {
-    onProgress(progress: IndexProgress) {
-      const phaseName = PHASE_NAMES[progress.phase] || progress.phase;
-
-      if (progress.phase !== lastPhase && lastPhase) {
-        finishPhase();
-      }
-      lastPhase = progress.phase;
-      currentMessage = phaseName;
-
-      if (progress.total > 0) {
-        currentPercent = Math.round((progress.current / progress.total) * 100);
-        currentCount = 0;
-      } else if (progress.current > 0) {
-        currentPercent = -1;
-        currentCount = progress.current;
-      } else {
-        currentPercent = -1;
-        currentCount = 0;
-      }
-
-      // Render immediately — scanning is synchronous so setInterval can't fire
-      render();
-    },
-    stop() {
-      if (interval) {
-        clearInterval(interval);
-        interval = null;
-      }
-      finishPhase();
-    },
-  };
-}
+// Shimmer progress renderer (runs in a worker thread for smooth animation)
+// Imported at top of file from '../ui/shimmer-progress'
 
 /**
  * Print success message
@@ -490,7 +356,7 @@ program
           onProgress: progress.onProgress,
         });
 
-        progress.stop();
+        await progress.stop();
         printIndexResult(clack, result, projectPath);
       } else {
         clack.log.info('Run "codegraph index" to index the project');
@@ -594,7 +460,7 @@ program
         onProgress: progress.onProgress,
       });
 
-      progress.stop();
+      await progress.stop();
       printIndexResult(clack, result, projectPath);
 
       if (!result.success) {
@@ -646,7 +512,7 @@ program
         onProgress: progress.onProgress,
       });
 
-      progress.stop();
+      await progress.stop();
 
       const totalChanges = result.filesAdded + result.filesModified + result.filesRemoved;
 

+ 102 - 26
src/extraction/index.ts

@@ -18,7 +18,7 @@ import {
 } from '../types';
 import { QueryBuilder } from '../db/queries';
 import { extractFromSource } from './tree-sitter';
-import { detectLanguage, isLanguageSupported, initGrammars, loadGrammarsForLanguages, resetParser } from './grammars';
+import { detectLanguage, isLanguageSupported, initGrammars, loadGrammarsForLanguages } from './grammars';
 import { logDebug, logWarn } from '../errors';
 import { validatePathWithinRoot, normalizePath } from '../utils';
 import picomatch from 'picomatch';
@@ -29,11 +29,7 @@ import picomatch from 'picomatch';
  */
 const FILE_IO_BATCH_SIZE = 10;
 
-/**
- * Reset tree-sitter parser after this many parses per language to reclaim
- * WASM heap memory and prevent "memory access out of bounds" crashes.
- */
-const PARSER_RESET_INTERVAL = 5000;
+// PARSER_RESET_INTERVAL moved to parse-worker.ts (runs in worker thread)
 
 /**
  * Progress callback for indexing operations
@@ -249,6 +245,36 @@ export function scanDirectory(
   return scanDirectoryWalk(rootDir, config, onProgress);
 }
 
+/**
+ * Async variant of scanDirectory that yields to the event loop periodically,
+ * allowing worker threads to receive and render progress messages.
+ */
+export async function scanDirectoryAsync(
+  rootDir: string,
+  config: CodeGraphConfig,
+  onProgress?: (current: number, file: string) => void
+): Promise<string[]> {
+  const gitFiles = getGitVisibleFiles(rootDir);
+  if (gitFiles) {
+    const files: string[] = [];
+    let count = 0;
+    for (const filePath of gitFiles) {
+      if (shouldIncludeFile(filePath, config)) {
+        files.push(filePath);
+        count++;
+        onProgress?.(count, filePath);
+        // Yield every 100 files so worker threads can render progress
+        if (count % 100 === 0) {
+          await new Promise<void>(r => setImmediate(r));
+        }
+      }
+    }
+    return files;
+  }
+
+  return scanDirectoryWalk(rootDir, config, onProgress);
+}
+
 /**
  * Filesystem walk fallback for non-git projects.
  */
@@ -387,7 +413,7 @@ export class ExtractionOrchestrator {
       total: 0,
     });
 
-    const files = scanDirectory(this.rootDir, this.config, (current, file) => {
+    const files = await scanDirectoryAsync(this.rootDir, this.config, (current, file) => {
       onProgress?.({
         phase: 'scanning',
         current,
@@ -409,19 +435,69 @@ export class ExtractionOrchestrator {
       };
     }
 
-    // Load only the grammars needed for languages actually present in the project.
-    // This avoids compiling all 16+ WASM grammar modules upfront, which can cause
-    // V8 WASM Zone OOM on large codebases (see issue #54).
-    const neededLanguages = [...new Set(files.map((f) => detectLanguage(f)))];
-    await loadGrammarsForLanguages(neededLanguages);
-
-    // Phase 2: Parse files (read in parallel batches, parse/store sequentially)
+    // Phase 2: Parse files in a worker thread (keeps main thread unblocked for UI)
     const total = files.length;
     let processed = 0;
-    const parseCounts = new Map<Language, number>(); // track parses per language for WASM reset
+
+    // Detect needed languages and load grammars in the parse worker
+    const neededLanguages = [...new Set(files.map((f) => detectLanguage(f)))];
+
+    // Try to use a worker thread for parsing (keeps main thread unblocked for UI).
+    // Falls back to in-process parsing if the compiled worker is unavailable (e.g. tests).
+    const parseWorkerPath = path.join(__dirname, 'parse-worker.js');
+    const useWorker = fs.existsSync(parseWorkerPath);
+    let parseWorker: import('worker_threads').Worker | null = null;
+
+    if (useWorker) {
+      const { Worker } = await import('worker_threads');
+      parseWorker = new Worker(parseWorkerPath);
+    } else {
+      // In-process fallback: load grammars locally
+      await loadGrammarsForLanguages(neededLanguages);
+    }
+
+    // Set up worker-based or in-process parsing
+    let nextId = 0;
+    const pendingParses = new Map<number, {
+      resolve: (result: ExtractionResult) => void;
+    }>();
+
+    if (parseWorker) {
+      // Wait for grammars to load in the worker
+      await new Promise<void>((resolve, reject) => {
+        parseWorker!.once('message', (msg: { type: string }) => {
+          if (msg.type === 'grammars-loaded') resolve();
+          else reject(new Error(`Unexpected message: ${msg.type}`));
+        });
+        parseWorker!.postMessage({ type: 'load-grammars', languages: neededLanguages });
+      });
+
+      parseWorker.on('message', (msg: { type: string; id?: number; result?: ExtractionResult }) => {
+        if (msg.type === 'parse-result' && msg.id !== undefined) {
+          const pending = pendingParses.get(msg.id);
+          if (pending) {
+            pendingParses.delete(msg.id);
+            pending.resolve(msg.result!);
+          }
+        }
+      });
+    }
+
+    function requestParse(filePath: string, content: string): Promise<ExtractionResult> {
+      if (parseWorker) {
+        return new Promise<ExtractionResult>((resolve) => {
+          const id = nextId++;
+          pendingParses.set(id, { resolve });
+          parseWorker!.postMessage({ type: 'parse', id, filePath, content });
+        });
+      }
+      // In-process fallback
+      return Promise.resolve(extractFromSource(filePath, content, detectLanguage(filePath)));
+    }
 
     for (let i = 0; i < files.length; i += FILE_IO_BATCH_SIZE) {
       if (signal?.aborted) {
+        if (parseWorker) await parseWorker.terminate();
         return {
           success: false,
           filesIndexed,
@@ -454,9 +530,10 @@ export class ExtractionOrchestrator {
         })
       );
 
-      // Parse and store sequentially
+      // Send to worker for parsing, store results on main thread
       for (const { filePath, content, stats, error } of fileContents) {
         if (signal?.aborted) {
+          if (parseWorker) await parseWorker.terminate();
           return {
             success: false,
             filesIndexed,
@@ -488,20 +565,16 @@ export class ExtractionOrchestrator {
           continue;
         }
 
-        const result = await this.indexFileWithContent(filePath, content, stats);
+        // Parse in worker thread (main thread stays unblocked)
+        const result = await requestParse(filePath, content);
 
-        // Periodically reset the parser to reclaim WASM heap memory.
-        // Without this, tree-sitter's WASM runtime fragments its heap
-        // across thousands of parses and eventually crashes.
-        const lang = detectLanguage(filePath);
-        const count = (parseCounts.get(lang) ?? 0) + 1;
-        parseCounts.set(lang, count);
-        if (count % PARSER_RESET_INTERVAL === 0) {
-          resetParser(lang);
+        // Store in database on main thread (SQLite is not thread-safe)
+        if (result.nodes.length > 0 || result.errors.length === 0) {
+          const language = detectLanguage(filePath);
+          this.storeExtractionResult(filePath, content, language, stats, result);
         }
 
         if (result.errors.length > 0) {
-          // Annotate errors with file path if not already set
           for (const err of result.errors) {
             if (!err.filePath) err.filePath = filePath;
           }
@@ -520,6 +593,9 @@ export class ExtractionOrchestrator {
       }
     }
 
+    // Shut down parse worker
+    if (parseWorker) await parseWorker.terminate();
+
     // Phase 3: Resolve references
     onProgress?.({
       phase: 'resolving',

+ 51 - 0
src/extraction/parse-worker.ts

@@ -0,0 +1,51 @@
+/**
+ * Parse Worker
+ *
+ * Runs tree-sitter parsing in a separate thread so the main thread
+ * stays unblocked and the UI animation renders smoothly.
+ */
+
+import { parentPort } from 'worker_threads';
+import { extractFromSource } from './tree-sitter';
+import { detectLanguage, loadGrammarsForLanguages, resetParser } from './grammars';
+import type { Language, ExtractionResult } from '../types';
+
+const PARSER_RESET_INTERVAL = 5000;
+const parseCounts = new Map<Language, number>();
+
+parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[] }) => {
+  if (msg.type === 'load-grammars') {
+    await loadGrammarsForLanguages(msg.languages!);
+    parentPort!.postMessage({ type: 'grammars-loaded' });
+  } else if (msg.type === 'parse') {
+    const { id, filePath, content } = msg;
+    try {
+      const language = detectLanguage(filePath!);
+      const result: ExtractionResult = extractFromSource(filePath!, content!, language);
+
+      // Periodic parser reset to reclaim WASM heap memory
+      const count = (parseCounts.get(language) ?? 0) + 1;
+      parseCounts.set(language, count);
+      if (count % PARSER_RESET_INTERVAL === 0) {
+        resetParser(language);
+      }
+
+      parentPort!.postMessage({ type: 'parse-result', id, result });
+    } catch (err) {
+      const message = err instanceof Error ? err.message : String(err);
+      parentPort!.postMessage({
+        type: 'parse-result',
+        id,
+        result: {
+          nodes: [],
+          edges: [],
+          unresolvedReferences: [],
+          errors: [{ message: `Parse worker error: ${message}`, filePath: filePath!, severity: 'error', code: 'parse_error' }],
+          durationMs: 0,
+        } satisfies ExtractionResult,
+      });
+    }
+  } else if (msg.type === 'shutdown') {
+    parentPort!.postMessage({ type: 'shutdown-ack' });
+  }
+});

+ 6 - 82
src/installer/index.ts

@@ -184,92 +184,16 @@ async function initializeLocalProject(clack: typeof import('@clack/prompts')): P
   const cg = await CodeGraph.init(projectPath);
   clack.log.success('Created .codegraph/ directory');
 
-  // Index the project with shimmer progress
-  const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'];
-  const ANIM_INTERVAL = 150;
-  const FRAMES_PER_GLYPH = 3;
-  const _lerp = (a: number, b: number, t: number) => Math.round(a + (b - a) * t);
-  const _shimmerColor = (frame: number) => {
-    const t = (Math.sin(frame * 2 * Math.PI / 13) + 1) / 2;
-    return `\x1b[38;2;${_lerp(160, 251, t)};${_lerp(100, 191, t)};${_lerp(9, 36, t)}m\x1b[1m`;
-  };
-  const rst = '\x1b[0m';
-  const dm = '\x1b[2m';
-  const grn = '\x1b[32m';
-  const phaseNames: Record<string, string> = {
-    scanning: 'Scanning files',
-    parsing: 'Parsing code',
-    storing: 'Storing data',
-    resolving: 'Resolving refs',
-  };
-
-  const _startTime = Date.now();
-  const _animFrame = () => Math.floor((Date.now() - _startTime) / ANIM_INTERVAL);
-  let curMsg = '';
-  let curPercent = -1;
-  let curCount = 0;
-  let lastPhase = '';
-
-  const renderBar = (filled: number, empty: number): string => {
-    if (filled === 0) return `${dm}${'░'.repeat(empty)}${rst}`;
-    const cycleFrames = 24;
-    const shimmerPos = ((_animFrame() % 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\x1b[1m█`;
-    }
-    return bar + `${rst}${dm}${'░'.repeat(empty)}${rst}`;
-  };
-
-  const renderTick = () => {
-    const frame = _animFrame();
-    const glyph = SPINNER_GLYPHS[Math.floor(frame / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length];
-    const color = _shimmerColor(frame);
-    let line: string;
-    if (curPercent >= 0) {
-      const barW = 25, filled = Math.round(barW * curPercent / 100), empty = barW - filled;
-      line = `${dm}│${rst}  ${color}${glyph}${rst} ${curMsg}  ${renderBar(filled, empty)}  ${curPercent}%`;
-    } else if (curCount > 0) {
-      line = `${dm}│${rst}  ${color}${glyph}${rst} ${curMsg}... ${formatNumber(curCount)} found`;
-    } else {
-      line = `${dm}│${rst}  ${color}${glyph}${rst} ${curMsg}...`;
-    }
-    process.stdout.write(`\r\x1b[K${line}`);
-  };
-
-  const finishPhase = () => {
-    if (!curMsg) return;
-    process.stdout.write(`\r\x1b[K`);
-    let detail = '';
-    if (curPercent >= 0) detail = ' — done';
-    else if (curCount > 0) detail = ` — ${formatNumber(curCount)} found`;
-    process.stdout.write(`${dm}│${rst}  ${grn}◆${rst} ${curMsg}${detail}\n`);
-  };
-
-  process.stdout.write(`${dm}│${rst}\n`);
-  const ticker = setInterval(renderTick, ANIM_INTERVAL);
+  // 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`);
+  const progress = createShimmerProgress();
 
   const result = await cg.indexAll({
-    onProgress: (progress) => {
-      const phaseName = phaseNames[progress.phase] || progress.phase;
-      if (progress.phase !== lastPhase && lastPhase) finishPhase();
-      lastPhase = progress.phase;
-      curMsg = phaseName;
-      if (progress.total > 0) { curPercent = Math.round((progress.current / progress.total) * 100); curCount = 0; }
-      else if (progress.current > 0) { curPercent = -1; curCount = progress.current; }
-      else { curPercent = -1; curCount = 0; }
-      renderTick();
-    },
+    onProgress: progress.onProgress,
   });
 
-  clearInterval(ticker);
-  finishPhase();
+  await progress.stop();
 
   if (result.filesErrored > 0) {
     clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} failed, ${formatNumber(result.nodesCreated)} symbols)`);

+ 73 - 0
src/ui/shimmer-progress.ts

@@ -0,0 +1,73 @@
+import { Worker } from 'worker_threads';
+import * as path from 'path';
+
+const PHASE_NAMES: Record<string, string> = {
+  scanning: 'Scanning files',
+  parsing: 'Parsing code',
+  storing: 'Storing data',
+  resolving: 'Resolving refs',
+};
+
+export interface IndexProgress {
+  phase: string;
+  current: number;
+  total: number;
+}
+
+export interface ShimmerProgress {
+  onProgress: (progress: IndexProgress) => void;
+  stop: () => Promise<void>;
+}
+
+export function createShimmerProgress(): ShimmerProgress {
+  let lastPhase = '';
+
+  const workerPath = path.join(__dirname, 'shimmer-worker.js');
+  const worker = new Worker(workerPath, {
+    workerData: { startTime: Date.now() },
+  });
+
+  return {
+    onProgress(progress: IndexProgress) {
+      const phaseName = PHASE_NAMES[progress.phase] || progress.phase;
+
+      if (progress.phase !== lastPhase && lastPhase) {
+        worker.postMessage({ type: 'finish-phase' });
+      }
+      lastPhase = progress.phase;
+
+      let percent = -1;
+      let count = 0;
+      if (progress.total > 0) {
+        percent = Math.round((progress.current / progress.total) * 100);
+      } else if (progress.current > 0) {
+        count = progress.current;
+      }
+
+      worker.postMessage({
+        type: 'update',
+        phase: progress.phase,
+        phaseName,
+        percent,
+        count,
+      });
+    },
+
+    stop() {
+      return new Promise<void>((resolve) => {
+        const timeout = setTimeout(() => {
+          worker.terminate().then(() => resolve());
+        }, 2000);
+
+        worker.on('message', (msg: { type: string }) => {
+          if (msg.type === 'stopped') {
+            clearTimeout(timeout);
+            worker.terminate().then(() => resolve());
+          }
+        });
+
+        worker.postMessage({ type: 'stop' });
+      });
+    },
+  };
+}

+ 107 - 0
src/ui/shimmer-worker.ts

@@ -0,0 +1,107 @@
+import { parentPort, workerData } from 'worker_threads';
+import type { ShimmerWorkerMessage } from './types';
+
+const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'];
+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}${'░'.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}█`;
+  }
+  bar += `${RST}${DM}${'░'.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] ?? '·';
+  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}│${RST}  ${color}${glyph}${RST} ${currentMessage}  ${renderBar(frame, filled, empty)}  ${currentPercent}%`;
+  } else if (currentCount > 0) {
+    line = `${DM}│${RST}  ${color}${glyph}${RST} ${currentMessage}... ${formatNumber(currentCount)} found`;
+  } else {
+    line = `${DM}│${RST}  ${color}${glyph}${RST} ${currentMessage}...`;
+  }
+
+  process.stdout.write(`\r\x1b[K${line}`);
+}
+
+function finishPhase(): void {
+  if (!currentMessage) return;
+  process.stdout.write(`\r\x1b[K`);
+  let detail = '';
+  if (currentPercent >= 0) detail = ' — done';
+  else if (currentCount > 0) detail = ` — ${formatNumber(currentCount)} found`;
+  process.stdout.write(`${DM}│${RST}  ${GRN}◆${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' });
+  }
+});

+ 9 - 0
src/ui/types.ts

@@ -0,0 +1,9 @@
+/** Messages from main thread to worker */
+export type ShimmerWorkerMessage =
+  | { type: 'update'; phase: string; phaseName: string; percent: number; count: number }
+  | { type: 'finish-phase' }
+  | { type: 'stop' };
+
+/** Messages from worker to main thread */
+export type ShimmerMainMessage =
+  | { type: 'stopped' };