mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +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>
211 lines
7.3 KiB
TypeScript
211 lines
7.3 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type React from 'react';
|
|
import { Box, Text } from 'ink';
|
|
import Gradient from 'ink-gradient';
|
|
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
|
|
import { theme } from '../semantic-colors.js';
|
|
import { shortAsciiLogo } from './AsciiArt.js';
|
|
import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js';
|
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
|
import { getRenderableGradientColors } from '../utils/gradientUtils.js';
|
|
import { pickAsciiArtTier } from '../utils/customBanner.js';
|
|
|
|
/**
|
|
* Auth display type for the Header component.
|
|
* Simplified representation of authentication method shown to users.
|
|
*/
|
|
export enum AuthDisplayType {
|
|
QWEN_OAUTH = 'Qwen OAuth',
|
|
CODING_PLAN = 'Coding Plan',
|
|
API_KEY = 'API Key',
|
|
UNKNOWN = 'Unknown',
|
|
}
|
|
|
|
interface HeaderProps {
|
|
/**
|
|
* Width-aware override for the logo column. Each tier is a sanitized
|
|
* ASCII string; the renderer picks `large` when it fits, then `small`,
|
|
* then falls through to the default Qwen logo. Either tier may be
|
|
* omitted: a missing tier simply skips that step.
|
|
*/
|
|
customAsciiArt?: { small?: string; large?: string };
|
|
/**
|
|
* Sanitized replacement for the bold ">_ Qwen Code" title in the info
|
|
* panel. The version suffix is always appended. When undefined or empty
|
|
* the default title is used; the leading `>_` glyph is part of the
|
|
* default brand and is dropped when a custom title is set.
|
|
*/
|
|
customBannerTitle?: string;
|
|
/**
|
|
* Sanitized subtitle string rendered between the title and the
|
|
* auth/model line. When undefined the existing blank spacer row is
|
|
* preserved so unset users see the same layout as before.
|
|
*/
|
|
customBannerSubtitle?: string;
|
|
version: string;
|
|
authDisplayType?: AuthDisplayType;
|
|
model: string;
|
|
workingDirectory: string;
|
|
}
|
|
|
|
export const Header: React.FC<HeaderProps> = ({
|
|
customAsciiArt,
|
|
customBannerTitle,
|
|
customBannerSubtitle,
|
|
version,
|
|
authDisplayType,
|
|
model,
|
|
workingDirectory,
|
|
}) => {
|
|
const { columns: terminalWidth } = useTerminalSize();
|
|
|
|
const formattedAuthType = authDisplayType ?? AuthDisplayType.UNKNOWN;
|
|
|
|
// Calculate available space properly:
|
|
// First determine if logo can be shown, then use remaining space for path
|
|
const containerMarginX = 2; // marginLeft + marginRight on the outer container
|
|
const logoGap = 2; // Gap between logo and info panel
|
|
const infoPanelPaddingX = 1;
|
|
const infoPanelBorderWidth = 2; // left + right border
|
|
const infoPanelChromeWidth = infoPanelBorderWidth + infoPanelPaddingX * 2;
|
|
const minPathLength = 40; // Minimum readable path length
|
|
const minInfoPanelWidth = minPathLength + infoPanelChromeWidth;
|
|
|
|
const availableTerminalWidth = Math.max(
|
|
0,
|
|
terminalWidth - containerMarginX * 2,
|
|
);
|
|
|
|
// Two distinct fallback paths:
|
|
// - User supplied a custom tier and at least one tier fits → render that.
|
|
// - User supplied custom art but neither tier fits → hide the logo column.
|
|
// Falling back to the bundled QWEN logo here would silently undo a
|
|
// white-label deployment on narrow terminals.
|
|
// - User supplied no custom art → fall through to `shortAsciiLogo` and let
|
|
// the existing width gate decide whether to show or hide it.
|
|
const hasCustomArt = Boolean(customAsciiArt?.small || customAsciiArt?.large);
|
|
const customTier = pickAsciiArtTier(
|
|
customAsciiArt?.small,
|
|
customAsciiArt?.large,
|
|
availableTerminalWidth,
|
|
logoGap,
|
|
minInfoPanelWidth,
|
|
getAsciiArtWidth,
|
|
);
|
|
const displayLogo = customTier ?? (hasCustomArt ? '' : shortAsciiLogo);
|
|
const logoWidth = getAsciiArtWidth(displayLogo);
|
|
|
|
// Check if we have enough space for logo + gap + minimum info panel.
|
|
// When `displayLogo` is empty (custom art too wide for both tiers) showLogo
|
|
// will be false, hiding the column entirely.
|
|
const showLogo =
|
|
displayLogo !== '' &&
|
|
availableTerminalWidth >= logoWidth + logoGap + minInfoPanelWidth;
|
|
|
|
// Calculate available width for info panel (use all remaining space)
|
|
// Cap at 60 when in two-column layout (with logo)
|
|
const maxInfoPanelWidth = 60;
|
|
const availableInfoPanelWidth = showLogo
|
|
? Math.min(availableTerminalWidth - logoWidth - logoGap, maxInfoPanelWidth)
|
|
: availableTerminalWidth;
|
|
|
|
// Calculate max path lengths (subtract padding/borders from available space)
|
|
const maxPathLength = Math.max(
|
|
0,
|
|
availableInfoPanelWidth - infoPanelChromeWidth,
|
|
);
|
|
|
|
const infoPanelContentWidth = Math.max(
|
|
0,
|
|
availableInfoPanelWidth - infoPanelChromeWidth,
|
|
);
|
|
const authModelText = `${formattedAuthType} | ${model}`;
|
|
const modelHintText = ' (/model to change)';
|
|
const showModelHint =
|
|
infoPanelContentWidth > 0 &&
|
|
getCachedStringWidth(authModelText + modelHintText) <=
|
|
infoPanelContentWidth;
|
|
|
|
// Now shorten the path to fit the available space
|
|
const tildeifiedPath = tildeifyPath(workingDirectory);
|
|
const shortenedPath = shortenPath(tildeifiedPath, Math.max(3, maxPathLength));
|
|
const displayPath =
|
|
maxPathLength <= 0
|
|
? ''
|
|
: shortenedPath.length > maxPathLength
|
|
? shortenedPath.slice(0, maxPathLength)
|
|
: shortenedPath;
|
|
|
|
const gradientColors = getRenderableGradientColors(theme.ui.gradient, [
|
|
theme.text.secondary,
|
|
theme.text.link,
|
|
theme.text.accent,
|
|
]);
|
|
|
|
return (
|
|
<Box
|
|
flexDirection="row"
|
|
alignItems="center"
|
|
marginX={containerMarginX}
|
|
width={availableTerminalWidth}
|
|
>
|
|
{/* Left side: ASCII logo (only if enough space) */}
|
|
{showLogo && (
|
|
<>
|
|
<Box flexShrink={0}>
|
|
{gradientColors ? (
|
|
<Gradient colors={gradientColors}>
|
|
<Text>{displayLogo}</Text>
|
|
</Gradient>
|
|
) : (
|
|
<Text>{displayLogo}</Text>
|
|
)}
|
|
</Box>
|
|
{/* Fixed gap between logo and info panel */}
|
|
<Box width={logoGap} />
|
|
</>
|
|
)}
|
|
|
|
{/* Right side: Info panel (flexible width, max 60 in two-column layout) */}
|
|
<Box
|
|
flexDirection="column"
|
|
borderStyle="single"
|
|
borderColor={theme.border.default}
|
|
paddingX={infoPanelPaddingX}
|
|
flexGrow={showLogo ? 0 : 1}
|
|
width={showLogo ? availableInfoPanelWidth : undefined}
|
|
>
|
|
{/* Title line: customBannerTitle (already sanitized) or the default
|
|
">_ Qwen Code" brand. Version suffix is always appended. */}
|
|
<Text>
|
|
<Text bold color={theme.text.accent}>
|
|
{customBannerTitle ? customBannerTitle : '>_ Qwen Code'}
|
|
</Text>
|
|
<Text color={theme.text.secondary}> (v{version})</Text>
|
|
</Text>
|
|
{/* Subtitle (when set) replaces the blank spacer row. We always
|
|
emit a row here so the auth/model line stays at the same
|
|
vertical position regardless of whether the subtitle is set. */}
|
|
{customBannerSubtitle ? (
|
|
<Text color={theme.text.secondary}>{customBannerSubtitle}</Text>
|
|
) : (
|
|
<Text> </Text>
|
|
)}
|
|
{/* Auth and Model line */}
|
|
<Text>
|
|
<Text color={theme.text.secondary}>{authModelText}</Text>
|
|
{showModelHint && (
|
|
<Text color={theme.text.secondary}>{modelHintText}</Text>
|
|
)}
|
|
</Text>
|
|
{/* Directory line */}
|
|
<Text color={theme.text.secondary}>{displayPath}</Text>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|