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
15 changes: 9 additions & 6 deletions packages/cli/src/commands/extensions/new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
*/

import { access, cp, mkdir, readdir, writeFile } from 'node:fs/promises';
import { join, dirname, basename } from 'node:path';
import { join, basename } from 'node:path';
import type { CommandModule } from 'yargs';
import { fileURLToPath } from 'node:url';
import { resolveBundleDir } from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../utils/errors.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';

Expand All @@ -16,10 +16,13 @@ interface NewArgs {
template?: string;
}

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const EXAMPLES_PATH = join(__dirname, 'examples');
// Anchor the bundled extension-examples directory at the on-disk sibling of
// `cli.js` (i.e. `dist/examples/`, populated by `prepare-package.js`). Today
// this module is bundled into `cli.js` itself, so the `chunks/` strip in
// `resolveBundleDir` is a no-op — but using the same helper as the other
// asset-anchor sites means this code stays correct if esbuild later hoists
// this module into a shared chunk.
const EXAMPLES_PATH = join(resolveBundleDir(import.meta.url), 'examples');

async function pathExists(path: string) {
try {
Expand Down
16 changes: 10 additions & 6 deletions packages/cli/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { pathToFileURL } from 'node:url';
import { writeStderrLine } from '../utils/stdioHelpers.js';
import { Storage } from '@qwen-code/qwen-code-core';
import { Storage, resolveBundleDir } from '@qwen-code/qwen-code-core';
import {
type SupportedLanguage,
SUPPORTED_LANGUAGES,
Expand Down Expand Up @@ -42,10 +42,14 @@ type TranslationLoadResult =
| { translations?: undefined; error: Error };

// Path helpers
const getBuiltinLocalesDir = (): string => {
const __filename = fileURLToPath(import.meta.url);
return path.join(path.dirname(__filename), 'locales');
};
//
// Anchor the bundled locales directory at the on-disk sibling of `cli.js`
// (i.e. `dist/locales/`, populated by `prepare-package.js`). See
// `resolveBundleDir` for the rationale behind stripping a trailing
// `chunks/` segment when this module is hoisted into a shared esbuild
// chunk.
const getBuiltinLocalesDir = (): string =>
path.join(resolveBundleDir(import.meta.url), 'locales');

const getUserLocalesDir = (): string =>
path.join(Storage.getGlobalQwenDir(), 'locales');
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
ToolNames,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { loadLowlight } from './utils/lowlightLoader.js';
import {
getStickyTodos,
getStickyTodoMaxVisibleItems,
Expand Down Expand Up @@ -406,6 +407,31 @@ export const AppContainer = (props: AppContainerProps) => {
const lastTitleRef = useRef<string | null>(null);
const staticExtraHeight = 3;

// Prefetch the lowlight chunk on mount so the dynamic import is already
// in flight before the first code block needs colorizing. Without this
// kick-off, code blocks committed to ink's append-only <Static> region
// before the import resolves stay plain text for the rest of the session
// — Static can only be re-rendered via `refreshStatic`, which is not
// wired to lowlight load completion. Common reachable paths: short
// `--prompt -p` runs that finalize quickly, Ctrl+C-cancelled first turns,
// and the first-paint history replay on `--resume`. Firing the load
// from mount keeps the startup parse-cost win (V8 still parses off the
// critical path) while restoring the "first paint sees a loaded
// instance" guarantee. Errors are silently swallowed; CodeColorizer
// already falls back to plain text on miss.
useEffect(() => {
void loadLowlight().catch((err) => {
// The loader latches its failure permanently (see `lowlightFailed` in

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] Comment references lowlightFailed which no longer exists in lowlightLoader.ts. The current implementation uses a 30s cooldown with lowlightLastFailureAt / lowlightError — the loader does NOT latch failure permanently. A maintainer debugging highlight failures may search for the wrong variable and misunderstand the recovery behavior.

Suggested change
// The loader latches its failure permanently (see `lowlightFailed` in
// The loader caches rejection with a cooldown (see
// `LOWLIGHT_RETRY_COOLDOWN_MS` / `lowlightLastFailureAt` in
// `lowlightLoader.ts`). This useEffect runs once on mount, so this
// catch fires at most once per session regardless. Log to the debug
// channel so a degraded syntax-highlight state (corrupted install,
// missing chunk) leaves a breadcrumb without spamming the user's
// TTY — `CodeColorizer` already falls back to plain text.

— 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 — applied your suggested wording verbatim. The stale lowlightFailed reference was left over from the latch-based loader before the cooldown rework landed in 3a97c45.

// `lowlightLoader.ts`), so this catch only fires once per session. Log
// to the debug channel so a degraded syntax-highlight state (corrupted
// install, missing chunk) leaves a breadcrumb without spamming the
// user's TTY — `CodeColorizer` already falls back to plain text.
debugLogger.warn(

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] debugLogger.warn() here may be a no-op at mount time if Config.initialize() hasn't completed yet — the debug logger silently drops messages when no active session exists (session is set during Config construction). If the lowlight chunk fails to load on mount, the error breadcrumb could be lost entirely.

Suggested change
debugLogger.warn(
// Fallback to stderr in case debugLogger session isn't active yet
const msg = `Failed to load lowlight chunk; code blocks will render as plain text: ${err instanceof Error ? err.message : String(err)}`;
debugLogger.warn(msg);
console.error(`[lowlight] ${msg}`);

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

`Failed to load lowlight chunk; code blocks will render as plain text: ${err instanceof Error ? err.message : String(err)}`,
);
});
}, []);

// Initialize config (runs once on mount)
useEffect(() => {
(async () => {
Expand Down
30 changes: 24 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,20 @@ 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. To avoid leaving code blocks committed to ink's append-only
// <Static> region as plain text for the rest of the session, AppContainer
// fires `loadLowlight()` from a mount effect — in steady state the import
// is already resolved by the time any colorize call lands. The fallback
// below still handles the brief window before resolution and any
// permanent-failure path (latched inside lowlightLoader).

function renderHastNode(
node: Root | Element | HastText | RootContent,
theme: Theme,
Expand Down Expand Up @@ -97,11 +105,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
72 changes: 72 additions & 0 deletions packages/cli/src/ui/utils/lowlightLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @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;
// Latches once the dynamic import has failed permanently. Without this,
// every React render of a code block would re-call `loadLowlight()` and
// re-attempt `import('lowlight')` — wasting CPU and spamming debug logs on
// every keystroke if the chunk file is permanently missing (corrupted
// install). A single permanent latch is acceptable because the colorizer
// already falls back to plain text on miss; recovery requires a fresh
// process anyway.
let lowlightFailed = false;
let lowlightError: Error | 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.
*
* Once an import attempt fails the failure is latched (see `lowlightFailed`)
* and subsequent calls return the same rejection without retrying.
*/
export function loadLowlight(): Promise<Lowlight> {
if (lowlightInstance) return Promise.resolve(lowlightInstance);
if (lowlightFailed) {
return Promise.reject(

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 lowlightFailed latch is permanent — once set, syntax highlighting is disabled for the entire process lifetime with no recovery path. Transient errors (EMFILE, disk I/O hiccup, antivirus file lock) cause irreversible degradation. Consider adding a cooldown-based retry (e.g., reset after 30 seconds) to recover from transient failures.

— 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.

Good call — replaced the permanent latch with a 30-second cooldown in 3a97c45.

Mechanics: loadLowlight() records lowlightLastFailureAt = Date.now() on rejection and returns the cached rejection synchronously for any call within LOWLIGHT_RETRY_COOLDOWN_MS (30s). After the cooldown the next call retries the dynamic import. Exposed isLowlightCoolingDown() so the render-hot caller (CodeColorizer.ensureLowlightLoading) can skip both the call and the duplicate failure log.

Tradeoff vs options (a)/(b)/(c) from the review:

  • (a) cooldown, picked: recovers from transient errors (EMFILE, antivirus locks, slow disk after wake-from-sleep). For a permanently-broken install the retry fires at most once every 30s — negligible vs the per-render hot loop the cooldown is designed to prevent. The user gets working highlights back within 30s if the underlying issue clears.
  • (b) permanent latch + debug log, the previous behaviour: simplest, but leaves syntax highlighting dead for the whole session on any one-shot error. Combined with the per-line trigger in CodeColorizer (now also fixed) this was the worst-case path.
  • (c) explicit reset: needs a UI trigger and a way to surface "highlighting is off, press X to retry" — more product surface than the failure mode warrants today.

If we ever wire up the "degraded-state stderr warning" suggested in your top-level review, that's a natural place to surface a one-shot retry too.

lowlightError ?? new Error('lowlight import previously failed'),
);
}
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.

lowlightFailed = true;
lowlightError = err instanceof Error ? err : new Error(String(err));
lowlightLoad = null;
throw err;
});
return lowlightLoad;
}
20 changes: 20 additions & 0 deletions packages/cli/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,23 @@ 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';
try {
await loadLowlight();
} catch (err) {
// Don't crash the entire test run if lowlight fails to import; snapshot
// tests that hit a code block will then render the plain-text fallback.
console.warn(
'[test-setup] Failed to prime lowlight cache, snapshot tests may ' +
'show plain-text fallback:',
String(err),
);
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ export * from './followup/index.js';
// ============================================================================

export * from './utils/browser.js';
export * from './utils/bundlePaths.js';
export * from './utils/configResolver.js';
export * from './utils/debugLogger.js';
export * from './utils/editor.js';
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/skills/skill-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as path from 'path';
import * as os from 'os';
import { fileURLToPath } from 'url';
import { watch as watchFs, type FSWatcher } from 'chokidar';
import { resolveBundleDir } from '../utils/bundlePaths.js';
import { parse as parseYaml } from '../utils/yaml-parser.js';
import * as yaml from 'yaml';
import type {
Expand Down Expand Up @@ -86,8 +86,13 @@ export class SkillManager {
private activationRegistry: SkillActivationRegistry | null = null;

constructor(private readonly config: Config) {
// Anchor the bundled skills directory at the on-disk sibling of
// `cli.js` (i.e. `dist/bundled/`, populated by `copy_bundle_assets.js`).
// See `resolveBundleDir` for the rationale behind stripping a trailing
// `chunks/` segment when this module is hoisted into a shared esbuild
// chunk.
this.bundledSkillsDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
resolveBundleDir(import.meta.url),
'bundled',
);
}
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/utils/bundlePaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2025 Qwen team
* SPDX-License-Identifier: Apache-2.0
*/

import * as path from 'node:path';
import { fileURLToPath } from 'node:url';

/**
* Resolves the on-disk directory a module should treat as a sibling of the
* bundled `cli.js` entry, given the caller's `import.meta.url`.
*
* Why this exists: `esbuild.config.js` ships with `splitting: true` and
* `chunkNames: 'chunks/[name]-[hash]'`, so modules that are hoisted into a
* shared chunk live at `dist/chunks/<chunk>.js`. Any code that derives a path
* from `import.meta.url` and joins a sibling asset (e.g. `bundled/`,
* `vendor/`, `locales/`, `examples/`) would otherwise land in
* `dist/chunks/<asset>` and miss the actual `dist/<asset>` location.
*
* The fix is intentionally narrow: only strip the trailing path segment when
* its basename is exactly `chunks`. In source / transpiled / non-split
* builds the trailing segment is the source directory's own name, never
* `chunks`, so this is a no-op there.
*
* Centralising the check keeps the coupling to `esbuild.config.js`'s
* `chunkNames` setting in one place — if that ever changes, only this helper
* needs to be updated (rather than each call site).
*
* @param importMetaUrl Pass `import.meta.url` from the caller. It must be
* evaluated at the caller's chunk so the resolution matches that chunk's
* on-disk location; centralising the `fileURLToPath`/`dirname` work here
* does not change that.
* @returns The directory that should be used as the anchor for sibling
* asset lookups (`path.join(result, 'bundled')`, etc.).
*/
export function resolveBundleDir(importMetaUrl: string): string {
const moduleDir = path.dirname(fileURLToPath(importMetaUrl));
return path.basename(moduleDir) === '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] resolveBundleDir hardcodes path.basename(moduleDir) === 'chunks', coupling it to esbuild.config.js's chunkNames setting. If chunkNames changes to e.g. 'shared/[name]-[hash]', the check silently fails and all 5 call-site asset lookups (vendor/, bundled/, locales/, examples/) resolve to the wrong directory. No compile-time or runtime check guards this coupling. Consider exporting a shared CHUNK_DIR constant.

— 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.

Done in 3a97c45: extracted BUNDLE_CHUNK_DIR = 'chunks' as a named export from packages/core/src/utils/bundlePaths.ts and updated esbuild.config.js to interpolate the same name into chunkNames (\${BUNDLE_CHUNK_DIR}/[name]-[hash]``).

I considered importing the TS constant directly into esbuild.config.js but that would force esbuild.config.js to run through tsx (currently plain node esbuild.config.js via npm run bundle), which is a bigger change than the coupling warrants. Instead both sides now declare BUNDLE_CHUNK_DIR = 'chunks' with explicit cross-reference comments — renaming on one side without the other stands out at review time, and the shared name makes the linkage greppable.

If you'd still prefer a runtime check (e.g. a build-time assertion that the two strings agree), happy to add a small scripts/check-bundle-config.js invoked from npm run bundle before esbuild runs — let me know.

? path.dirname(moduleDir)
: moduleDir;
}
10 changes: 6 additions & 4 deletions packages/core/src/utils/ripgrepUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
*/

import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { resolveBundleDir } from './bundlePaths.js';
import { fileExists } from './fileUtils.js';
import { execCommand, isCommandAvailable } from './shell-utils.js';
import { createDebugLogger } from './debugLogger.js';
Expand Down Expand Up @@ -57,9 +57,11 @@ function wslTimeout(): number {
: RIPGREP_RUN_TIMEOUT_MS;
}

// Get the directory of the current module
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolved at module load to the directory that should anchor sibling-asset

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] The local const __filename = fileURLToPath(import.meta.url) was removed here, but getBuiltinRipgrep() at line 114 still references bare __filename (__filename.includes(path.join('src', 'utils')) and __filename.endsWith('.ts')). In dev mode (tsx), __filename is not a global in ESM — this throws ReferenceError. In bundled mode, esbuild's define rewrites it to __qwen_filename (the shim chunk path), making inSrcUtils always false and levelsUp always 0 — accidentally correct but fragile.

Suggested change
// Resolved at module load to the directory that should anchor sibling-asset
const __filename = fileURLToPath(import.meta.url);
const __dirname = resolveBundleDir(import.meta.url);

— 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.

Confirmed broken — and the bug is reachable in dev mode (

@qwen-code/qwen-code@0.15.10 dev
node scripts/dev.js → tsx), not just bundled output. Repro:

$ npx tsx -e "import {getBuiltinRipgrep} from './packages/core/src/utils/ripgrepUtils.ts'; getBuiltinRipgrep()"
ReferenceError: __filename is not defined

Vitest hides the bug because Vite's transform synthesises a string __filename for the loaded module; pure node ESM (tsx, the dev runner) does not. In the production bundle esbuild's define rewrites __filename to __qwen_filename (the shim chunk path), which makes inSrcUtils accidentally false and levelsUp accidentally 0 — wenshao's read is exactly right.

Fix in 3a97c45: restored the local const __filename = fileURLToPath(import.meta.url); shadow alongside resolveBundleDir(import.meta.url). Centralising into resolveBundleDir alone can't replace the per-file shadow because the heuristic needs both the per-file path and the chunk-stripped dirname. Added a comment block explaining why both bindings stick around.

// lookups (here: the vendored ripgrep binary copied to `dist/vendor/`). See
// `resolveBundleDir` for the rationale behind stripping a trailing `chunks/`
// segment when this module is hoisted into a shared esbuild chunk.
const __dirname = resolveBundleDir(import.meta.url);

type Platform = 'darwin' | 'linux' | 'win32';
type Architecture = 'x64' | 'arm64';
Expand Down
8 changes: 7 additions & 1 deletion scripts/create-standalone-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,15 @@ const TARGETS = new Map([
['win-x64', { outputExtension: 'zip', nodeExecutable: ['node.exe'] }],
]);

const DIST_REQUIRED_PATHS = ['cli.js', 'vendor', 'bundled/qc-helper/docs'];
const DIST_REQUIRED_PATHS = [
'cli.js',
'chunks',
'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
Loading
Loading