Skip to content
12 changes: 10 additions & 2 deletions esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,12 @@ const external = [

esbuild
.build({
entryPoints: ['packages/cli/index.ts'],
entryPoints: { cli: 'packages/cli/index.ts' },
bundle: true,
outfile: 'dist/cli.js',
outdir: 'dist',
entryNames: '[name]',
chunkNames: 'chunks/[name]-[hash]',
splitting: true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Enabling splitting: true silently breaks three internal modules that derive runtime paths from import.meta.url. Each one assumed it would be co-located with cli.js in dist/; once esbuild hoists them into dist/chunks/chunk-*.js, path.dirname(fileURLToPath(import.meta.url)) resolves to dist/chunks/, but the assets they look up still live one level above. Verified empirically against the bundle this PR produces:

  1. packages/core/src/skills/skill-manager.ts:88-92 — bundledSkillsDir ends up as dist/chunks/bundled. The actual skills directory is dist/bundled/{batch,loop,qc-helper,review} (created by copy_bundle_assets.js). The existsSync guard at skill-manager.ts:896 swallows the miss into a debugLogger.warn, so all four bundled skills (/review, /qc-helper, /batch, /loop) silently disappear from any package built from this branch — no startup error, no user-visible signal.
  2. packages/core/src/utils/ripgrepUtils.ts:111-122 — getBuiltinRipgrep() computes dist/chunks/vendor/ripgrep/<arch>-<plat>/rg, which doesn't exist. The bundled binary at dist/vendor/ripgrep/... becomes unreachable. resolveRipgrep then falls back to system rg if installed, or returns null on minimal hosts (CI runners, fresh containers) — making grep fall back to a slow internal scanner on the very environments the vendored binary was shipped for.
  3. packages/cli/src/i18n/index.ts:45-48 — same pattern; getBuiltinLocalesDir() returns dist/chunks/locales. User-visible behavior survives because tryImportBundledTranslations (i18n/index.ts:114) loads via static glob import, but the loose-on-disk override path is now dead code.

Existing unit tests don't catch any of this — they all run in source mode where import.meta.url resolves to the real source file and the heuristics work. Suggested fixes (any one is enough):

  • Mark the three modules as part of the entry chunk (e.g. eagerly import them from packages/cli/index.ts so esbuild keeps them in cli.js). Smallest change, preserves the lowlight win.
  • Pass the package root in from cli.js (whose import.meta.url is dist/cli.js) and use that as the anchor in each module instead of import.meta.url. Most robust.
  • Add a runtime fallback in each function that walks .. until the expected sibling (bundled/, vendor/ripgrep/, locales/) is found.

At minimum, please add a smoke test that runs the built dist/cli.js and asserts each of the three is reachable, otherwise the next code-splitting change will hit the same trap.

— claude-opus-4-7[1m] via Qwen Code /qreview

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the bug empirically against the bundle this PR produces and fixed in 8d1da04d. All three modules now strip a trailing chunks segment when path.basename(moduleDir) === 'chunks', so the sibling-asset lookups resolve under dist/ rather than dist/chunks/. In source / transpiled modes the basename is never chunks, so the fallback is a no-op.

Sanity check after rebuild:

bundledSkillsDir resolves to: dist/bundled
exists: true
subdirs: [ 'batch', 'loop', 'qc-helper', 'review' ]

Also added chunks to DIST_REQUIRED_PATHS in create-standalone-package.js so a regressed bundle without chunks fails the packager check (separate from the [Suggestion] thread on line 42).

Re: smoke test for the built dist/cli.js — agree this is the right long-term gate. Not adding it in this round to keep the diff focused on the three concrete misses; will file a follow-up so the next code-splitting change has CI cover.

platform: 'node',
format: 'esm',
target: 'node22',
Expand All @@ -103,6 +106,11 @@ esbuild
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
// Make global available for compatibility
global: 'globalThis',
// Redirect free __dirname/__filename references to the shim so that

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] define: { __dirname: '__qwen_dirname', __filename: '__qwen_filename' } silently rewrites ALL bare __dirname/__filename references in bundled source files to the shim chunk path — not the source file's directory. Any new code adding path.join(__dirname, 'some-asset') gets a wrong path at runtime with no warning. Consider adding an ESLint rule banning bare __dirname/__filename in packages/*/src/.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a contributor-facing warning to the define block in 3a97c45 — it spells out that bare __dirname / __filename resolves to the shim chunk path (dist/chunks/), shows the fileURLToPath(import.meta.url) shadow pattern, and points future contributors at resolveBundleDir(import.meta.url) for sibling-asset lookups in modules that may end up hoisted into a shared chunk.

Left the ESLint rule out for now — adding a custom rule (no plugin handles this exact pattern, since the rule has to be "bare reference not shadowed by a local same-name binding") felt heavier than the bug count justifies. If we hit this trap a third time I'd happily land a small custom rule under packages/cli/eslint-plugin-internal/. The new comment also calls out the canonical safe pattern so reviewers have a concrete reference when this comes up.

// vendored libraries that emit their own `var __dirname` locals don't
// collide with our injected bindings when code-splitting is enabled.
__dirname: '__qwen_dirname',
__filename: '__qwen_filename',
},
loader: { '.node': 'file' },
plugins: [wasmBinaryPlugin, wasmLoader({ mode: 'embedded' })],
Expand Down
27 changes: 21 additions & 6 deletions packages/cli/src/ui/utils/CodeColorizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import React from 'react';
import { Text, Box } from 'ink';
import { common, createLowlight } from 'lowlight';
import type {
Root,
Element,
Expand All @@ -22,11 +21,17 @@ import {
} from '../components/shared/MaxSizedBox.js';
import type { LoadedSettings } from '../../config/settings.js';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { getLowlightInstance, loadLowlight } from './lowlightLoader.js';

// Configure theming and parsing utilities.
const lowlight = createLowlight(common);
const debugLogger = createDebugLogger('CODE_COLORIZER');

// Lowlight is heavy (~1.5 MB bundled, ~36–60 ms V8 parse). It's loaded lazily
// from `./lowlightLoader.js` via dynamic import so it lives in a separate
// esbuild chunk that's only parsed once a code block actually needs
// highlighting. Callers see plain text for the very first render and the
// highlighted version once React next re-renders the surrounding subtree
// (typically on the next user keystroke or message).

function renderHastNode(
node: Root | Element | HastText | RootContent,
theme: Theme,
Expand Down Expand Up @@ -97,11 +102,21 @@ function highlightAndRenderLine(
language: string | null,
theme: Theme,
): React.ReactNode {
// Trigger the lazy load on first use; until it resolves, fall back to a
// plain-text rendering of the line. The next React render of the
// surrounding subtree will pick up the highlighted version.
const ll = getLowlightInstance();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] On failure, highlightAndRenderLine calls void loadLowlight().catch(...) on EVERY code line of EVERY code block. After lowlightFailed latches, each call returns Promise.reject() immediately — the .catch() handler fires for every line, creating hundreds/thousands of duplicate debug log entries. The load trigger and error logging should happen once per code block, not per line.

Suggested change
const ll = getLowlightInstance();
// In colorizeCode, before lines.map():
const ll = getLowlightInstance();
if (!ll) {
void loadLowlight().catch((err) => {
debugLogger.error('[CodeColorizer] failed to load lowlight:', err);
});
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3a97c45 with the exact pattern you suggested: extracted ensureLowlightLoading() and hoisted the call into colorizeCode (once per block) instead of highlightAndRenderLine (once per line). highlightAndRenderLine now takes the resolved Lowlight | null as a parameter, so it's pure lookup with no side effect on the per-line path.

Side benefit: colorizeLine (single-line code, used by inline backticks) goes through the same ensureLowlightLoading() helper, so its trigger path also gets the cooldown short-circuit from #3234507014 — duplicate logs there were less severe but also gone.

if (!ll) {
void loadLowlight().catch((err) => {
debugLogger.error('[CodeColorizer] failed to load lowlight:', err);
});
return line;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The header comment promises that the highlighted version arrives "once React next re-renders the surrounding subtree (typically on the next user keystroke or message)." That holds for items still in the pending area, but not for items committed to ink's <Static> — packages/cli/src/ui/components/MainContent.tsx:205-211 explicitly notes Static is append-only: once an item is rendered to the terminal buffer it cannot be replaced, except via refreshStatic() (only triggered by theme switch / compact-mode merge / Ctrl+O — never on lowlight load).

So a code block that lands in <Static> before the dynamic import resolves stays plain text for the rest of the session. In typical streaming use the import wins easily, but it bites for: short --prompt -p runs that finalize quickly, Ctrl+C-cancelled first turns, and --resumed sessions whose first-paint history slice rides through the progressive Static replay path. The fact that test-setup.ts has to prime the loader synchronously to keep snapshots stable is itself evidence this race is reachable in production.

The simplest fix is to fire loadLowlight() from app boot (a non-awaited dispatch from AppContainer's mount effect), so the import is already in flight long before the first colorize call. The startup-cost win is preserved — V8 still parses the chunk off the critical path — but the "first paint sees a loaded instance" guarantee is restored. A heavier alternative is to broadcast a load-completion signal via context so memo'd subtrees can opt into re-render, but that doesn't help anything already absorbed into Static.

— claude-opus-4-7[1m] via Qwen Code /qreview

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the <Static> race. Fixed in 872d971 by firing loadLowlight() from an AppContainer mount effect, exactly as suggested — the dynamic import is already in flight long before any colorize call lands, so the steady-state "first paint sees a loaded instance" guarantee is restored while the startup parse-cost win is preserved (V8 still parses off the critical path).

Also updated the CodeColorizer.tsx header comment to point at the AppContainer prefetch instead of claiming the next re-render fixes it.

}
try {
const getHighlightedLine = () =>
!language || !lowlight.registered(language)
? lowlight.highlightAuto(line)
: lowlight.highlight(language, line);
!language || !ll.registered(language)
? ll.highlightAuto(line)
: ll.highlight(language, line);

const renderedNode = renderHastNode(getHighlightedLine(), theme, undefined);

Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/ui/utils/lowlightLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Standalone loader for the lowlight syntax-highlight engine.
*
* Kept in its own module — with zero imports beyond `lowlight` itself — so
* that priming the cache from `test-setup.ts` does not transitively pull
* `themeManager`, settings, or `@qwen-code/qwen-code-core` into every test
* file's module graph. That cascade was observed to alter theme/config test
* outcomes (e.g. theme-manager auto-detection and QWEN_HOME env tests).
*/

import type { Root } from 'hast';

export type Lowlight = {
registered(language: string): boolean;
highlight(language: string, value: string): Root;
highlightAuto(value: string): Root;
};

let lowlightInstance: Lowlight | null = null;
let lowlightLoad: Promise<Lowlight> | null = null;

export function getLowlightInstance(): Lowlight | null {
return lowlightInstance;
}

/**
* Kicks off (or returns the in-flight) load of the lowlight chunk. Exported
* for two callers:
* 1. `CodeColorizer.tsx` — fires the load on first colorize call so the
* next React commit picks up the highlighted output.
* 2. `test-setup.ts` — awaits this once to keep snapshot tests
* deterministic without dragging more modules into the test graph.
*/
export function loadLowlight(): Promise<Lowlight> {
if (lowlightInstance) return Promise.resolve(lowlightInstance);
if (lowlightLoad) return lowlightLoad;
lowlightLoad = import('lowlight')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] mod.createLowlight(mod.common) as Lowlight has no runtime API shape validation. If lowlight changes its API (e.g., method renaming), the as cast silently coerces the mismatched object. The resulting TypeError in highlightAndRenderLine's try/catch is swallowed, causing all code blocks to silently render plain text with zero logs — no debug warning, no cooldown trigger.

Suggested change
lowlightLoad = import('lowlight')
const instance = mod.createLowlight(mod.common);
if (typeof instance?.registered !== 'function' ||
typeof instance?.highlight !== 'function' ||
typeof instance?.highlightAuto !== 'function') {
throw new Error('lowlight instance does not match expected API');
}
lowlightInstance = instance as Lowlight;

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d5acc21 — added the three-method shape check before the cast. A mismatched object now throws a descriptive Error which routes through the existing .catch (sets lowlightLastFailureAt/lowlightError) so the cooldown latch engages and the degraded state surfaces in the debug channel exactly once. Extra cost is one typeof triple per session (load runs once).

.then((mod) => {
lowlightInstance = mod.createLowlight(mod.common) as Lowlight;
return lowlightInstance;
})
.catch((err) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The .catch handler resets lowlightLoad = null on import failure, which means every subsequent React render will retry import('lowlight') unboundedly. If the chunk file is permanently missing (corrupted install), this loops forever — wasting CPU and spamming debug logs on every keystroke.

Consider adding a permanent-failure sentinel so once import('lowlight') fails, subsequent calls short-circuit immediately instead of retrying:

Suggested change
.catch((err) => {
.catch((err) => {
lowlightLoad = null;
lowlightFailed = true;
throw err;
});

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 872d971. Added a lowlightFailed sentinel that latches on the first import failure, so subsequent loadLowlight() calls short-circuit to a rejected promise without re-attempting import('lowlight'). The colorizer already falls back to plain text on miss; recovery requires a fresh process anyway.

The lowlightLoad = null reset on the failure path is intentionally kept — it pairs with the Promise.reject(lowlightError) short-circuit so the latched error replays without holding the unresolved promise.

lowlightLoad = null;
throw err;
});
return lowlightLoad;
}
10 changes: 10 additions & 0 deletions packages/cli/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,13 @@ if (process.env['QWEN_DEBUG_LOG_FILE'] === undefined) {
}

import './src/test-utils/customMatchers.js';

// Lowlight is loaded asynchronously in production to keep it out of the
// startup-critical bundle chunk. Snapshot tests render synchronously via
// `lastFrame()` and would otherwise capture the plain-text fallback before
// the dynamic import resolves. Prime the cache once here so every test sees
// the fully-highlighted output. The loader is intentionally a tiny standalone
// module (no transitive imports of themeManager / settings / core) so this
// prime does not perturb any other test's module graph.
import { loadLowlight } from './src/ui/utils/lowlightLoader.js';
await loadLowlight();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The top-level await loadLowlight() has no error handling. If lowlight is corrupted or the import fails (e.g. transient CI issue), vitest crashes during initialization — killing all CLI tests, including those unrelated to syntax highlighting.

Wrap in try/catch to degrade gracefully:

Suggested change
await loadLowlight();
import { loadLowlight } from './src/ui/utils/lowlightLoader.js';
try {
await loadLowlight();
} catch (err) {
console.warn(
'[test-setup] Failed to prime lowlight cache, ' +
'snapshot tests may show plain-text fallback:',
String(err),
);
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 872d971. The top-level await loadLowlight() is now wrapped in try/catch — a transient import failure logs a warning and falls through, so vitest no longer crashes during init and unrelated tests still run. Snapshot tests that hit a code block will render the plain-text fallback.

1 change: 1 addition & 0 deletions scripts/create-standalone-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const TARGETS = new Map([
const DIST_REQUIRED_PATHS = ['cli.js', 'vendor', 'bundled/qc-helper/docs'];
const DIST_ALLOWED_ENTRIES = new Set([
'cli.js',
'chunks',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] chunks is in DIST_ALLOWED_ENTRIES but missing from DIST_REQUIRED_PATHS on line 39. With splitting: true enabled, the chunks/ directory is functionally required — without it the bundled cli.js will throw ERR_MODULE_NOT_FOUND for every static import "./chunks/..." reference at startup, plus ERR_MODULE_NOT_FOUND mid-session for any lazy await import(). As the standalone packager stands today, a regressed bundle that produces only cli.js (e.g. someone reverts splitting: true only in esbuild.config.js, or a partial build) will be packaged into a broken archive without any error from this script — the failure first surfaces when an end user runs qwen from the unpacked tarball.

Suggested change
'chunks',
const DIST_REQUIRED_PATHS = ['cli.js', 'chunks', 'vendor', 'bundled/qc-helper/docs'];
const DIST_ALLOWED_ENTRIES = new Set([
'cli.js',
'chunks',

— claude-opus-4-7[1m] via Qwen Code /qreview

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d581da0. chunks is now in DIST_REQUIRED_PATHS alongside cli.js, vendor, and bundled/qc-helper/docs, so a regressed bundle without the chunks directory fails the standalone packager check up front instead of producing a broken tarball.

Verified on a fresh npm run bundle — dist/chunks/ is populated (lowlight-FYAAUU5J.js, chunk-CYG2IFDL.js, chunk-UWSICIN3.js, et al.) and the packager check passes against it.

'vendor',
'bundled',
'package.json',
Expand Down
20 changes: 11 additions & 9 deletions scripts/esbuild-shims.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,27 @@
*/

/**
* Shims for esbuild ESM bundles to support require() calls
* This file is injected into the bundle via esbuild's inject option
* Shims for esbuild ESM bundles.
*
* With code-splitting enabled, the inject is applied per-chunk and the
* exported bindings cannot collide with `var __dirname` polyfills that
* vendored libraries (e.g. yargs) emit in their own ESM compat layers.
* To stay collision-free, this file exposes prefixed names; the build
* config uses esbuild `define` to rewrite free `__dirname` / `__filename`
* references in source to these prefixed identifiers, while leaving
* vendor-declared locals untouched.
*/

import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

// Create require function for the current module and make it global
const _require = createRequire(import.meta.url);

// Make require available globally for dynamic requires
if (typeof globalThis.require === 'undefined') {
globalThis.require = _require;
}

// Export for esbuild injection
export const require = _require;

// Setup __filename and __dirname for compatibility
export const __filename = fileURLToPath(import.meta.url);
export const __dirname = dirname(__filename);
export const __qwen_filename = fileURLToPath(import.meta.url);
export const __qwen_dirname = dirname(__qwen_filename);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The header comment explains why the rename was needed (vendor var __dirname collisions), but doesn't warn future contributors about a real semantic change for first-party code: __qwen_dirname is computed once from this shim's import.meta.url, which now lives in dist/chunks/chunk-A4BMJM77.js. So any free __dirname/__filename reference written in our own source code will resolve to dist/chunks/, not the directory of the source file or even dist/. This is the same root cause as the skill-manager / ripgrepUtils / i18n bugs flagged on esbuild.config.js:82.

Existing first-party files survive only because they happen to declare a local shadow (const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename);) which esbuild's define correctly leaves alone. The next contributor who writes path.join(__dirname, '../docs') directly will silently get dist/chunks/../docs at runtime — works in npm run dev (where ts-node provides a real __dirname), fails or points to the wrong place in production.

Please add a contributor-facing note here (and adjacent to the define block in esbuild.config.js) along the lines of:

Suggested change
export const __qwen_dirname = dirname(__qwen_filename);
export const require = _require;
// IMPORTANT: __qwen_filename / __qwen_dirname always resolve to this shim's
// chunk file — i.e. dist/chunks/ in a built bundle, NOT the directory of any
// source file that uses bare __dirname / __filename. esbuild's `define`
// rewrites all free references in source code to these symbols, so to get a
// per-file path you MUST declare a local shadow at the top of your module:
// const __filename = fileURLToPath(import.meta.url);
// const __dirname = path.dirname(__filename);
export const __qwen_filename = fileURLToPath(import.meta.url);
export const __qwen_dirname = dirname(__qwen_filename);

— claude-opus-4-7[1m] via Qwen Code /qreview

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 872d971. Added a contributor-facing block comment in esbuild-shims.js next to the __qwen_filename / __qwen_dirname exports, explaining that:

  1. These symbols always resolve to the shim's chunk file (dist/chunks/), not the directory of the source file using them.
  2. To get a per-file path you must declare a local shadow with fileURLToPath(import.meta.url) at the top of the module.
  3. Even with a local shadow, under code-splitting the path can still point to dist/chunks/, so sibling-asset lookups (vendor/, bundled/, locales/) must strip a trailing chunks segment — see skill-manager.ts / ripgrepUtils.ts / i18n/index.ts for the pattern.

Cross-referenced from the define block isn't added — kept the note in one place at the symbol definition site so it's the first thing a contributor reading either symbol's resolution sees.

1 change: 1 addition & 0 deletions scripts/prepare-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ const distPackageJson = {
},
files: [
'cli.js',
'chunks',
'vendor',
'*.sb',
'README.md',
Expand Down
Loading