mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-19 16:28:28 +00:00
* docs(design): add banner customization design (#3005) Document the design for issue #3005 (customize CLI banner area). Covers the banner region taxonomy and what is replaceable vs. locked, the three proposed settings (`ui.hideBanner`, `ui.customBannerTitle`, `ui.customAsciiArt`) and their resolution pipeline, the schema additions and wiring touch points, five alternative shapes considered, and the security / failure-handling guards. Mirrored EN + zh-CN under `docs/design/customize-banner-area/`. No code changes in this commit; implementation lands in a follow-up PR. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(cli): customize banner area (logo, title, hide) Adds three opt-in `ui.*` settings that let users replace brand chrome on startup while keeping the operational lines (version, auth, model, path) locked: `hideBanner`, `customBannerTitle`, `customAsciiArt` (string, {path}, or {small,large}). A new resolver in `packages/cli/src/ui/utils/customBanner.ts` walks the loaded settings, normalizes each tier per scope (so {path} resolves against the file that declared it), reads the file with O_NOFOLLOW and a 64 KB cap on POSIX, sanitizes via a banner-specific stripper that drops OSC/CSI/SS2/SS3 sequences while preserving newlines, and caps art at 200 lines × 200 cols and titles at 80 chars. Every soft failure logs a `[BANNER]` warn and falls through to the bundled QWEN logo or default brand title — banner config can never crash the CLI. `<Header />` now picks the widest custom tier that fits via a shared `pickAsciiArtTier` helper and falls back to `shortAsciiLogo` otherwise; `<AppHeader />` extends the existing `showBanner` gate to honor `hideBanner` alongside the screen-reader fallback. Tracks #3005 and the design merged in #3671. * docs(design): apply prettier to banner customization design Reformats the EN and zh-CN design docs in `docs/design/customize-banner-area/` to satisfy `npx prettier --check`: table column alignment and trailing commas in `jsonc` examples. No content changes — the words, tables, and code blocks all say the same thing as before. Carries forward the only actionable feedback from the now-closed docs-only PR #3671, where the prettier check was the sole change requested. * fix(cli): address banner audit findings Three audit-driven fixes for the banner customization feature: 1. **VSCode JSON schema accepts every documented shape.** The `ui.customAsciiArt` entry in `packages/vscode-ide-companion/schemas/settings.schema.json` was declared as `type: object`, which made VSCode flag the inline-string form (`"customAsciiArt": " ___"`) — a shape the runtime accepts and the design doc recommends — as a schema violation. Replaced with a `oneOf` covering string, `{path}`, and `{small,large}` (with each tier itself string-or-`{path}`). 2. **Narrow terminals no longer leak the QWEN logo over a white-label deployment.** When a user supplied custom ASCII art but neither tier fit the terminal, `Header.tsx` previously fell back to the bundled `shortAsciiLogo` — silently undoing the white-label intent on small windows. The fallback now distinguishes "user supplied custom art" from "no custom art at all": in the first case the logo column is hidden entirely (info panel still renders); in the second case the default logo shows as before. Soft-failure paths (missing file, sanitization rejection) still fall through to `shortAsciiLogo`. 3. **Sanitizer strips C1 control bytes (0x80-0x9F).** The art and title strippers previously stopped at 0x7F, leaving single-byte CSI (`0x9B`), DCS (`0x90`), ST (`0x9C`) and other C1 controls intact — which legacy 8-bit terminals would still interpret. Aligned the ranges with the repo's existing `stripUnsafeCharacters` (in `textUtils.ts`) so banner content can't carry interpreted control bytes through. New tests cover: C1 strip in art and title, absolute path reads, symlink rejection on POSIX, narrow-terminal hide-on-custom-art, and end-to-end `<AppHeader />` rendering through `resolveCustomBanner`. The full banner suite is 48 tests (was 42). * docs(design): clarify cross-scope tier merge and white-label fallback Two clarifications surfaced by the audit on the implementation PR: 1. The design said `customAsciiArt` follows standard merge precedence, but the resolver actually walks scopes per-tier so workspace can override only `large` while user keeps `small`. Document that this per-tier walk is intentional — both because each `{path}` has to resolve against the file that declared it (the merged view loses that information) and because it lets users keep a personal default tier and override the other one per-workspace. 2. The render-time tier-selection step now distinguishes "user supplied custom art but neither tier fits" (hide the logo column entirely; falling back to `shortAsciiLogo` would silently undo a white-label deployment on narrow terminals) from "user supplied no custom art at all" (fall through to `shortAsciiLogo` and let the default-logo width gate decide). Step 5's pure soft-failure fallback (missing file, sanitization rejection) is unchanged — still `shortAsciiLogo`. Mirrored both edits in the zh-CN translation. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(design): add size budget section to banner customization Question raised on the implementation PR: "why is the test logo `CCA` instead of the full `Custom Code Agent` — is there a character limit?" There is no character-count limit on titles or art. There is a **width budget** driven by terminal columns, plus an absolute hard cap (200×200 art, 80-char title) to keep malformed input from freezing layout. The existing user-facing guide didn't quantify the budget anywhere, so users were guessing why long inline names didn't render. Add a "How wide can the logo be? — the size budget" subsection that spells out the formula (`availableLogoWidth = terminalCols − 4 − 2 − 44`), tabulates it at 80 / 100 / 120 / 200 cols, calls out that a 17-char brand like "Custom Code Agent" can't render as a single ANSI Shadow line on most terminals (~120 cols of art), and shows the stacked-words `{ small, large }` recipe — including the `figlet` one-liner that generates the corresponding `banner-large.txt`. Mirrored in the zh-CN translation. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(design): add limits-at-a-glance table; switch demo to Custom Agent The banner-customization design now has the size budget written down, but the per-cap limits (80-char title, 200×200 art, 64 KB file) were buried inside the size-budget formula table. Surface them as their own "Limits at a glance" subsection at the top of the user-configuration guide so users see the hard caps before they start hand-crafting art. Also switch the running example from "Custom Code Agent" (17 chars, ~120 cols of ANSI Shadow art on one line — too wide for any common terminal) to "Custom Agent" (12 chars, two-word stack at ~54 cols × 12 lines, fits any terminal ≥ 104 cols). The figlet recipe is now a two-word pipeline so a copy-paste run produces art the size the doc claims. Mirrored both changes in the zh-CN translation. The implementation itself is unchanged. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address PR review + CI Lint failure Two reviewer findings on PR #3710 (and the Lint job that fails for the same root cause): 1. **Schema regen now reproduces the committed JSON Schema.** The CI Lint step runs `npm run generate:settings-schema` and fails when the worktree dirties — my earlier hand-authored `oneOf` got blown away because `customAsciiArt` is `type: 'object'` in the source schema and the generator had no way to emit a union. Add a `jsonSchemaOverride` escape-hatch field on `SettingDefinition`: when set, the generator emits the override verbatim (description carried forward) instead of the type-driven shape. Set it on `customAsciiArt` to express the runtime union (string | {path} | {small,large} where each tier is itself string-or-{path}). The committed schema is now regenerated from source and CI's regenerate-and-diff check passes; two back-to-back regens produce identical output. 2. **Untrusted workspace settings no longer influence the banner.** `collectScopedTiers()` walked `settings.workspace` directly because per-scope file paths are needed to resolve relative `{path}` entries — but that bypassed the trust gate that `settings.merged` enforces. An untrusted checkout could therefore render its own ASCII art and trigger local file reads through a `{path}` entry before the user trusts the folder. Skip `settings.workspace` entirely when `settings.isTrusted` is false. Two regression tests cover the gate (untrusted = workspace silenced, falls through to user; trusted = workspace honored). Test suite for the banner is now 30 resolver tests + the existing Header / AppHeader / settingsSchema tests = 66 total, all green. * feat(cli): add ui.customBannerSubtitle for the spacer row Adds a fourth opt-in setting to the banner customization surface. The info panel renders four rows (title, subtitle/spacer, status, path); the second row was a hard-coded single-space spacer up to now. With this change a fork or white-label deployment can set `ui.customBannerSubtitle` to a one-line subtitle (e.g. "Built-in DataWorks Official Skills") and have it render in the secondary text color in place of the spacer. Empty/unset preserves the previous blank-spacer layout, so the change is back-compat. The subtitle is sanitized through the same `sanitizeSingleLine` helper as the title (now factored out): OSC / CSI / SS2 / SS3 leaders dropped, every other C0/C1 control byte replaced with a space, internal whitespace collapsed, ends trimmed. Capped at 160 characters — looser than the title's 80 because tagline / "powered by" copy commonly runs longer — with the same `[BANNER]` warn on truncation. Wiring: - `settingsSchema.ts` — new `customBannerSubtitle` entry next to `customBannerTitle`, `showInDialog: false` (free-form text in the TUI dialog isn't worth its own picker). - `customBanner.ts` — `ResolvedBanner.subtitle` field; `resolveCustomBanner` populates it; `sanitizeTitle` and the new `sanitizeSubtitle` share the same helper. - `Header.tsx` — when `customBannerSubtitle` is truthy the spacer row renders the string (secondary color, single line) instead of `<Text> </Text>`. Auth/model and path still sit at their usual positions. - `AppHeader.tsx` — pipes `resolvedBanner.subtitle` through. - VSCode JSON schema regenerated from source (idempotent). Tests: 5 new resolver tests (default, sanitize, length cap, empty, newline + C1 strip), 2 new Header tests (renders subtitle between title and auth; spacer preserved when unset), 1 new AppHeader integration test (end-to-end through resolver). Banner suite is now 35 + 17 + 6 + 16 = 74 tests, all green. Design docs (EN + zh-CN) updated: region taxonomy now lists four B-rows; "Limits at a glance" table grows a subtitle row; "Customization rules" matrix and "How to modify" section gain a "Add a brand subtitle" example with a rendered four-row preview. * docs(design): sweep stale 3-setting references after subtitle add Self-review found several sections of the banner customization design doc still framed for the original three settings; bring them in line with the four-setting reality landed inc7aa4a401: - Region taxonomy ASCII diagram now shows four B-rows (① title, ② subtitle, ③ status, ④ path). - Resolution-pipeline ASCII diagram and step list pick up customBannerSubtitle on the input side and the title/subtitle sanitize step on the resolver side. - "Settings schema additions" section lists the fourth entry, customBannerSubtitle, and notes the customAsciiArt jsonSchemaOverride that landed for VS Code schema reproducibility. - "Wiring changes" section updates the Header prop list and the HeaderProps interface, replaces the brittle line-number anchors with file-level anchors, drops the obsolete `paths` second arg from resolveCustomBanner, and adds the trust-gate sentence. - "Security & failure handling" table replaces the stripTerminalControlSequences shorthand with the actual banner-specific stripper, splits the title/subtitle row to cover both, and adds the untrusted-workspace gate as its own row. - "Verification plan" gains two scenarios: the subtitle row, and the untrusted-workspace check that the Critical reviewer comment on the impl PR explicitly asked us to lock down. Mirrored every edit in the zh-CN translation. The implementation itself is unchanged. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address banner re-review (FIFO, mutex schema, display width, regex dedupe) Addresses the five findings on PR #3710 from the latest re-review: 1. **[Critical] FIFO/pipe at `customAsciiArt.path` no longer hangs startup.** The resolver was calling `openSync(path, O_NOFOLLOW)` *before* the `fstatSync(...).isFile()` check; on POSIX, opening a FIFO read-only blocks until a writer connects, and `O_NOFOLLOW` doesn't help — it only refuses symlinks at the final path component. `readArtFile` now `lstatSync()`s first and refuses non-regular files (FIFO / socket / device / symlink) before the open, while keeping the post-open `fstatSync` check for TOCTOU safety against a swap between the lstat and the open. New POSIX-only regression test `mkfifo`s a named pipe and asserts the resolver soft-fails inside 1 s; if the open ever regresses to blocking, the test will hang past the timeout and the assertion will catch it. 2. **[Suggestion] `{path}` and `{small,large}` are now mutually exclusive in both schema and runtime.** The `jsonSchemaOverride` on `ui.customAsciiArt` is split into three branches (string, `{path}`, `{small?, large?}`); none of them allow `path` and tier keys to co-exist. `normalizeTiers()` mirrors that — an object carrying both kinds of keys is now soft-rejected with a `[BANNER]` warn rather than letting `path` silently win and dropping the tier values. New regression test pins the runtime side. 3. **[Suggestion] Column cap and tier-fit selection now measure in terminal cells.** `getAsciiArtWidth` (in `textUtils.ts`) and the `MAX_ART_COLS` cap in `customBanner.ts` were both using UTF-16 `.length`, so 200 CJK fullwidth characters would slip the cap and render at ~400 cells, and `pickAsciiArtTier`'s width-fit check was wrong for any non-ASCII art. Switched both to `getCachedStringWidth` (string-width semantics, already in the repo); art truncation walks code points until adding another would push the cell width past the cap, so we never split a fullwidth code point or surrogate pair down the middle. New regression test exercises the CJK fullwidth case. 4. **[Suggestion] `collectScopedTiers()` no longer drops a whole scope just because it has no `file.path`.** Inline-string tiers don't need an owning settings directory; only `{path}` tiers do. The path-presence check was moved into the `{path}` branch, so a path-less scope (e.g. `systemDefaults`, future SDK-injected scopes) can still contribute inline art. `{path}` entries in such a scope soft-fail with a tier-specific `[BANNER]` warn rather than killing the whole scope. Two regression tests cover both sides. 5. **[Suggestion] OSC / CSI / SS2-3 regex are now authored once.** Extracted `TERMINAL_OSC_REGEX`, `TERMINAL_CSI_REGEX`, `TERMINAL_SHIFT_DCS_REGEX` from `stripTerminalControlSequences` in `@qwen-code/qwen-code-core` and re-export them from the package index. `customBanner.ts` reuses the constants for `sanitizeArt` (which still has to preserve `\n` / `\t`) and delegates the title/subtitle pipeline directly to `stripTerminalControlSequences`. Also backported the C1 control strip (0x80-0x9F) into the core helper so all callers (session-title, etc.) benefit from the same coverage; banner sanitizer was the only place catching single-byte CSI / DCS / ST. Banner suite is now 40 + 17 + 6 + 16 = 79 tests, all green. Schema regen is still byte-for-byte idempotent. `npm run typecheck` and prettier clean on touched files. * fix(cli): replace require() with ES6 import in FIFO test (lint) The FIFO regression test in7ccbfaeb1used a synchronous `require()` to pull in `node:child_process` so the test could lazy-load `execFileSync` only when needed. CI Lint flagged it under `no-restricted-syntax` — the repo enforces ES6 imports throughout, including in tests, with no exception for `require()`. Move the import to the top of the file alongside the other `node:` / vitest imports. The `try/catch` around `execFileSync('mkfifo', ...)` still gates the test on `mkfifo` being available (rare on a fresh container, so we skip rather than fail). 40 / 40 tests still pass and ESLint is clean on the touched file. --------- Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
220 lines
6.1 KiB
TypeScript
220 lines
6.1 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Generates a JSON Schema from the internal SETTINGS_SCHEMA definition.
|
|
*
|
|
* Usage: npx tsx scripts/generate-settings-schema.ts
|
|
*
|
|
* This reads the TypeScript settings schema and converts it to a standard
|
|
* JSON Schema file that VS Code uses for IntelliSense in settings.json files.
|
|
*
|
|
* Prerequisites: npm run build (core package must be built first)
|
|
*/
|
|
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import type {
|
|
SettingDefinition,
|
|
SettingItemDefinition,
|
|
SettingsSchema,
|
|
} from '../packages/cli/src/config/settingsSchema.js';
|
|
import { getSettingsSchema } from '../packages/cli/src/config/settingsSchema.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
interface JsonSchemaProperty {
|
|
$schema?: string;
|
|
type?: string | string[];
|
|
description?: string;
|
|
properties?: Record<string, JsonSchemaProperty>;
|
|
items?: JsonSchemaProperty;
|
|
enum?: (string | number)[];
|
|
default?: unknown;
|
|
additionalProperties?: boolean | JsonSchemaProperty;
|
|
required?: string[];
|
|
oneOf?: JsonSchemaProperty[];
|
|
anyOf?: JsonSchemaProperty[];
|
|
allOf?: JsonSchemaProperty[];
|
|
}
|
|
|
|
function convertItemDefinitionToJsonSchema(
|
|
itemDef: SettingItemDefinition,
|
|
): JsonSchemaProperty {
|
|
const schema: JsonSchemaProperty = {};
|
|
|
|
if (itemDef.description) {
|
|
schema.description = itemDef.description;
|
|
}
|
|
|
|
schema.type = itemDef.type;
|
|
|
|
if (itemDef.enum) {
|
|
schema.enum = itemDef.enum;
|
|
}
|
|
|
|
if (itemDef.type === 'object' && itemDef.properties) {
|
|
schema.properties = {};
|
|
const requiredFields: string[] = [];
|
|
|
|
for (const [key, childDef] of Object.entries(itemDef.properties)) {
|
|
const childSchema = convertItemDefinitionToJsonSchema(childDef);
|
|
schema.properties[key] = childSchema;
|
|
if (childDef.required) {
|
|
requiredFields.push(key);
|
|
}
|
|
}
|
|
|
|
if (requiredFields.length > 0) {
|
|
schema.required = requiredFields;
|
|
}
|
|
}
|
|
|
|
if (itemDef.type === 'object' && itemDef.additionalProperties !== undefined) {
|
|
if (typeof itemDef.additionalProperties === 'boolean') {
|
|
schema.additionalProperties = itemDef.additionalProperties;
|
|
} else {
|
|
schema.additionalProperties = convertItemDefinitionToJsonSchema(
|
|
itemDef.additionalProperties,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (itemDef.items) {
|
|
schema.type = 'array';
|
|
schema.items = convertItemDefinitionToJsonSchema(itemDef.items);
|
|
}
|
|
|
|
return schema;
|
|
}
|
|
|
|
function convertSettingToJsonSchema(
|
|
setting: SettingDefinition,
|
|
): JsonSchemaProperty {
|
|
// Escape hatch: a SettingDefinition can supply a verbatim JSON Schema
|
|
// fragment for cases the `type` field cannot express (most commonly
|
|
// unions). The description is carried forward from the SettingDefinition
|
|
// so we don't have to restate it in the override.
|
|
if (setting.jsonSchemaOverride) {
|
|
const override = { ...setting.jsonSchemaOverride } as JsonSchemaProperty;
|
|
if (setting.description && override.description === undefined) {
|
|
override.description = setting.description;
|
|
}
|
|
return override;
|
|
}
|
|
|
|
const schema: JsonSchemaProperty = {};
|
|
|
|
if (setting.description) {
|
|
schema.description = setting.description;
|
|
}
|
|
|
|
switch (setting.type) {
|
|
case 'boolean':
|
|
schema.type = 'boolean';
|
|
break;
|
|
case 'string':
|
|
schema.type = 'string';
|
|
break;
|
|
case 'number':
|
|
schema.type = 'number';
|
|
break;
|
|
case 'array':
|
|
schema.type = 'array';
|
|
if (setting.items) {
|
|
schema.items = convertItemDefinitionToJsonSchema(setting.items);
|
|
} else {
|
|
schema.items = { type: 'string' };
|
|
}
|
|
break;
|
|
case 'enum':
|
|
if (setting.options && setting.options.length > 0) {
|
|
schema.enum = setting.options.map((o) => o.value);
|
|
const optionsText =
|
|
'Options: ' + setting.options.map((o) => `${o.value}`).join(', ');
|
|
schema.description = schema.description
|
|
? `${schema.description} ${optionsText}`
|
|
: optionsText;
|
|
} else {
|
|
// Enum without predefined options - accept any string
|
|
schema.type = 'string';
|
|
}
|
|
break;
|
|
case 'object':
|
|
schema.type = 'object';
|
|
if (setting.properties) {
|
|
schema.properties = {};
|
|
for (const [key, childDef] of Object.entries(setting.properties)) {
|
|
schema.properties[key] = convertSettingToJsonSchema(
|
|
childDef as SettingDefinition,
|
|
);
|
|
}
|
|
} else {
|
|
schema.additionalProperties = true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Add default value for simple types only
|
|
if (setting.default !== undefined && setting.default !== null) {
|
|
const defaultVal = setting.default;
|
|
if (
|
|
typeof defaultVal === 'boolean' ||
|
|
typeof defaultVal === 'number' ||
|
|
typeof defaultVal === 'string'
|
|
) {
|
|
schema.default = defaultVal;
|
|
} else if (Array.isArray(defaultVal) && defaultVal.length > 0) {
|
|
schema.default = defaultVal;
|
|
}
|
|
}
|
|
|
|
return schema;
|
|
}
|
|
|
|
function generateJsonSchema(
|
|
settingsSchema: SettingsSchema,
|
|
): JsonSchemaProperty {
|
|
const jsonSchema: JsonSchemaProperty = {
|
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
type: 'object',
|
|
description: 'Qwen Code settings configuration',
|
|
properties: {},
|
|
additionalProperties: true,
|
|
};
|
|
|
|
for (const [key, setting] of Object.entries(settingsSchema)) {
|
|
jsonSchema.properties![key] = convertSettingToJsonSchema(
|
|
setting as SettingDefinition,
|
|
);
|
|
}
|
|
|
|
// Add $version property
|
|
jsonSchema.properties!['$version'] = {
|
|
type: 'number',
|
|
description: 'Settings schema version for migration tracking.',
|
|
default: 3,
|
|
};
|
|
|
|
return jsonSchema;
|
|
}
|
|
|
|
const schema = getSettingsSchema();
|
|
const jsonSchema = generateJsonSchema(schema as unknown as SettingsSchema);
|
|
|
|
const outputDir = path.resolve(
|
|
__dirname,
|
|
'../packages/vscode-ide-companion/schemas',
|
|
);
|
|
const outputPath = path.join(outputDir, 'settings.schema.json');
|
|
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(outputPath, JSON.stringify(jsonSchema, null, 2) + '\n');
|
|
|
|
console.log(`Generated settings JSON Schema at: ${outputPath}`);
|