From 4f084352f41123e219d0dd32d65e83da4145938e Mon Sep 17 00:00:00 2001 From: ChiGao Date: Thu, 7 May 2026 10:17:53 +0800 Subject: [PATCH] feat(cli): customize banner area (logo, title, hide) (#3710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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. `
` now picks the widest custom tier that fits via a shared `pickAsciiArtTier` helper and falls back to `shortAsciiLogo` otherwise; `` 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 `` 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 * 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 * 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 * 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 ` `. 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 in c7aa4a401: - 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 * 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 in 7ccbfaeb1 used 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: 秦奇 Co-authored-by: Qwen-Coder --- .../customize-banner-area.md | 776 ++++++++++++++++++ .../customize-banner-area.zh-CN.md | 720 ++++++++++++++++ packages/cli/src/config/settingsSchema.ts | 118 +++ .../cli/src/ui/components/AppHeader.test.tsx | 73 +- packages/cli/src/ui/components/AppHeader.tsx | 16 +- .../cli/src/ui/components/Header.test.tsx | 87 ++ packages/cli/src/ui/components/Header.tsx | 65 +- .../cli/src/ui/utils/customBanner.test.ts | 583 +++++++++++++ packages/cli/src/ui/utils/customBanner.ts | 470 +++++++++++ packages/cli/src/ui/utils/textUtils.ts | 10 +- packages/core/src/index.ts | 7 +- packages/core/src/utils/terminalSafe.ts | 40 +- .../schemas/settings.schema.json | 79 ++ scripts/generate-settings-schema.ts | 15 + 14 files changed, 3024 insertions(+), 35 deletions(-) create mode 100644 docs/design/customize-banner-area/customize-banner-area.md create mode 100644 docs/design/customize-banner-area/customize-banner-area.zh-CN.md create mode 100644 packages/cli/src/ui/utils/customBanner.test.ts create mode 100644 packages/cli/src/ui/utils/customBanner.ts diff --git a/docs/design/customize-banner-area/customize-banner-area.md b/docs/design/customize-banner-area/customize-banner-area.md new file mode 100644 index 0000000000..ee4d1417c7 --- /dev/null +++ b/docs/design/customize-banner-area/customize-banner-area.md @@ -0,0 +1,776 @@ +# Customize Banner Area Design + +> Allow users to replace the QWEN ASCII art, replace the brand title, and +> hide the banner entirely — without letting them suppress the operational +> data (version, auth, model, working directory) that makes Qwen Code +> debuggable and trustworthy. + +## Overview + +The Qwen Code CLI prints a banner at startup containing a QWEN ASCII logo +and a bordered info panel. Several real-world use cases want some control +over this surface: + +- **White-label / third-party brand integration**: enterprises and teams + embedding Qwen Code into their own products want to display their brand + identity rather than the default "Qwen Code". +- **Personalization**: individuals want to match the terminal banner to a + team standard or their own taste. +- **Multi-tenant / multi-instance distinction**: in shared environments, + different teams want a quick visual signal of which instance they are + in. + +The design stance is simple: **brand chrome is replaceable; operational +data is not**. Customization should let users put their own branding on +top, not let them silence the information that makes a session +debuggable. That stance drives every "what can change vs. what is locked" +decision in the rest of this document. + +This is tracked by [issue #3005](https://github.com/QwenLM/qwen-code/issues/3005). + +## Banner region taxonomy + +Today the banner is rendered by `Header` (mounted from `AppHeader`) and +breaks into the following regions: + +``` + marginX=2 marginX=2 + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ┌──── Logo Column ─────┐ gap=2 ┌──── Info Panel (bordered) ──────────┐ │ +│ │ │ │ │ │ +│ │ ███ QWEN ASCII ███ │ │ ① Title: >_ Qwen Code (vX.Y.Z) │ │ +│ │ ███ ART ART ███ │ │ ② Subtitle: «blank, or override» │ │ +│ │ ███ QWEN ASCII ███ │ │ ③ Status: Qwen OAuth | qwen-… │ │ +│ │ │ │ ④ Path: ~/projects/example │ │ +│ └──────── A ───────────┘ └──────────────── B ──────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + region: AppHeader + │ Tips component renders below (governed by ui.hideTips) │ +``` + +The two top-level boxes are: + +- **A. Logo column** — a single ASCII art block with a gradient. Sourced + today from `shortAsciiLogo` in + `packages/cli/src/ui/components/AsciiArt.ts`. +- **B. Info panel** — a bordered box containing four rows. The second + row is a blank visual spacer by default, optionally swapped for a + caller-supplied subtitle: + - **B①** Title: `>_ Qwen Code (vX.Y.Z)` — brand text + version suffix. + - **B②** Subtitle / spacer: blank single-space row by default. When + `ui.customBannerSubtitle` is set, that string takes this row (e.g. + a fork might use `Built-in DataWorks Official Skills`). + - **B③** Status: ` | ( /model to change)`. + - **B④** Path: a tildeified, shortened working directory. + +The whole thing is wrapped by ``, which already gates the +banner on `showBanner = !config.getScreenReader()` (screen-reader mode +falls back to plain output). + +## Customization rules — what can change, what is locked + +| Region | Today's source | Customization category | Rationale | +| ------------------------------------------- | ----------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **A. Logo column** | `shortAsciiLogo` (`AsciiArt.ts`) | **Replaceable + auto-hideable** | Pure brand surface. White-label needs full control over the visual. The existing "auto-hide on narrow terminals" fallback is preserved. | +| **B①. Title — brand text** (`>_ Qwen Code`) | Hard-coded in `Header.tsx` | **Replaceable** | Brand surface. The leading `>_` glyph is part of the existing brand; if a user wants it gone, they simply omit it from `customBannerTitle`. | +| **B①. Title — version suffix** (`(vX.Y.Z)`) | `version` prop | **Locked** | Critical for bug reports. Hiding it makes "what version are you on?" answerable only via `--version`, which is a real cost in support workflows. We trade a small white-label loss for support tractability. | +| **B②. Subtitle / spacer row** | blank by default | **Replaceable** | Pure brand / context surface. Used by white-label forks to label the build (e.g. "Built-in DataWorks Official Skills"). Sanitized like the title; one line only — no layout-breaking newlines. | +| **B③. Status line** (auth + model) | `formattedAuthType`, `model` props | **Locked** | Operational and security signal. Users must always see which credential is in use and which model will spend their tokens. Suppressing it is a footgun even for white-label scenarios. | +| **B④. Path line** (working directory) | `workingDirectory` prop | **Locked** | Operational. "Which directory am I in?" is a constant question; the banner is its canonical answer. | +| **Whole banner** (A + B) | `
` mount in `AppHeader.tsx` | **Hideable** | A single `ui.hideBanner: true` skips both regions — same shape as the existing screen-reader gate. `` continues to be governed independently by `ui.hideTips`. | + +The matrix translates to four settings, no more: + +| Setting | Default | Effect | Region affected | +| ------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------- | +| `ui.hideBanner` | `false` | Hides the entire banner (regions A + B). | A + B | +| `ui.customBannerTitle` | unset | Replaces the brand text in B①. The version suffix is still appended. Trimmed; an empty string means "use default". | B① brand text | +| `ui.customBannerSubtitle` | unset | Replaces the blank spacer row B② with a one-line subtitle. Sanitized; capped at 160 characters; empty means "keep the blank spacer". | B② spacer | +| `ui.customAsciiArt` | unset | Replaces region A. Three accepted shapes (see below). Falls back to default on any error. | A | + +What is **not** offered, by design: + +- No setting hides only the version suffix. +- No setting hides only the auth/model line. +- No setting hides only the path line. +- No setting changes the gradient colors of the logo (theme owns that). +- No setting reorders or restructures the info panel. + +If the implementation later needs to expose any of those, they should be +new fields with their own justification — not derived from the three +fields above. + +## User configuration guide — how to modify + +### Limits at a glance + +A handful of caps apply to every banner customization. Keep them in mind +before hand-crafting art so the resolver doesn't truncate or reject +your input. + +| What | Limit | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Title character count** | **80 characters max** (post-sanitize). Anything longer is truncated and a `[BANNER]` warn is logged. Newlines and control chars are stripped before this length is counted. | +| **Subtitle character count** | **160 characters max** (post-sanitize). Same cleanup pipeline as the title; same `[BANNER]` warn on truncation. | +| **ASCII art block size** | **200 lines × 200 columns max** per tier. Anything larger is truncated to fit and a `[BANNER]` warn is logged. | +| **ASCII art file size on disk** | **64 KB max**. Larger files are read up to the cap; the rest is ignored. | +| **ASCII art width that renders** | Driven by terminal columns at startup, **not** a fixed character count. See "How wide can the logo be?" below for the formula and per-terminal numbers. | + +There is **no fixed character-count limit on the ASCII art** — only the +column / line caps above and the per-startup width budget. A 17-character +brand name that would render comfortably in one font may need stacking or +a denser font in another; the limiting factor is visual width, not letters. + +### Where settings live + +All four settings live under `ui` in `settings.json`. Both user-level +(`~/.qwen/settings.json`) and workspace-level (`.qwen/settings.json` in +the project root) are supported with the standard merge precedence +(workspace overrides user, system overrides workspace). + +`customAsciiArt` is special-cased: rather than treating the whole object +as one value that the higher-precedence scope replaces, the resolver +walks scopes per-tier. If user settings define `{ small }` and workspace +settings define `{ large }`, both contribute — `small` from user, +`large` from workspace. This keeps two things working at once: + +1. Each `{ path }` entry resolves against the file that declared it + (workspace `.qwen/` vs. user `~/.qwen/`); the merged view alone would + lose that scope information. +2. Users can keep a default `large` tier in their personal settings and + override only `small` per-workspace, without restating the whole + object. + +When the same tier is defined in multiple scopes, normal precedence +applies (system > workspace > user). Setting `customAsciiArt` to a bare +string or `{ path }` in any scope still fills both tiers in that scope. + +### Hide the banner entirely + +```jsonc +{ + "ui": { + "hideBanner": true, + }, +} +``` + +The startup output skips both the logo column and the info panel. Tips +still render unless `ui.hideTips` is also `true`. + +### Replace the brand title + +```jsonc +{ + "ui": { + "customBannerTitle": "Acme CLI", + }, +} +``` + +Renders as `Acme CLI (vX.Y.Z)` in the info panel. The `>_` glyph is +removed when a custom title is set; if you want it back, include it +yourself: `"customBannerTitle": ">_ Acme CLI"`. + +### Add a brand subtitle + +```jsonc +{ + "ui": { + "customBannerSubtitle": "Built-in DataWorks Official Skills", + }, +} +``` + +Renders the subtitle on its own row, in the secondary text color, in +place of the blank spacer that normally sits between the title and the +auth/model line: + +``` +┌─────────────────────────────────────────────────────────┐ +│ DataWorks DataAgent (vX.Y.Z) │ ← B① title +│ Built-in DataWorks Official Skills │ ← B② subtitle +│ Qwen OAuth | qwen-coder ( /model to change) │ ← B③ status +│ ~/projects/example │ ← B④ path +└─────────────────────────────────────────────────────────┘ +``` + +Constraints: + +- Single line only. Newlines and other control bytes are stripped / + folded to spaces so a paste accident can't break the info-panel + layout. +- Sanitized capped at 160 characters (looser than the title cap because + taglines / "powered by" lines often run a bit long). +- Leave the field unset (or set it to an empty string / whitespace) + to keep the existing blank spacer row — back-compat is the default. +- The subtitle does not change which lines are locked; auth, model, + and working directory are always visible regardless of subtitle + state. + +### Replace the ASCII art — inline string + +```jsonc +{ + "ui": { + "customAsciiArt": " ___ _ _ ____ \n / _ \\| | / |/ _\\\n| |_| | |__| | __/\n \\___/|____|_|___|", + }, +} +``` + +Use `\n` to embed newlines inside the JSON string. The art is rendered +with the active gradient theme just like the default logo. + +> **Don't have ASCII art handy?** Use any external generator and paste +> the result. The simplest path is `figlet`: +> `npx figlet -f "ANSI Shadow" "xxxCode" > brand.txt` and then point +> `customAsciiArt: { "path": "./brand.txt" }` at it. The CLI does not +> render text-to-art at runtime — see the _Out of scope_ section for +> why. + +### Replace the ASCII art — external file + +```jsonc +{ + "ui": { + "customAsciiArt": { "path": "./brand.txt" }, + }, +} +``` + +Avoids JSON-escaping a multi-line string. Path resolution rules: + +- **Workspace settings**: relative paths resolve against the workspace + `.qwen/` directory. +- **User settings**: relative paths resolve against `~/.qwen/`. +- Absolute paths are used as-is. +- The file is read **once at startup**, sanitized, and cached. Editing + the file mid-session does not re-render the banner — restart the CLI. + +### Replace the ASCII art — width-aware + +```jsonc +{ + "ui": { + "customAsciiArt": { + "small": " ACME\n ----", + "large": { "path": "./brand-wide.txt" }, + }, + }, +} +``` + +`large` is preferred when the terminal is wide enough; otherwise `small` +is used; otherwise the logo column is hidden (the existing two-column +fallback). Either tier may be a string or `{ path }`. Either tier may be +omitted: a missing tier simply falls through to the next step. + +### How wide can the logo be? — the size budget + +There is no hard character-count limit on the title or art. There is a +**width budget** driven by terminal columns and an absolute hard cap to +keep a malformed file from freezing layout: + +| Knob | Limit | +| ------------------------------------------------ | --------------------------------------------------------------------- | +| Terminal columns at startup | Whatever the user's terminal reports. | +| Container outer margin | 4 cols (2 left + 2 right). | +| Gap between logo and info panel | 2 cols. | +| Info panel minimum width | 44 cols (40 path + border + padding). | +| **Available logo width** (per tier, render-time) | `terminalCols − 4 − 2 − 44 = terminalCols − 50`. | +| Hard cap on each art tier (post-sanitize) | 200 cols × 200 lines. Anything beyond is truncated + `[BANNER]` warn. | +| Hard cap on `customBannerTitle` (post-sanitize) | 80 chars. Anything beyond is truncated + `[BANNER]` warn. | + +Reading the budget at common terminal widths: + +| Terminal cols | Max logo width that renders | What that means in practice | +| ------------- | --------------------------- | --------------------------------------------------------------------- | +| 80 | 30 | Most figlet "ANSI Shadow" letters are ~7–11 cols — 3 letters max. | +| 100 | 50 | A short word in ANSI Shadow (~6 letters), or two short words stacked. | +| 120 | 70 | Stacked multi-line word art fits comfortably. | +| 200 | 150 | Long inline strings like full product names in ANSI Shadow fit. | + +Two practical implications when designing your art: + +1. **A multi-word brand often won't render as a single ANSI Shadow line + on most terminals.** At ~7–9 cols per ANSI Shadow letter, even a + 12-character brand like `Custom Agent` is roughly 95 cols of art on + one line — already more than a 100-col terminal can spare alongside + the info panel. Either stack the words on multiple lines, pick a + denser figlet font, or use a compact single-line text decoration + like `▶ Custom Agent ◀`. +2. **Use the width-aware `{ small, large }` form** when a single tier + would force you to choose between "looks great wide / dies narrow" + and "looks fine narrow / wastes space wide". The example below + stacks the words for a ≥104-col terminal in `large` and falls + through to a 16-col single-line decoration in `small`. + +```jsonc +{ + "ui": { + "customBannerTitle": "Custom Agent", + "customAsciiArt": { + "small": "▶ Custom Agent ◀", + "large": { "path": "./banner-large.txt" }, + }, + }, +} +``` + +Where `banner-large.txt` contains the stacked-words ANSI Shadow output +(~54 cols × 12 lines), e.g., generated by: + +```bash +( npx figlet -f "ANSI Shadow" CUSTOM + npx figlet -f "ANSI Shadow" AGENT ) > banner-large.txt +``` + +### Combine all three + +```jsonc +{ + "ui": { + "hideBanner": false, + "customBannerTitle": "Acme CLI", + "customAsciiArt": { + "small": " ACME\n ----", + "large": { "path": "./brand-wide.txt" }, + }, + }, +} +``` + +### How to verify your change + +1. Save `settings.json` and start a fresh `qwen` session — banner + resolution runs once at startup. +2. Resize the terminal to confirm `small` / `large` tiers swap as + expected, and that the logo column disappears at very narrow widths. +3. If something does not appear as expected, look at + `~/.qwen/debug/.txt` (the symlink `latest.txt` points to + the current session) and grep for `[BANNER]` — every soft failure + logs a warn line with the underlying reason. + +## Resolution pipeline + +``` + settings.json packages/cli/src/ui/components/ + ───────────── ────────────────────────────── + { AppHeader.tsx + "ui": { │ + "hideBanner": false, │ showBanner = + "customBannerTitle": "Acme", │ !screenReader + "customBannerSubtitle": "Built-in …", │ && !ui.hideBanner + "customAsciiArt": … │ + } │ + } ▼ + │
+ resolveCustomBanner(settings) │ + ┌─────────────────────────┐ ▼ + │ 1. normalize to │ packages/cli/src/ui/components/ + │ { small, large } │ Header.tsx + │ 2. resolve each tier: │ │ + │ string → as-is │ │ pick tier by + │ {path} → fs.read │ │ availableTerminalWidth + │ O_NOFOLLOW │ ▼ + │ ≤ 64 KB │ render Logo Column + │ 3. sanitize art: │ render Info Panel: + │ stripControlSeqs │ Title = customBannerTitle + │ ≤ 200 lines × 200 │ ?? '>_ Qwen Code' + │ cols │ Subtitle = customBannerSubtitle + │ 4. sanitize title + │ ?? blank spacer row + │ subtitle (single- │ Status = locked + │ line, ≤ 80 / 160 │ Path = locked + │ chars) │ + │ 5. memoize by source │ + └─────────────────────────┘ +``` + +The five-step resolution algorithm runs once when settings are loaded +and again only on settings reload events: + +1. **Normalize**. A bare `string` or `{ path }` becomes + `{ small: x, large: x }`. A `{ small, large }` object passes through. +2. **Resolve each tier**. For each `AsciiArtSource`: + - If it is a string, use it as-is. + - If it is `{ path }`, read the file synchronously with `O_NOFOLLOW` + defense (Windows: plain read-only — the constant is not exposed), + capped at 64 KB. Relative paths resolve against the _owning + settings file's directory_ — workspace settings against the + workspace `.qwen/`, user settings against `~/.qwen/`. Read failure + logs `[BANNER]` warn and falls back to default for that tier. +3. **Sanitize**. A banner-specific stripper drops OSC / CSI / SS2 / SS3 + leaders and replaces every other C0 / C1 control byte (and DEL) with + a space, while preserving `\n` so multi-line art survives. Trim + trailing whitespace per line, then cap at 200 lines × 200 columns. + Anything beyond the cap is truncated and a `[BANNER]` warn is logged. +4. **Render-time tier selection**. In `Header.tsx`, given the resolved + `small` and `large`, evaluate the existing width budget + (`availableTerminalWidth ≥ logoWidth + logoGap + minInfoPanelWidth`): + - Prefer `large` if it fits. + - Else fall back to `small` if it fits. + - Else, **if the user supplied any custom art**, hide the logo column + entirely (the existing `showLogo = false` branch) — falling back to + the bundled QWEN logo here would silently undo a white-label + deployment on narrow terminals. The info panel still renders. + - Else (no custom art was supplied at all) fall through to + `shortAsciiLogo` and let the existing width gate decide whether to + show or hide the default logo. +5. **Fallback**. If both tiers end up empty or invalid because of soft + failures (missing file, sanitization rejected everything, malformed + config), behave as if no customization had been set: render + `shortAsciiLogo` and follow the default-logo width gate. The CLI + must never crash on a banner config error. + +Pseudocode for tier selection: + +```ts +function pickTier( + small: string | undefined, + large: string | undefined, + availableWidth: number, + logoGap: number, + minInfoPanelWidth: number, +): string | undefined { + for (const candidate of [large, small]) { + if (!candidate) continue; + const w = getAsciiArtWidth(candidate); + if (availableWidth >= w + logoGap + minInfoPanelWidth) { + return candidate; + } + } + return undefined; // logo column hidden +} +``` + +## Settings schema additions + +Four new properties are appended to the `ui` object in +`packages/cli/src/config/settingsSchema.ts`, immediately after +`shellOutputMaxLines`: + +```ts +hideBanner: { + type: 'boolean', + label: 'Hide Banner', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Hide the startup ASCII banner and info panel.', + showInDialog: true, +}, +customBannerTitle: { + type: 'string', + label: 'Custom Banner Title', + category: 'UI', + requiresRestart: false, + default: '' as string, + description: + 'Replace the default ">_ Qwen Code" title shown in the banner info panel. The version suffix is always appended.', + showInDialog: false, +}, +customBannerSubtitle: { + type: 'string', + label: 'Custom Banner Subtitle', + category: 'UI', + requiresRestart: false, + default: '' as string, + description: + 'Optional subtitle line rendered between the banner title and the auth/model line. When unset, the info panel keeps its blank spacer row.', + showInDialog: false, +}, +customAsciiArt: { + type: 'object', + label: 'Custom ASCII Art', + category: 'UI', + requiresRestart: false, + default: undefined, + description: + 'Replace the default QWEN ASCII art. Accepts an inline string, {"path": "..."}, or {"small": ..., "large": ...} for width-aware selection.', + showInDialog: false, + // The runtime accepts a union the SettingDefinition `type` field can't + // express. The override is emitted verbatim by the JSON-schema generator + // so VS Code accepts every documented shape (string, {path}, or + // {small,large}) without flagging the bare-string form. + jsonSchemaOverride: { /* string | {path} | {small,large} oneOf … */ }, +}, +``` + +`hideBanner` mirrors the existing `hideTips` pattern (`showInDialog: +true`). The three free-form fields (title, subtitle, art) stay out of +the in-app settings dialog because a multi-line ASCII editor in the +TUI dialog is its own project; power users edit `settings.json` +directly. + +## Wiring changes + +The implementation touch points are small. Each is described below with +the file and line range from the current `main`. + +`packages/cli/src/ui/components/AppHeader.tsx:53` — extend `showBanner`: + +```ts +const showBanner = !config.getScreenReader() && !settings.merged.ui?.hideBanner; +``` + +`packages/cli/src/ui/components/AppHeader.tsx` — pass the resolved +banner into `
`: + +```tsx +
+``` + +`packages/cli/src/ui/components/Header.tsx` — extend `HeaderProps`: + +```ts +interface HeaderProps { + customAsciiArt?: { small?: string; large?: string }; + customBannerTitle?: string; + customBannerSubtitle?: string; + version: string; + authDisplayType?: AuthDisplayType; + model: string; + workingDirectory: string; +} +``` + +`packages/cli/src/ui/components/Header.tsx:45-46` — pick the tier before +computing `logoWidth`, with the existing default as the floor: + +```ts +const tier = pickTier( + customAsciiArt?.small, + customAsciiArt?.large, + availableTerminalWidth, + logoGap, + minInfoPanelWidth, +); +const displayLogo = tier ?? shortAsciiLogo; +``` + +`packages/cli/src/ui/components/Header.tsx` — render the title from +the prop, and use the subtitle prop in place of the blank spacer row +when set: + +```tsx + + {customBannerTitle ? customBannerTitle : '>_ Qwen Code'} + +… +{customBannerSubtitle ? ( + {customBannerSubtitle} +) : ( + +)} +``` + +**New file**: `packages/cli/src/ui/utils/customBanner.ts` — the resolver. +Exports: + +```ts +export interface ResolvedBanner { + asciiArt: { small?: string; large?: string }; + title?: string; + subtitle?: string; +} + +export function resolveCustomBanner(settings: LoadedSettings): ResolvedBanner; +``` + +The resolver does the normalization, file reads, sanitization, and +caching described in the resolution pipeline above. It is called once +during CLI startup and re-run on settings hot-reload events. Per-scope +file paths come from `settings.system.path` / `settings.workspace.path` +/ `settings.user.path` directly so each `{ path }` resolves against +the file that declared it; workspace settings are skipped entirely +when `settings.isTrusted` is false. + +## Alternative approaches considered + +Five shapes of this feature were considered. They are listed here so +future contributors understand the design space and can revisit the +choice if the constraints change. + +### Option 1 — Three flat settings (RECOMMENDED, matches the issue) + +```jsonc +{ + "ui": { + "customAsciiArt": "...", // string | {path} | {small,large} + "customBannerTitle": "Acme CLI", + "hideBanner": false, + }, +} +``` + +- **Effect**: minimal user-facing surface; exactly what the issue asks + for. +- **Pros**: zero learning curve; trivially documented; consistent with + existing flat `ui.*` properties (`hideTips`, `customWittyPhrases`, + etc.). +- **Cons**: three top-level keys that conceptually belong together + aren't grouped; future banner-only knobs (gradient, subtitle) would + add more siblings to `ui` instead of nesting cleanly. + +### Option 2 — Nested `ui.banner` namespace + +```jsonc +{ + "ui": { + "banner": { + "hide": false, + "title": "Acme CLI", + "asciiArt": { "path": "./brand.txt" }, + }, + }, +} +``` + +- **Effect**: same capabilities as Option 1, organized by feature. +- **Pros**: clean namespace for future banner-only knobs; easier + discovery via `/settings`. +- **Cons**: diverges from the issue's exact wording; existing UI + settings are mostly flat (only `ui.accessibility` and `ui.statusLine` + nest), so consistency is mixed; adds one nesting level for users to + remember. + +### Option 3 — Banner profile presets + slot overrides + +```jsonc +{ + "ui": { + "bannerProfile": "minimal" | "default" | "branded" | "hidden", + "banner": { /* slot overrides for 'branded' */ } + } +} +``` + +- **Effect**: users pick from named presets; advanced users override + slots inside a chosen profile. +- **Pros**: nice onboarding UX; presets ship with the CLI. +- **Cons**: significant complexity; presets are a maintenance + commitment; the issue asks for raw customization, not curation. + +### Option 4 — Whole-banner override (single string template) + +```jsonc +{ + "ui": { + "bannerTemplate": "{{logo}}\n>_ {{title}} ({{version}})\n{{auth}} | {{model}}\n{{path}}", + }, +} +``` + +- **Effect**: single freeform template with locked variables filled in. +- **Pros**: maximum flexibility for non-standard layouts. +- **Cons**: re-implements layout in user-space; loses Ink's two-column + resilience to terminal width; very easy to write a template that + breaks on narrow terminals; large blast radius for a small feature. + +### Option 5 — Plugin / hook API + +Expose a banner-renderer hook through the extensions system. + +- **Effect**: code-level customization; extensions can render anything. +- **Pros**: maximum power; lets enterprises ship a sealed branding + plugin. +- **Cons**: large API surface; needs security review for arbitrary + terminal rendering; massively over-scoped for the issue. + +### Recommendation + +**Option 1** is recommended. It satisfies the issue verbatim, slots into +the existing `ui.*` style, and avoids forcing a nested-namespace +decision before we know what other banner-only knobs would actually +look like. If future siblings start accumulating, migrating to Option 2 +is additive — `ui.banner.title` and `ui.customBannerTitle` can coexist +during a deprecation window. + +## Security & failure handling + +The custom banner content is rendered verbatim in the terminal AND, in +the path-form, read from disk. Both surfaces are attack-reachable if a +hostile or compromised settings file is loaded. The same threat model +that drives the session-title feature applies here. + +| Concern | Guard | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ANSI / OSC-8 / CSI injection in art, title, or subtitle | Banner-specific stripper (`sanitizeArt` / `sanitizeSingleLine`): drops OSC / CSI / SS2 / SS3 leaders and replaces every other C0 / C1 control byte (and DEL) with a space. Applied before render and cache write. | +| Oversize file freezes startup | 64 KB hard cap on file reads. | +| Pathological art freezes layout | 200 lines × 200 cols cap on each resolved string. Excess is truncated; a `[BANNER]` warn is logged. | +| Symlink redirect on the path form | `O_NOFOLLOW` on file reads (Windows: plain read-only; constant not exposed). | +| Missing or unreadable file | Catch, log `[BANNER]` warn, fall back to default. Never throw into the UI. | +| Title or subtitle with newlines / excess length | Newlines folded to spaces; capped at 80 (title) / 160 (subtitle) characters. | +| Untrusted workspace influencing rendering or file reads | When `settings.isTrusted` is false, the resolver skips `settings.workspace` entirely (mirrors the trust gate that `settings.merged` applies). | +| Race on settings reload | Resolution is memoized by source (path or string hash) per call. Reloads re-run the resolver and re-read affected files. | + +Failure mode summary: every soft failure ends in `shortAsciiLogo` (or +the locked default title) plus a debug-log warn. Hard failures +(thrown errors) are not allowed in any branch of the resolver. + +## Out of scope + +These were considered and deliberately deferred. Each can be a separate +follow-up if user demand surfaces. + +| Item | Why not | +| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Text-to-ASCII rendering (`{ text: "xxxCode" }` form) | Considered and rejected for v1. Adding this would require either a `figlet` runtime dependency (~2–3 MB unpacked once a usable set of fonts is included) or a vendored single-font renderer (~200 lines + a `.flf` font file we'd own). Both options bring ongoing surface area: font selection, font-license tracking, "my font doesn't render right on terminal X" issues, and CJK / wide-character handling. The driving use case for this feature (white-label / multi-tenant) almost always has a designer producing intentional ASCII art, not relying on a default figlet font. Users who want one-line generation can already get it with `npx figlet "xxxCode" > brand.txt` + `customAsciiArt: { "path": "./brand.txt" }` — same outcome, no added dependency, no support burden inside Qwen Code. If demand surfaces later this form is purely additive: extend `AsciiArtSource` to `string \| {path} \| {text, font?}` without breaking any existing config. | +| `/banner` slash command for live editing | The settings UI is the canonical edit surface. A live editor for multi-line ASCII art is its own project. | +| Custom gradient colors / per-line color overrides | Theme owns colors. A separate proposal can extend the theme contract; banner customization should not duplicate that surface. | +| URL-loaded ASCII art | Network fetch at startup is its own can of worms — failure modes, caching, security review. The file-path form is the lower-risk equivalent. | +| Animation (spinning logo, marquee title) | Adds rendering load and a11y concerns; nothing in the use cases needs it. | +| VSCode / Web UI banner parity | Those surfaces don't render the Ink banner today. If they grow a banner, this design is the reference. | +| Dynamic reload on file change | The resolver runs at startup and on settings reload only. Mid-session art changes are rare enough that "restart to take effect" is the acceptable trade. | +| Hiding only individual locked regions (version, auth, model, path) | These are operational signals; suppressing them harms support and security posture more than it helps white-label scenarios. | + +## Verification plan + +For the eventual implementation PR, the following end-to-end checks +should pass. + +1. `~/.qwen/settings.json` with `customBannerTitle: "Acme CLI"` and an + inline `customAsciiArt` string → `qwen` shows the new title and art; + version suffix still present. +2. `customBannerSubtitle: "Built-in Acme Skills"` → the subtitle row + renders between the title and the auth/model line in the secondary + text color; auth, model, and path still visible. Unsetting it + restores the blank spacer row (back-compat). +3. `hideBanner: true` → `qwen` starts with no banner; tips and chat + render normally. +4. `customAsciiArt: { "path": "./brand.txt" }` in a workspace + `settings.json`, with `brand.txt` next to it in `.qwen/` → loads + from disk on workspace open. +5. `customAsciiArt: { "small": "...", "large": "..." }` → resize the + terminal between wide / medium / narrow; large at wide widths, + small at medium widths, logo column hidden at narrow widths, info + panel always visible. +6. Inject `\x1b[31mhostile` into `customBannerTitle` _and_ + `customBannerSubtitle` → both render as literal text, not + interpreted as red. +7. Point `path` at a missing file → CLI starts; `[BANNER]` warn + appears in `~/.qwen/debug/.txt`; default art renders. +8. Open the worktree with workspace trust off → workspace-defined + `customAsciiArt` (including `{ path }` entries) is silently + ignored; user-scope settings still apply. +9. `npm test` and `npm run typecheck` pass for the CLI package; unit + tests in `customBanner.test.ts` cover each accepted shape and each + failure path (missing file, oversize file, ANSI injection, malformed + object). diff --git a/docs/design/customize-banner-area/customize-banner-area.zh-CN.md b/docs/design/customize-banner-area/customize-banner-area.zh-CN.md new file mode 100644 index 0000000000..f7022fcd1d --- /dev/null +++ b/docs/design/customize-banner-area/customize-banner-area.zh-CN.md @@ -0,0 +1,720 @@ +# Banner 自定义区域设计方案 + +> 允许用户替换 QWEN ASCII Logo、替换品牌标题、整体隐藏 Banner —— +> 但不允许抹掉用于排障与可信度的运行时信息(版本号、鉴权方式、模型、 +> 工作目录)。 + +## 概述 + +Qwen Code CLI 启动时会在终端顶部打印一个 Banner,包含 QWEN ASCII +Logo 和一个带边框的信息面板。多种真实场景需要对这一区域进行控制: + +- **白标 / 第三方品牌集成**:将 Qwen Code 嵌入企业或团队自有产品时, + 需要展示自家品牌而非默认的 "Qwen Code"。 +- **个性化**:个人用户希望让终端 Banner 与团队规范或个人审美一致。 +- **多租户 / 多实例区分**:在共享环境下,不同团队希望快速辨认自己 + 正在使用哪个实例。 + +设计立场十分简单:**品牌外观可替换;运行时信息不可替换**。 +自定义只允许用户把自己的品牌叠在上面,**不允许**屏蔽用于排障的关键 +信息。本文档后续每一处「可改 / 不可改」的判定都来自这一立场。 + +对应 issue:[#3005](https://github.com/QwenLM/qwen-code/issues/3005)。 + +## Banner 区域划分 + +当前 Banner 由 `Header`(由 `AppHeader` 挂载)渲染,整体可拆分如下: + +``` + marginX=2 marginX=2 + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ┌──── Logo 列 ─────────┐ gap=2 ┌──── 信息面板 (带边框) ──────────────┐ │ +│ │ │ │ │ │ +│ │ ███ QWEN ASCII ███ │ │ ① 标题: >_ Qwen Code (vX.Y.Z) │ │ +│ │ ███ ART ART ███ │ │ ② 副标题: «空白行 / 自定义覆盖» │ │ +│ │ ███ QWEN ASCII ███ │ │ ③ 状态: Qwen OAuth | qwen-… │ │ +│ │ │ │ ④ 路径: ~/projects/example │ │ +│ └──────── A ───────────┘ └──────────────── B ──────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + 区域归属:AppHeader + │ Tips 组件渲染在下方(由 ui.hideTips 控制) │ +``` + +两个顶级区块: + +- **A. Logo 列** —— 单块带渐变色的 ASCII art。 + 当前来源:`packages/cli/src/ui/components/AsciiArt.ts` 中的 + `shortAsciiLogo`。 +- **B. 信息面板** —— 带边框的信息盒,共四行。第二行默认是空白视觉 + spacer,可选地切换为调用方提供的副标题: + - **B①** 标题:`>_ Qwen Code (vX.Y.Z)` —— 品牌文字 + 版本号后缀。 + - **B②** 副标题 / spacer:默认是单空格行,设置 `ui.customBannerSubtitle` + 后渲染清洗后的单行副标题字符串(例如某个 fork 用 + `Built-in DataWorks Official Skills`)。 + - **B③** 状态:`<鉴权显示类型> | <模型> ( /model 切换)`。 + - **B④** 路径:经过 tildeify 与缩短的工作目录。 + +外层 `` 已经基于 `showBanner = !config.getScreenReader()` +对 Banner 做了屏读模式下的整体隐藏处理(屏读模式下回退为纯文本输出)。 + +## 自定义规则 —— 哪些可改,哪些被锁定 + +| 区域 | 当前来源 | 自定义类别 | 锁定/开放原因 | +| ---------------------------------- | ------------------------------------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A. Logo 列** | `shortAsciiLogo` (`AsciiArt.ts`) | **可替换 + 可自动隐藏** | 纯品牌区域。白标场景需要完全控制视觉。窄终端下「自动隐藏 Logo」的现有行为保持不变。 | +| **B①. 标题文字**(`>_ Qwen Code`) | `Header.tsx` 硬编码 | **可替换** | 品牌区域。开头的 `>_` 字符是现有品牌的一部分;如不需要,用户在 `customBannerTitle` 中省略即可。 | +| **B①. 版本号后缀**(`(vX.Y.Z)`) | `version` prop | **锁定** | 排障与支持必备。隐藏后只能通过 `--version` 才能回答「你用的什么版本?」,对支持流程是真实成本。我们以小幅白标体验损失换取支持可达性。 | +| **B②. 副标题 / spacer 行** | 默认空白 | **可替换** | 纯品牌 / 上下文区域。白标 fork 用它给构建版本打 tag(如 "Built-in DataWorks Official Skills")。清洗规则与标题一致;只允许单行,不接受会破坏布局的换行。 | +| **B③. 状态行**(鉴权 + 模型) | `formattedAuthType`、`model` prop | **锁定** | 运营与安全信号。用户必须看到当前使用的凭据以及实际消耗 token 的模型。任何隐藏/替换都是 footgun,即便在白标场景下也不应允许。 | +| **B④. 路径行**(工作目录) | `workingDirectory` prop | **锁定** | 运营信息。「我现在在哪个目录?」是高频问题;Banner 是其唯一权威答案。 | +| **整个 Banner** (A + B) | `AppHeader.tsx` 中 `
` 挂载点 | **可隐藏** | 一个 `ui.hideBanner: true` 同时跳过 A、B 两个区块 —— 形态与现有屏读模式开关一致。`` 仍由独立的 `ui.hideTips` 控制。 | + +上述矩阵对应四个设置项,仅此而已: + +| 设置 | 默认值 | 效果 | 影响区域 | +| ------------------------- | ------- | ---------------------------------------------------------------------------------------------------- | ------------ | +| `ui.hideBanner` | `false` | 隐藏整个 Banner(区域 A + B)。 | A + B | +| `ui.customBannerTitle` | 未设置 | 替换 B① 的品牌文字。版本号后缀照常追加。会被 trim;空字符串 = 使用默认。 | B① 品牌文字 | +| `ui.customBannerSubtitle` | 未设置 | 用一行副标题替换 B② 的空白 spacer。会被清洗;上限 160 字符;空字符串 = 保留空白 spacer(向后兼容)。 | B② spacer 行 | +| `ui.customAsciiArt` | 未设置 | 替换区域 A。支持三种数据形态(见下文)。任何错误均回退为默认。 | A | + +**有意不提供**的能力: + +- 不提供「仅隐藏版本号后缀」的开关。 +- 不提供「仅隐藏鉴权/模型行」的开关。 +- 不提供「仅隐藏路径行」的开关。 +- 不提供 Logo 渐变颜色的修改入口(颜色由 theme 负责)。 +- 不提供调整信息面板顺序或结构的能力。 + +如果未来确有需求,应作为新字段单独走方案评估,而不是从上述三个字段 +派生出来。 + +## 用户配置指南 —— 如何修改 + +### 限制总览 + +每次 banner 自定义都会受这几组上限约束。手写 art 前先看一遍,免得被 +解析器静默截断或拒绝。 + +| 项目 | 上限 | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------- | +| **标题字符数** | **80 字符上限**(清洗后计数)。超出截断并打 `[BANNER]` warn。换行符与控制字符在计数前已被剥离。 | +| **副标题字符数** | **160 字符上限**(清洗后计数)。清洗管线与标题一致;超出截断同样打 `[BANNER]` warn。 | +| **ASCII art 块尺寸** | **每档 200 行 × 200 列上限**。超出截断并打 `[BANNER]` warn。 | +| **ASCII art 文件大小** | **64 KB 上限**。文件大于上限时只读取上限以内的字节,剩余忽略。 | +| **ASCII art 实际可渲染宽度** | 由启动时终端列数决定,**不是固定字符数**。具体公式与各种终端宽度下的可用值见下文「Logo 能多大?—— 宽度预算」。 | + +ASCII art **没有固定的字符数上限** —— 只有上面这两组列/行硬上限以及 +启动时按终端列数计算的宽度预算。同样 17 字符的品牌名,换字体后能不能 +单行渲染下来,取决于视觉宽度而不是字母数。 + +### 配置存放位置 + +四个设置都位于 `settings.json` 的 `ui` 节点下。同时支持用户级 +(`~/.qwen/settings.json`)和工作区级(项目根目录的 +`.qwen/settings.json`),按标准合并优先级生效(workspace 覆盖 +user,system 覆盖 workspace)。 + +`customAsciiArt` 是特例:解析器不把整个对象当成一个值由更高优先级的 +scope 直接替换,而是按 tier 逐个穿越所有 scope。如果 user 设置定义 +了 `{ small }`、workspace 设置定义了 `{ large }`,两边都会生效 —— +`small` 取自 user,`large` 取自 workspace。这样能同时满足两件事: + +1. 每个 `{ path }` 项相对于声明它的那个文件解析(workspace `.qwen/` + vs. user `~/.qwen/`);只看 merged 视图就丢失了 scope 信息。 +2. 用户可以把默认的 `large` tier 留在个人设置里,按工作区只覆盖 + `small`,而不必每次重写整个对象。 + +同一 tier 在多个 scope 都定义时,仍按正常优先级生效(system > +workspace > user)。在任意 scope 把 `customAsciiArt` 设为单条字符串 +或 `{ path }` 时,仍然会同时填充该 scope 的两个 tier。 + +### 整体隐藏 Banner + +```jsonc +{ + "ui": { + "hideBanner": true, + }, +} +``` + +启动输出会跳过 Logo 列和信息面板。除非也设置了 `ui.hideTips`,否则 +Tips 仍会显示。 + +### 替换品牌标题 + +```jsonc +{ + "ui": { + "customBannerTitle": "Acme CLI", + }, +} +``` + +信息面板将渲染为 `Acme CLI (vX.Y.Z)`。设置自定义标题后默认不再带 +`>_` 字符;如需保留,请自己写进去: +`"customBannerTitle": ">_ Acme CLI"`。 + +### 添加品牌副标题 + +```jsonc +{ + "ui": { + "customBannerSubtitle": "Built-in DataWorks Official Skills", + }, +} +``` + +副标题会以次要文字色单独成一行,**取代**默认的空白 spacer 行(即原本 +位于标题与鉴权 / 模型行之间那一行): + +``` +┌─────────────────────────────────────────────────────────┐ +│ DataWorks DataAgent (vX.Y.Z) │ ← B① 标题 +│ Built-in DataWorks Official Skills │ ← B② 副标题 +│ Qwen OAuth | qwen-coder ( /model 切换) │ ← B③ 状态 +│ ~/projects/example │ ← B④ 路径 +└─────────────────────────────────────────────────────────┘ +``` + +约束: + +- 仅允许单行。换行符以及其他控制字节会被剥离 / 折叠为空格,避免 + 粘贴事故撕坏信息面板布局。 +- 清洗后上限 160 字符(比标题宽松一些 —— 副标语 / "powered by" 之 + 类的文案常常会比品牌名长)。 +- 留空(或设置为空字符串 / 全空白)= 保留默认的空白 spacer 行 —— + 向后兼容是默认行为。 +- 副标题不会改变锁定行的行为;鉴权、模型与工作目录始终可见,与副 + 标题状态无关。 + +### 替换 ASCII art —— 内联字符串 + +```jsonc +{ + "ui": { + "customAsciiArt": " ___ _ _ ____ \n / _ \\| | / |/ _\\\n| |_| | |__| | __/\n \\___/|____|_|___|", + }, +} +``` + +JSON 字符串中用 `\n` 表示换行。该 ASCII art 会与默认 Logo 一样应用 +当前主题的渐变色。 + +> **手头没有 ASCII art?** 任何外部生成器都行,把生成结果粘贴 +> 进来即可。最简路径是 `figlet`: +> `npx figlet -f "ANSI Shadow" "xxxCode" > brand.txt`,然后把 +> `customAsciiArt: { "path": "./brand.txt" }` 指向该文件。CLI **不会** +> 在运行时把文案渲染成 ASCII art —— 原因见下文「不在本设计范围内」。 + +### 替换 ASCII art —— 外部文件 + +```jsonc +{ + "ui": { + "customAsciiArt": { "path": "./brand.txt" }, + }, +} +``` + +避免在 JSON 中转义大段多行字符串。路径解析规则: + +- **工作区级设置**:相对路径相对于 workspace 的 `.qwen/` 目录。 +- **用户级设置**:相对路径相对于 `~/.qwen/`。 +- 绝对路径直接使用。 +- 文件**仅在启动时读取一次**,经过清洗后写入缓存。会话进行中修改 + 文件不会重新渲染 —— 请重启 CLI。 + +### 替换 ASCII art —— 宽度自适应 + +```jsonc +{ + "ui": { + "customAsciiArt": { + "small": " ACME\n ----", + "large": { "path": "./brand-wide.txt" }, + }, + }, +} +``` + +终端足够宽时优先使用 `large`;否则使用 `small`;再否则隐藏 Logo 列 +(沿用当前的双列回退策略)。`small` 与 `large` 各自既可以是字符串 +也可以是 `{ path }`。任意一档可省略:缺失时直接进入下一档。 + +### Logo 能多大?—— 宽度预算 + +标题和 art 都没有"字符数硬上限",只有由终端列数决定的**宽度预算**, +以及防止畸形输入冻结布局的绝对硬上限: + +| 项 | 上限 | +| ---------------------------------- | ----------------------------------------------- | +| 启动时终端列数 | 用户终端报告多少就是多少。 | +| 容器外边距 | 4 列(左 2 + 右 2)。 | +| Logo 列与信息面板之间的间距 | 2 列。 | +| 信息面板最小宽度 | 44 列(40 路径 + 边框 + 内边距)。 | +| **每档 art 在渲染时的可用宽度** | `终端列数 − 4 − 2 − 44 = 终端列数 − 50`。 | +| 单档 art 清洗后的硬上限 | 200 列 × 200 行。超出截断并打 `[BANNER]` warn。 | +| `customBannerTitle` 清洗后的硬上限 | 80 字符。超出截断并打 `[BANNER]` warn。 | + +常见终端宽度对应的 logo 上限: + +| 终端列数 | 可渲染最大 logo 宽度 | 实际意味着什么 | +| -------- | -------------------- | ------------------------------------------------------- | +| 80 | 30 | 大部分 figlet "ANSI Shadow" 字母 7–11 列,最多 3 个字。 | +| 100 | 50 | ANSI Shadow 写一个短词(约 6 字母)或两个短词堆叠。 | +| 120 | 70 | 多行单词堆叠的 art 完全够。 | +| 200 | 150 | 单行长串(例如完整产品名的 ANSI Shadow)也能装下。 | + +设计 art 时的两条经验法则: + +1. **多单词品牌名通常无法在多数终端上用一行 ANSI Shadow 渲染。** + ANSI Shadow 每字母约 7–9 列,即便像 `Custom Agent` 这样 12 字符的 + 品牌名,单行就要约 95 列 art —— 100 列的终端在装下信息面板后已经 + 不够。要么把单词换行堆叠,要么换更窄的 figlet 字体,要么直接用 + 紧凑的单行装饰,例如 `▶ Custom Agent ◀`。 +2. **当单档既要"宽屏好看"又要"窄屏不死"时,用 `{ small, large }` + 宽度自适应形态**。下面这个例子里 `large` 是 ≥ 104 列终端用的堆叠 + 多行 art,`small` 是 16 列的单行装饰,窄到装不下两者就直接隐藏 + logo 列。 + +```jsonc +{ + "ui": { + "customBannerTitle": "Custom Agent", + "customAsciiArt": { + "small": "▶ Custom Agent ◀", + "large": { "path": "./banner-large.txt" }, + }, + }, +} +``` + +`banner-large.txt` 里放堆叠后的 ANSI Shadow 输出(约 54 列 × 12 行), +可以用下面的命令生成: + +```bash +( npx figlet -f "ANSI Shadow" CUSTOM + npx figlet -f "ANSI Shadow" AGENT ) > banner-large.txt +``` + +### 三项组合 + +```jsonc +{ + "ui": { + "hideBanner": false, + "customBannerTitle": "Acme CLI", + "customAsciiArt": { + "small": " ACME\n ----", + "large": { "path": "./brand-wide.txt" }, + }, + }, +} +``` + +### 如何验证 + +1. 保存 `settings.json`,重新启动 `qwen` —— Banner 解析仅在启动时 + 运行一次。 +2. 调整终端宽度,确认 `small` / `large` 切换符合预期,并且在极窄 + 宽度下 Logo 列正确隐藏。 +3. 若结果与预期不符,查看 + `~/.qwen/debug/.txt`(`latest.txt` 软链指向当前 + 会话),grep `[BANNER]` —— 每一次软失败都会打印一行 warn + 说明原因。 + +## 解析流水线 + +``` + settings.json packages/cli/src/ui/components/ + ───────────── ────────────────────────────── + { AppHeader.tsx + "ui": { │ + "hideBanner": false, │ showBanner = + "customBannerTitle": "Acme", │ !screenReader + "customBannerSubtitle": "Built-in …", │ && !ui.hideBanner + "customAsciiArt": … │ + } │ + } ▼ + │
+ resolveCustomBanner(settings) │ + ┌─────────────────────────┐ ▼ + │ 1. 归一化为 │ packages/cli/src/ui/components/ + │ { small, large } │ Header.tsx + │ 2. 解析每一档: │ │ + │ string → 直接使用 │ │ 按 availableTerminalWidth + │ {path} → fs.read │ │ 挑选档位 + │ O_NOFOLLOW │ ▼ + │ ≤ 64 KB │ 渲染 Logo 列 + │ 3. 清洗 art: │ 渲染信息面板: + │ stripControlSeqs │ Title = customBannerTitle + │ ≤ 200 行 × 200 列 │ ?? '>_ Qwen Code' + │ 4. 清洗 title + │ Subtitle = customBannerSubtitle + │ subtitle(单行, │ ?? 空白 spacer 行 + │ ≤ 80 / 160 字符) │ Status = 锁定 + │ 5. 按来源 memoize │ Path = 锁定 + └─────────────────────────┘ +``` + +五步解析算法在加载设置时运行一次,仅在设置热重载事件触发时再次 +运行: + +1. **归一化**。裸 `string` 或 `{ path }` 转为 + `{ small: x, large: x }`。`{ small, large }` 对象原样通过。 +2. **逐档解析**。对每个 `AsciiArtSource`: + - 字符串:直接使用。 + - `{ path }`:同步读取,使用 `O_NOFOLLOW` 防御软链劫持 + (Windows 退化为普通只读读取 —— 该常量不暴露), + 上限 64 KB。相对路径相对于*所属设置文件的目录*:workspace + 设置相对 workspace `.qwen/`,user 设置相对 `~/.qwen/`。 + 读取失败 → `[BANNER]` warn,该档回退默认。 +3. **清洗**。Banner 专用 stripper:去掉 OSC / CSI / SS2 / SS3 引导 + 字符,把其余 C0 / C1 控制字节(含 DEL)替换为空格,同时保留 + `\n` 让多行 ASCII art 存活。每行 trim 尾部空白后,截断至 200 行 + × 200 列,超出部分截断并打印 `[BANNER]` warn。 +4. **渲染期挑档**。在 `Header.tsx` 中,给定解析后的 `small` 与 + `large`,根据现有宽度预算 + (`availableTerminalWidth ≥ logoWidth + logoGap + minInfoPanelWidth`): + - 若 `large` 容得下,优先 `large`。 + - 否则若 `small` 容得下,回退 `small`。 + - 再否则,**只要用户提供过 custom art**,就直接隐藏 Logo 列 + (沿用 `showLogo = false` 分支)—— 此时若退到内置 QWEN logo 会 + 在窄终端上悄悄破坏白标部署。信息面板继续渲染。 + - 否则(用户完全没提供 custom art)退到 `shortAsciiLogo`,由 + 默认 logo 的宽度闸门决定是否显示。 +5. **兜底**。如果两档因为软失败(文件缺失、清洗后全空、配置畸形) + 都最终为空或非法,按未自定义渲染 `shortAsciiLogo`,并按默认 + logo 的宽度闸门处理。CLI **绝不能**因为 Banner 配置错误而崩溃。 + +挑档的伪代码: + +```ts +function pickTier( + small: string | undefined, + large: string | undefined, + availableWidth: number, + logoGap: number, + minInfoPanelWidth: number, +): string | undefined { + for (const candidate of [large, small]) { + if (!candidate) continue; + const w = getAsciiArtWidth(candidate); + if (availableWidth >= w + logoGap + minInfoPanelWidth) { + return candidate; + } + } + return undefined; // 隐藏 Logo 列 +} +``` + +## Settings schema 新增 + +在 `packages/cli/src/config/settingsSchema.ts` 的 `ui` 对象中, +紧接 `shellOutputMaxLines` 追加四个属性: + +```ts +hideBanner: { + type: 'boolean', + label: 'Hide Banner', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Hide the startup ASCII banner and info panel.', + showInDialog: true, +}, +customBannerTitle: { + type: 'string', + label: 'Custom Banner Title', + category: 'UI', + requiresRestart: false, + default: '' as string, + description: + 'Replace the default ">_ Qwen Code" title shown in the banner info panel. The version suffix is always appended.', + showInDialog: false, +}, +customBannerSubtitle: { + type: 'string', + label: 'Custom Banner Subtitle', + category: 'UI', + requiresRestart: false, + default: '' as string, + description: + 'Optional subtitle line rendered between the banner title and the auth/model line. When unset, the info panel keeps its blank spacer row.', + showInDialog: false, +}, +customAsciiArt: { + type: 'object', + label: 'Custom ASCII Art', + category: 'UI', + requiresRestart: false, + default: undefined, + description: + 'Replace the default QWEN ASCII art. Accepts an inline string, {"path": "..."}, or {"small": ..., "large": ...} for width-aware selection.', + showInDialog: false, + // 运行时接受 SettingDefinition `type` 表达不出来的联合形态。 + // override 由 JSON-schema 生成器原样输出,让 VS Code 接受所有 + // 文档化的形态(string、{path}、{small,large}),不再把裸字符串 + // 标红。 + jsonSchemaOverride: { /* string | {path} | {small,large} oneOf … */ }, +}, +``` + +`hideBanner` 沿用现有 `hideTips` 的模式(`showInDialog: true`); +其余三个自由文本字段(标题、副标题、art)不进入应用内设置对话框 —— +在 TUI 对话框里做多行 ASCII 编辑器是另一个项目,高级用户直接编辑 +`settings.json` 即可。 + +## 代码改动点 + +实施改动很小。下面给出每处的文件与当前 `main` 分支上的行号范围。 + +`packages/cli/src/ui/components/AppHeader.tsx:53` —— 扩展 +`showBanner`: + +```ts +const showBanner = !config.getScreenReader() && !settings.merged.ui?.hideBanner; +``` + +`packages/cli/src/ui/components/AppHeader.tsx` —— 把解析后的 +Banner 数据传入 `
`: + +```tsx +
+``` + +`packages/cli/src/ui/components/Header.tsx` —— 扩展 `HeaderProps`: + +```ts +interface HeaderProps { + customAsciiArt?: { small?: string; large?: string }; + customBannerTitle?: string; + customBannerSubtitle?: string; + version: string; + authDisplayType?: AuthDisplayType; + model: string; + workingDirectory: string; +} +``` + +`packages/cli/src/ui/components/Header.tsx:45-46` —— 在计算 +`logoWidth` 之前先挑档,并以现有默认作为兜底: + +```ts +const tier = pickTier( + customAsciiArt?.small, + customAsciiArt?.large, + availableTerminalWidth, + logoGap, + minInfoPanelWidth, +); +const displayLogo = tier ?? shortAsciiLogo; +``` + +`packages/cli/src/ui/components/Header.tsx` —— 标题从 prop 渲染, +副标题在 prop 真值时取代原本的空白 spacer 行: + +```tsx + + {customBannerTitle ? customBannerTitle : '>_ Qwen Code'} + +… +{customBannerSubtitle ? ( + {customBannerSubtitle} +) : ( + +)} +``` + +**新增文件**:`packages/cli/src/ui/utils/customBanner.ts` —— 解析器。 +对外接口: + +```ts +export interface ResolvedBanner { + asciiArt: { small?: string; large?: string }; + title?: string; + subtitle?: string; +} + +export function resolveCustomBanner(settings: LoadedSettings): ResolvedBanner; +``` + +解析器负责上述「解析流水线」中描述的归一化、文件读取、清洗与缓存。 +在 CLI 启动时调用一次,并在设置热重载事件中再次调用。每个 scope 的 +文件路径直接来自 `settings.system.path` / `settings.workspace.path` / +`settings.user.path`,因此每个 `{ path }` 都相对于声明它的那个文件 +解析;当 `settings.isTrusted` 为 false 时整个跳过 workspace scope。 + +## 备选方案对比 + +下面给出曾经评估过的 5 种形态,便于后续维护者了解设计空间,必要时 +重新评估。 + +### 方案 1 —— 三个扁平字段(推荐,与 issue 完全一致) + +```jsonc +{ + "ui": { + "customAsciiArt": "...", // string | {path} | {small,large} + "customBannerTitle": "Acme CLI", + "hideBanner": false, + }, +} +``` + +- **效果**:用户面最小,与 issue 描述一一对应。 +- **优点**:零学习成本;文档极易;与现有 `ui.*` 扁平字段一致 + (`hideTips`、`customWittyPhrases` 等)。 +- **缺点**:三个语义相关的键散落在 `ui` 顶层;未来若新增 banner + 专属开关(渐变、副标题等)只能继续向 `ui` 加兄弟字段,不能 + 天然分组。 + +### 方案 2 —— 嵌套 `ui.banner` 命名空间 + +```jsonc +{ + "ui": { + "banner": { + "hide": false, + "title": "Acme CLI", + "asciiArt": { "path": "./brand.txt" }, + }, + }, +} +``` + +- **效果**:能力等同方案 1,按特性聚合。 +- **优点**:未来 banner 专属开关有干净的命名空间;`/settings` + 发现性更好。 +- **缺点**:与 issue 原文写法不完全一致;现有 UI 设置以扁平为主 + (仅 `ui.accessibility` 与 `ui.statusLine` 是嵌套的),一致性 + 打折;多了一层让用户记忆。 + +### 方案 3 —— Banner profile 预设 + slot override + +```jsonc +{ + "ui": { + "bannerProfile": "minimal" | "default" | "branded" | "hidden", + "banner": { /* 'branded' 下的 slot 覆盖 */ } + } +} +``` + +- **效果**:用户从命名预设挑选;高级用户在所选预设上覆盖具体 slot。 +- **优点**:onboarding 体验更好;预设可由 CLI 自带。 +- **缺点**:复杂度显著上升;预设是长期维护承诺;issue 要求的是 + 开放自定义而非内容策划。 + +### 方案 4 —— 整体 Banner 模板字符串 + +```jsonc +{ + "ui": { + "bannerTemplate": "{{logo}}\n>_ {{title}} ({{version}})\n{{auth}} | {{model}}\n{{path}}", + }, +} +``` + +- **效果**:单条 freeform 模板,受锁字段做插值。 +- **优点**:非标准布局的灵活度最高。 +- **缺点**:把布局责任推给用户态;Ink 双列对终端宽度的鲁棒性失去; + 极易写出在窄终端下崩坏的模板;为这点收益打开很大的破坏面。 + +### 方案 5 —— 插件 / 钩子 API + +通过扩展系统暴露一个 banner-renderer 钩子。 + +- **效果**:代码级自定义;扩展可以渲染任意内容。 +- **优点**:能力上限最高;企业可以打包出整套封装的品牌插件。 +- **缺点**:API 表面巨大;任意终端渲染需要安全评审;对该 issue + 完全过度设计。 + +### 推荐结论 + +**采用方案 1**。它直接满足 issue,契合现有 `ui.*` 风格,且不会在 +我们尚未明确还有哪些 banner 专属开关之前就被命名空间锁死。如果未来 +兄弟字段开始累积,迁移到方案 2 是叠加式的 —— `ui.banner.title` 与 +`ui.customBannerTitle` 可以在弃用窗口期内并存。 + +## 安全与失败处理 + +自定义 Banner 内容会**逐字渲染到终端**,并且在 path 形态下还会 +**从磁盘读取**。两条路径在加载到恶意或被篡改的 settings 时都是 +可达的。Session-title 特性所应对的同一类威胁模型在此同样适用。 + +| 关注点 | 防护手段 | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| ASCII art / 标题 / 副标题中的 ANSI / OSC-8 / CSI 注入 | Banner 专用 stripper(`sanitizeArt` / `sanitizeSingleLine`):剥离 OSC / CSI / SS2 / SS3 引导符,把其余 C0 / C1 控制字节(含 DEL)替换为空格。渲染与缓存写入前都过一遍。 | +| 超大文件冻结启动 | 文件读取硬上限 64 KB。 | +| 病态 ASCII art 冻结布局 | 每个解析结果上限 200 行 × 200 列;超出截断 + `[BANNER]` warn。 | +| 软链劫持 path 形态 | 文件读取使用 `O_NOFOLLOW`(Windows 下退化为只读;常量不暴露)。 | +| 文件缺失或不可读 | 捕获 → `[BANNER]` warn → 回退默认;绝不抛入 UI。 | +| 标题 / 副标题包含换行或过长 | 换行折叠为空格,截断至 80(标题)/ 160(副标题)字符。 | +| 不可信工作区影响渲染或文件读取 | `settings.isTrusted` 为 false 时,解析器整个跳过 `settings.workspace`(与 `settings.merged` 视图的信任闸门一致)。 | +| 设置热重载竞态 | 解析结果在每次调用内按来源(path 或字符串)做 memoize;reload 重新跑一遍解析器并重新读受影响的文件。 | + +失败模式总结:所有软失败最终都会落到 `shortAsciiLogo`(或锁定的 +默认标题)+ 一行调试日志 warn。任何分支都不允许产生硬失败 +(向上抛出异常)。 + +## 不在本设计范围内 + +下列项被有意排除。每一项都可以视用户反馈做后续单独提案。 + +| 项目 | 不做的理由 | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 文案转 ASCII art(`{ text: "xxxCode" }` 形态) | v1 评估后**拒绝**。要么引入 `figlet` 运行时依赖(含一套可用字体后约 2–3 MB unpacked),要么自己 vendor 一份单字体渲染器(~200 行代码 + 一份 `.flf` 字体我们自己维护)。两条路都带来长期的维护面:字体选型、字体 license 审计、「我的字体在 X 终端渲染不对」类 issue、CJK / 全角字符处理。本特性的驱动用例(白标 / 多租户)几乎一定有设计师交付成品 ASCII art,不会依赖 figlet 默认字体。希望一行命令生成的用户今天就能 `npx figlet "xxxCode" > brand.txt` + `customAsciiArt: { "path": "./brand.txt" }` —— 等价效果、零新增依赖、零 Qwen Code 内部支持负担。如果未来诉求增多,这一形态是纯叠加:把 `AsciiArtSource` 扩展为 `string \| {path} \| {text, font?}`,不会破坏任何已有配置。 | +| `/banner` slash 命令在线编辑 | 设置 UI 是规范化的编辑入口;多行 ASCII 在线编辑器是另一个项目。 | +| 自定义渐变色 / 单行颜色 | 颜色由 theme 拥有。如需扩展应另立提案,Banner 自定义不重复造该面。 | +| URL 加载 ASCII art | 启动期网络请求自带一堆问题:失败模式、缓存、安全评审。`{path}` 文件加载是低风险等价物。 | +| 动画(旋转 Logo、跑马灯标题) | 增加渲染负担与无障碍问题;本特性的用例不需要。 | +| VSCode / Web UI banner 对齐 | 这两个端目前不渲染 Ink Banner。若未来引入,本设计为参考。 | +| 文件变更的动态 reload | 解析器仅在启动与设置 reload 时运行。会话中途换 art 的需求很少,「重启生效」是可以接受的折中。 | +| 单独隐藏锁定区域(version / auth / model / path) | 这些是运行时信号;屏蔽它们对支持与安全姿态的损害,远大于白标场景的收益。 | + +## 验证计划 + +后续实施 PR 应通过以下端到端检查: + +1. `~/.qwen/settings.json` 设置 `customBannerTitle: "Acme CLI"` + 与一段内联 `customAsciiArt` → `qwen` 启动后展示新标题与新 + ASCII art;版本号后缀仍在。 +2. 设置 `customBannerSubtitle: "Built-in Acme Skills"` → 副标题 + 行以次要文字色出现在标题与鉴权 / 模型行之间;鉴权、模型、 + 路径仍可见。取消设置后回到空白 spacer 行(向后兼容)。 +3. 设置 `hideBanner: true` → `qwen` 启动无 Banner;Tips 与正文 + 照常渲染。 +4. workspace `settings.json` 设置 + `customAsciiArt: { "path": "./brand.txt" }`,`brand.txt` 与 + 之同处 `.qwen/` 目录 → 打开工作区时从磁盘加载。 +5. `customAsciiArt: { "small": "...", "large": "..." }` → + 在宽 / 中 / 窄三档下调整终端尺寸;宽时取 large、中时取 + small、窄时隐藏 Logo 列;信息面板始终可见。 +6. `customBannerTitle` **与** `customBannerSubtitle` 中分别 + 注入 `\x1b[31mhostile` → 两处都渲染为字面文本,不会被 + 解释为红色。 +7. `path` 指向不存在的文件 → CLI 正常启动; + `~/.qwen/debug/.txt` 出现 `[BANNER]` warn; + 渲染默认 art。 +8. 在工作区信任关闭的状态下打开 worktree → workspace 提供的 + `customAsciiArt`(含 `{ path }` 项)被静默忽略;user scope + 的设置仍然生效。 diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 138b694b43..0019bca293 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -78,6 +78,19 @@ export interface SettingDefinition { options?: readonly SettingEnumOption[]; /** Schema for array items when type is 'array' */ items?: SettingItemDefinition; + /** + * Escape hatch for the JSON Schema generator: when set, this object is + * emitted verbatim under the setting's properties entry instead of the + * shape derived from `type`/`properties`/etc. The `description` is still + * carried forward from the SettingDefinition. + * + * Use sparingly — for most settings the generator's normal mapping is + * preferable so the source schema stays the single source of truth. The + * one valid case so far is settings whose accepted runtime shape is a + * union (e.g. string | { path } | { small, large }) that the + * SettingDefinition `type` field cannot express. + */ + jsonSchemaOverride?: Record; } /** @@ -105,6 +118,20 @@ export interface SettingsSchema { [key: string]: SettingDefinition; } +/** + * Source for a single tier of custom ASCII art. Either an inline string + * or a reference to a file on disk that contains the art. + */ +export type AsciiArtSource = string | { path: string }; + +/** + * Setting value for `ui.customAsciiArt`. Accepts a bare source (treated as + * both width tiers), or a width-aware `{small, large}` object. + */ +export type CustomAsciiArtSetting = + | AsciiArtSource + | { small?: AsciiArtSource; large?: AsciiArtSource }; + /** * Common items schema for hook definitions. * Used by all hook event types in the hooks configuration. @@ -728,6 +755,97 @@ const SETTINGS_SCHEMA = { 'Max number of shell output lines shown inline. Set to 0 to disable the cap and show full output. The hidden line count is still surfaced via the `+N lines` indicator.', showInDialog: true, }, + hideBanner: { + type: 'boolean', + label: 'Hide Banner', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Hide the startup ASCII banner and info panel.', + showInDialog: true, + }, + customBannerTitle: { + type: 'string', + label: 'Custom Banner Title', + category: 'UI', + requiresRestart: false, + default: '' as string, + description: + 'Replace the default ">_ Qwen Code" title shown in the banner info panel. The version suffix is always appended.', + showInDialog: false, + }, + customBannerSubtitle: { + type: 'string', + label: 'Custom Banner Subtitle', + category: 'UI', + requiresRestart: false, + default: '' as string, + description: + 'Optional subtitle line rendered between the banner title and the auth/model line. When unset, the info panel keeps its blank spacer row.', + showInDialog: false, + }, + customAsciiArt: { + type: 'object', + label: 'Custom ASCII Art', + category: 'UI', + requiresRestart: false, + default: undefined as CustomAsciiArtSetting | undefined, + description: + 'Replace the default QWEN ASCII art. Accepts an inline string, {"path": "..."}, or {"small": ..., "large": ...} for width-aware selection.', + showInDialog: false, + // The runtime accepts three shapes (inline string, {path}, or + // {small,large} where each tier is itself string-or-{path}). The + // SettingDefinition `type: 'object'` keeps the in-app dialog out of + // the way (we don't want a multi-line ASCII editor in the TUI), but + // the JSON Schema needs a real union so VS Code stops flagging the + // documented bare-string form. + // The `oneOf` here uses three *mutually exclusive* branches rather + // than one permissive object branch, so VS Code rejects nonsense + // like `{ path, small, large }` (which the runtime would also + // reject — see `normalizeTiers` in `customBanner.ts`). + jsonSchemaOverride: { + oneOf: [ + { type: 'string' }, + // Bare `{path}` — no tier keys allowed. + { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + additionalProperties: false, + }, + // Width-aware `{small?, large?}` — `path` not allowed at this + // level; each tier is itself string-or-`{path}`. + { + type: 'object', + properties: { + small: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + additionalProperties: false, + }, + ], + }, + large: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + additionalProperties: false, + }, + ], + }, + }, + additionalProperties: false, + }, + ], + }, + }, }, }, diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 447204a559..392d9f74b0 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -17,14 +17,32 @@ import type { LoadedSettings } from '../../config/settings.js'; vi.mock('../hooks/useTerminalSize.js'); const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize); -const createSettings = (options?: { hideTips?: boolean }): LoadedSettings => - ({ - merged: { - ui: { - hideTips: options?.hideTips ?? true, - }, +const createSettings = (options?: { + hideTips?: boolean; + hideBanner?: boolean; + customBannerTitle?: string; + customBannerSubtitle?: string; + customAsciiArt?: unknown; +}): LoadedSettings => { + const ui = { + hideTips: options?.hideTips ?? true, + hideBanner: options?.hideBanner, + customBannerTitle: options?.customBannerTitle, + customBannerSubtitle: options?.customBannerSubtitle, + customAsciiArt: options?.customAsciiArt, + }; + return { + merged: { ui }, + system: { settings: {}, originalSettings: {}, path: '' }, + systemDefaults: { settings: {}, originalSettings: {}, path: '' }, + user: { + settings: { ui }, + originalSettings: { ui }, + path: '/home/u/.qwen/settings.json', }, - }) as never; + workspace: { settings: {}, originalSettings: {}, path: '' }, + } as never; +}; const createMockConfig = (overrides = {}) => ({ getContentGeneratorConfig: vi.fn(() => ({ authType: undefined })), @@ -91,4 +109,45 @@ describe('', () => { expect(lastFrame()).toContain('gemini-pro'); expect(lastFrame()).toContain('/projects/qwen-code'); }); + + it('hides the banner when ui.hideBanner is set, but keeps tips intact', () => { + const { lastFrame } = renderWithProviders( + createMockUIState(), + createSettings({ hideTips: false, hideBanner: true }), + ); + expect(lastFrame()).not.toContain('>_ Qwen Code'); + expect(lastFrame()).not.toContain('██╔═══██╗'); + }); + + it('renders the custom subtitle end-to-end through resolveCustomBanner (replaces the blank spacer between title and auth line)', () => { + const { lastFrame } = renderWithProviders( + createMockUIState(), + createSettings({ + customBannerTitle: 'DataWorks DataAgent', + customBannerSubtitle: 'Built-in DataWorks Official Skills', + }), + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('DataWorks DataAgent'); + expect(frame).toContain('Built-in DataWorks Official Skills'); + const titleIdx = frame.indexOf('DataWorks DataAgent'); + const subtitleIdx = frame.indexOf('Built-in DataWorks Official Skills'); + expect(titleIdx).toBeLessThan(subtitleIdx); + }); + + it('renders custom banner title and inline ASCII art end-to-end through resolveCustomBanner', () => { + const { lastFrame } = renderWithProviders( + createMockUIState(), + createSettings({ + customBannerTitle: 'Acme CLI', + customAsciiArt: ' ACME\n ----', + }), + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Acme CLI'); + expect(frame).not.toContain('>_ Qwen Code'); + expect(frame).toContain('ACME'); + // Default Qwen logo must NOT bleed through when the user supplied art. + expect(frame).not.toContain('██╔═══██╗'); + }); }); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index fa051e7e01..5f88fd2116 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useMemo } from 'react'; import { Box } from 'ink'; import { AuthType, isCodingPlanConfig } from '@qwen-code/qwen-code-core'; import { Header, AuthDisplayType } from './Header.js'; @@ -11,6 +12,7 @@ import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { resolveCustomBanner } from '../utils/customBanner.js'; interface AppHeaderProps { version: string; @@ -50,7 +52,8 @@ export const AppHeader = ({ version }: AppHeaderProps) => { const authType = contentGeneratorConfig?.authType; const model = uiState.currentModel; const targetDir = config.getTargetDir(); - const showBanner = !config.getScreenReader(); + const showBanner = + !config.getScreenReader() && !settings.merged.ui?.hideBanner; const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader()); const authDisplayType = getAuthDisplayType( @@ -59,6 +62,14 @@ export const AppHeader = ({ version }: AppHeaderProps) => { contentGeneratorConfig?.apiKeyEnvKey, ); + // Resolve once per (settings identity) — file reads and sanitization are + // not free, and the merged settings reference is stable across renders + // until a settings hot-reload swaps it. + const resolvedBanner = useMemo( + () => (showBanner ? resolveCustomBanner(settings) : undefined), + [showBanner, settings], + ); + return ( {showBanner && ( @@ -67,6 +78,9 @@ export const AppHeader = ({ version }: AppHeaderProps) => { authDisplayType={authDisplayType} model={model} workingDirectory={targetDir} + customAsciiArt={resolvedBanner?.asciiArt} + customBannerTitle={resolvedBanner?.title} + customBannerSubtitle={resolvedBanner?.subtitle} /> )} {showTips && } diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 087618654f..a8cafb2adb 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -100,4 +100,91 @@ describe('
', () => { expect(lastFrame()).toContain('██╔═══██╗'); }); + + it('renders the custom subtitle in place of the blank spacer row', () => { + const { lastFrame } = render( +
, + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Built-in DataWorks Official Skills'); + // Subtitle sits between the title and the auth line. + const titleIdx = frame.indexOf('>_ Qwen Code'); + const subtitleIdx = frame.indexOf('Built-in DataWorks Official Skills'); + const authIdx = frame.indexOf('Qwen OAuth'); + expect(titleIdx).toBeLessThan(subtitleIdx); + expect(subtitleIdx).toBeLessThan(authIdx); + }); + + it('keeps the blank spacer row when no subtitle is set (back-compat)', () => { + const { lastFrame } = render(
); + const frame = lastFrame() ?? ''; + // Title and auth still both render at their usual positions; the + // spacer between them is just whitespace-padding, so we assert the + // visible chrome the user sees. + expect(frame).toContain('>_ Qwen Code'); + expect(frame).toContain('Qwen OAuth'); + }); + + it('renders the custom banner title in place of the default brand', () => { + const { lastFrame } = render( +
, + ); + expect(lastFrame()).toContain('Acme CLI'); + expect(lastFrame()).not.toContain('>_ Qwen Code'); + // version suffix is still appended + expect(lastFrame()).toContain('v1.0.0'); + }); + + it('renders the custom large tier when it fits', () => { + const { lastFrame } = render( +
, + ); + expect(lastFrame()).toContain('LARGE_LOGO'); + expect(lastFrame()).not.toContain('██╔═══██╗'); + }); + + it('falls back to the small tier when the large one does not fit', () => { + useTerminalSizeMock.mockReturnValue({ columns: 70, rows: 24 }); + const { lastFrame } = render( +
, + ); + expect(lastFrame()).toContain('sm'); + expect(lastFrame()).not.toContain('X'.repeat(60)); + }); + + it('hides the logo column when neither custom tier fits — does NOT fall back to the default Qwen logo (preserves white-label intent)', () => { + const { lastFrame } = render( +
, + ); + expect(lastFrame()).not.toContain('██╔═══██╗'); + expect(lastFrame()).not.toContain('X'.repeat(150)); + expect(lastFrame()).not.toContain('Y'.repeat(150)); + // Info panel still renders. + expect(lastFrame()).toContain('Qwen OAuth'); + }); + + it('falls back to the default Qwen logo when no custom art was provided at all', () => { + useTerminalSizeMock.mockReturnValue({ columns: 60, rows: 24 }); + const { lastFrame } = render(
); + // With no customAsciiArt, narrow widths still hide the QWEN logo, but a + // wide enough terminal would show it — the previous test already covers + // the wide case. This one just confirms the no-custom-art path doesn't + // incidentally hide the logo. + expect(lastFrame()).toContain('>_ Qwen Code'); + }); }); diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 345bb9fe4b..14f374764f 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -13,6 +13,7 @@ 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. @@ -26,7 +27,26 @@ export enum AuthDisplayType { } interface HeaderProps { - customAsciiArt?: string; // For user-defined ASCII art + /** + * 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; @@ -35,6 +55,8 @@ interface HeaderProps { export const Header: React.FC = ({ customAsciiArt, + customBannerTitle, + customBannerSubtitle, version, authDisplayType, model, @@ -42,8 +64,6 @@ export const Header: React.FC = ({ }) => { const { columns: terminalWidth } = useTerminalSize(); - const displayLogo = customAsciiArt ?? shortAsciiLogo; - const logoWidth = getAsciiArtWidth(displayLogo); const formattedAuthType = authDisplayType ?? AuthDisplayType.UNKNOWN; // Calculate available space properly: @@ -61,8 +81,30 @@ export const Header: React.FC = ({ terminalWidth - containerMarginX * 2, ); - // Check if we have enough space for logo + gap + minimum info panel + // 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) @@ -138,15 +180,22 @@ export const Header: React.FC = ({ flexGrow={showLogo ? 0 : 1} width={showLogo ? availableInfoPanelWidth : undefined} > - {/* Title line: >_ Qwen Code (v{version}) */} + {/* Title line: customBannerTitle (already sanitized) or the default + ">_ Qwen Code" brand. Version suffix is always appended. */} - >_ Qwen Code + {customBannerTitle ? customBannerTitle : '>_ Qwen Code'} (v{version}) - {/* Empty line for spacing */} - + {/* 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 ? ( + {customBannerSubtitle} + ) : ( + + )} {/* Auth and Model line */} {authModelText} diff --git a/packages/cli/src/ui/utils/customBanner.test.ts b/packages/cli/src/ui/utils/customBanner.test.ts new file mode 100644 index 0000000000..0e11727edc --- /dev/null +++ b/packages/cli/src/ui/utils/customBanner.test.ts @@ -0,0 +1,583 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { pickAsciiArtTier, resolveCustomBanner } from './customBanner.js'; +import type { LoadedSettings, SettingsFile } from '../../config/settings.js'; +import type { + CustomAsciiArtSetting, + Settings, +} from '../../config/settingsSchema.js'; + +function makeSettings(opts: { + workspaceUi?: Settings['ui']; + workspacePath?: string; + userUi?: Settings['ui']; + userPath?: string; + systemUi?: Settings['ui']; + systemPath?: string; + isTrusted?: boolean; +}): LoadedSettings { + const file = (settings: Settings, p: string): SettingsFile => ({ + settings, + originalSettings: settings, + path: p, + }); + const empty: SettingsFile = { + settings: {}, + originalSettings: {}, + path: '', + }; + const merged: Settings = { + ui: { + ...(opts.userUi ?? {}), + ...(opts.workspaceUi ?? {}), + ...(opts.systemUi ?? {}), + }, + }; + return { + system: opts.systemUi + ? file({ ui: opts.systemUi }, opts.systemPath ?? '/sys/settings.json') + : empty, + systemDefaults: empty, + user: opts.userUi + ? file( + { ui: opts.userUi }, + opts.userPath ?? '/home/u/.qwen/settings.json', + ) + : empty, + workspace: opts.workspaceUi + ? file( + { ui: opts.workspaceUi }, + opts.workspacePath ?? '/repo/.qwen/settings.json', + ) + : empty, + isTrusted: opts.isTrusted ?? true, + migratedInMemorScopes: new Set(), + migrationWarnings: [], + merged, + } as unknown as LoadedSettings; +} + +describe('resolveCustomBanner', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-banner-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns empty banner when nothing is configured', () => { + const out = resolveCustomBanner(makeSettings({})); + expect(out.asciiArt.small).toBeUndefined(); + expect(out.asciiArt.large).toBeUndefined(); + expect(out.title).toBeUndefined(); + }); + + it('accepts an inline string and uses it for both tiers', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customAsciiArt: ' ACME\n ----' }, + }), + ); + expect(out.asciiArt.small).toBe(' ACME\n ----'); + expect(out.asciiArt.large).toBe(' ACME\n ----'); + }); + + it('accepts a {path} object and reads from disk', () => { + const file = path.join(tmpDir, 'brand.txt'); + fs.writeFileSync(file, 'WIDE\nLOGO\n'); + const out = resolveCustomBanner( + makeSettings({ + workspaceUi: { + customAsciiArt: { path: 'brand.txt' } as CustomAsciiArtSetting, + }, + workspacePath: path.join(tmpDir, 'settings.json'), + }), + ); + expect(out.asciiArt.small).toBe('WIDE\nLOGO'); + expect(out.asciiArt.large).toBe('WIDE\nLOGO'); + }); + + it('resolves relative paths against the owning settings directory', () => { + const file = path.join(tmpDir, 'art.txt'); + fs.writeFileSync(file, 'X\nY'); + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { path: './art.txt' } as CustomAsciiArtSetting, + }, + userPath: path.join(tmpDir, 'settings.json'), + }), + ); + expect(out.asciiArt.small).toBe('X\nY'); + }); + + it('accepts width-aware {small, large} tiers', () => { + const out = resolveCustomBanner( + makeSettings({ + workspaceUi: { + customAsciiArt: { + small: 'small', + large: 'large', + } as CustomAsciiArtSetting, + }, + }), + ); + expect(out.asciiArt.small).toBe('small'); + expect(out.asciiArt.large).toBe('large'); + }); + + it('omits a tier when only one is provided', () => { + const out = resolveCustomBanner( + makeSettings({ + workspaceUi: { + customAsciiArt: { large: 'big' } as CustomAsciiArtSetting, + }, + }), + ); + expect(out.asciiArt.small).toBeUndefined(); + expect(out.asciiArt.large).toBe('big'); + }); + + it('strips ANSI escape sequences from inline art', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customAsciiArt: '\x1b[31mhostile\x1b[0m\nART' }, + }), + ); + expect(out.asciiArt.small).not.toContain('\x1b'); + expect(out.asciiArt.small).toContain('hostile'); + expect(out.asciiArt.small).toContain('ART'); + }); + + it('strips C1 control characters (0x80-0x9f) including single-byte CSI', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customAsciiArt: 'A\x9b31mB\x9c\x90X' }, + }), + ); + // 0x9b (single-byte CSI), 0x9c (ST), 0x90 (DCS) are all C1 — must be + // replaced with space, not interpreted by the terminal. + expect(out.asciiArt.small).not.toMatch(/[\x80-\x9f]/); + expect(out.asciiArt.small).toContain('A'); + expect(out.asciiArt.small).toContain('B'); + expect(out.asciiArt.small).toContain('X'); + }); + + it('strips OSC-8 hyperlinks from inline art', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: '\x1b]8;;https://evil\x07click\x1b]8;;\x07', + }, + }), + ); + expect(out.asciiArt.small).not.toContain('\x1b'); + expect(out.asciiArt.small).toContain('click'); + }); + + it('preserves newlines so multi-line art survives sanitization', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customAsciiArt: 'line1\nline2\nline3' }, + }), + ); + expect(out.asciiArt.small?.split('\n')).toEqual([ + 'line1', + 'line2', + 'line3', + ]); + }); + + it('caps art at 200 lines × 200 cols', () => { + const tooManyLines = Array.from({ length: 250 }, () => 'x').join('\n'); + const out1 = resolveCustomBanner( + makeSettings({ userUi: { customAsciiArt: tooManyLines } }), + ); + expect(out1.asciiArt.small?.split('\n').length).toBe(200); + + const tooWide = 'a'.repeat(300); + const out2 = resolveCustomBanner( + makeSettings({ userUi: { customAsciiArt: tooWide } }), + ); + expect(out2.asciiArt.small?.length).toBe(200); + }); + + it('reads from an absolute path verbatim', () => { + const file = path.join(tmpDir, 'absolute.txt'); + fs.writeFileSync(file, 'ABS\nART'); + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { path: file } as CustomAsciiArtSetting, + }, + userPath: '/some/other/dir/settings.json', + }), + ); + expect(out.asciiArt.small).toBe('ABS\nART'); + }); + + it('refuses to open a FIFO at the configured path (POSIX) — must not hang startup', () => { + if (process.platform === 'win32') return; + const fifoPath = path.join(tmpDir, 'pipe.fifo'); + // mkfifo via child_process keeps this test self-contained without + // pulling in a native dep. If `mkfifo` isn't available (very rare on + // POSIX dev boxes) we skip the assertion rather than fail the suite. + try { + execFileSync('mkfifo', [fifoPath]); + } catch { + return; + } + const start = Date.now(); + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { path: 'pipe.fifo' } as CustomAsciiArtSetting, + }, + userPath: path.join(tmpDir, 'settings.json'), + }), + ); + const elapsedMs = Date.now() - start; + // The pre-open lstat() check should reject the FIFO instantly. If the + // resolver ever regresses to opening a FIFO read-only, this assertion + // will catch it because the open would block until a writer connects + // (we never start one) and the test would hang well past 1s. + expect(elapsedMs).toBeLessThan(1000); + expect(out.asciiArt.small).toBeUndefined(); + expect(out.asciiArt.large).toBeUndefined(); + }); + + it('refuses to follow a symlink at the configured path on POSIX', () => { + if (process.platform === 'win32') return; + const real = path.join(tmpDir, 'real.txt'); + fs.writeFileSync(real, 'REAL\nART'); + const link = path.join(tmpDir, 'link.txt'); + fs.symlinkSync(real, link); + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { path: 'link.txt' } as CustomAsciiArtSetting, + }, + userPath: path.join(tmpDir, 'settings.json'), + }), + ); + // O_NOFOLLOW makes openSync throw ELOOP — resolver soft-fails, so the + // tier ends up undefined rather than reading through the symlink. + expect(out.asciiArt.small).toBeUndefined(); + }); + + it('falls back when the {path} target is missing', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { path: 'missing.txt' } as CustomAsciiArtSetting, + }, + userPath: path.join(tmpDir, 'settings.json'), + }), + ); + expect(out.asciiArt.small).toBeUndefined(); + expect(out.asciiArt.large).toBeUndefined(); + }); + + it('truncates oversize files at 64KB', () => { + const file = path.join(tmpDir, 'huge.txt'); + fs.writeFileSync(file, 'a'.repeat(65 * 1024)); + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { path: 'huge.txt' } as CustomAsciiArtSetting, + }, + userPath: path.join(tmpDir, 'settings.json'), + }), + ); + // Capped at 200 cols regardless; mostly we're asserting "doesn't blow up". + expect(out.asciiArt.small?.length).toBe(200); + }); + + it('rejects {path} mixed with tier keys (mutually exclusive object branches)', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { + path: 'p.txt', + small: 'should-be-rejected', + } as unknown as CustomAsciiArtSetting, + }, + userPath: '/home/u/.qwen/settings.json', + }), + ); + // The resolver must NOT silently let `path` win — both forms are + // dropped and we fall through to the default art. + expect(out.asciiArt.small).toBeUndefined(); + expect(out.asciiArt.large).toBeUndefined(); + }); + + it('caps art width by terminal cells, not UTF-16 length (CJK fullwidth)', () => { + // 200 fullwidth CJK characters render at ~400 cells; before the fix + // the .length cap let them through. The cap is now visual width, so + // ~100 fullwidth chars max per line. + const fullwidth = '一'.repeat(150); // 150 chars × 2 cells = 300 cells + const out = resolveCustomBanner( + makeSettings({ userUi: { customAsciiArt: fullwidth } }), + ); + const small = out.asciiArt.small ?? ''; + // Truncated by visual width: each fullwidth char is 2 cells, so + // ≤ 100 chars fit under MAX_ART_COLS=200. + expect(small.length).toBeLessThanOrEqual(100); + // And it's a valid string of fullwidth chars (no surrogate / split + // mid-codepoint — every char is a full BMP code point here). + expect(/^一+$/.test(small)).toBe(true); + }); + + it('falls back when a {path} entry lives in a scope with no associated file path', () => { + // `userPath: ''` simulates a path-less scope (e.g. systemDefaults). + // A {path} tier in such a scope can't resolve relative paths, so we + // soft-fail it specifically instead of silently dropping the whole + // scope (which would block inline tiers from path-less scopes too). + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { path: 'art.txt' } as CustomAsciiArtSetting, + }, + userPath: '', + }), + ); + expect(out.asciiArt.small).toBeUndefined(); + expect(out.asciiArt.large).toBeUndefined(); + }); + + it('still accepts inline string art from a scope with no file path (decoupled from {path} resolution)', () => { + // The flip side of the previous test: inline strings don't need an + // owning settings directory, so a path-less scope must still + // contribute. Before the decoupling, the whole scope was dropped on + // `!file.path`, so even inline art would silently disappear. + const out = resolveCustomBanner( + makeSettings({ + userUi: { customAsciiArt: 'INLINE-FROM-PATHLESS-SCOPE' }, + userPath: '', + }), + ); + expect(out.asciiArt.small).toBe('INLINE-FROM-PATHLESS-SCOPE'); + expect(out.asciiArt.large).toBe('INLINE-FROM-PATHLESS-SCOPE'); + }); + + it('rejects a malformed customAsciiArt and falls back to default', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { path: 42 } as unknown as CustomAsciiArtSetting, + }, + }), + ); + expect(out.asciiArt.small).toBeUndefined(); + expect(out.asciiArt.large).toBeUndefined(); + }); + + it('treats whitespace-only inline art as empty', () => { + const out = resolveCustomBanner( + makeSettings({ userUi: { customAsciiArt: ' \n ' } }), + ); + expect(out.asciiArt.small).toBeUndefined(); + }); + + it('ignores untrusted workspace settings — does not honor an untrusted checkout (no inline render, no file read)', () => { + const file = path.join(tmpDir, 'evil.txt'); + fs.writeFileSync(file, 'EVIL'); + const out = resolveCustomBanner( + makeSettings({ + isTrusted: false, + // Workspace tries to provide both an inline string AND a {path} + // tier — both must be ignored when the workspace is untrusted. + workspaceUi: { + customAsciiArt: { + small: 'WORKSPACE-INLINE', + large: { path: 'evil.txt' }, + } as CustomAsciiArtSetting, + }, + workspacePath: path.join(tmpDir, 'settings.json'), + // User scope still contributes; if it didn't, we couldn't tell + // "untrusted dropped the workspace" apart from "resolver broke". + userUi: { customAsciiArt: 'USER-FALLBACK' }, + }), + ); + expect(out.asciiArt.small).toBe('USER-FALLBACK'); + expect(out.asciiArt.large).toBe('USER-FALLBACK'); + expect(out.asciiArt.small).not.toContain('WORKSPACE'); + expect(out.asciiArt.small).not.toContain('EVIL'); + }); + + it('honors workspace settings when isTrusted is true (sanity check on the trust gate)', () => { + const out = resolveCustomBanner( + makeSettings({ + isTrusted: true, + workspaceUi: { customAsciiArt: 'WORKSPACE-TRUSTED' }, + userUi: { customAsciiArt: 'USER' }, + }), + ); + expect(out.asciiArt.small).toBe('WORKSPACE-TRUSTED'); + }); + + it('uses workspace value when both user and workspace provide art', () => { + const out = resolveCustomBanner( + makeSettings({ + workspaceUi: { customAsciiArt: 'WORKSPACE' }, + userUi: { customAsciiArt: 'USER' }, + }), + ); + expect(out.asciiArt.small).toBe('WORKSPACE'); + }); + + it('combines tiers across scopes via deep-merge (workspace.large + user.small)', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { small: 'user-small' } as CustomAsciiArtSetting, + }, + workspaceUi: { + customAsciiArt: { large: 'workspace-large' } as CustomAsciiArtSetting, + }, + }), + ); + expect(out.asciiArt.small).toBe('user-small'); + expect(out.asciiArt.large).toBe('workspace-large'); + }); + + it('sanitizes the title and trims whitespace', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customBannerTitle: ' \x1b[31mAcme CLI\x1b[0m ' }, + }), + ); + expect(out.title).toBe('Acme CLI'); + }); + + it('strips C1 control characters from the title', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customBannerTitle: 'Acme\x9b31m CLI\x9c' }, + }), + ); + expect(out.title).not.toMatch(/[\x80-\x9f]/); + expect(out.title).toContain('Acme'); + expect(out.title).toContain('CLI'); + }); + + it('caps the title at 80 characters', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customBannerTitle: 'x'.repeat(200) }, + }), + ); + expect(out.title?.length).toBe(80); + }); + + it('treats empty title as undefined (falls back to default)', () => { + const out = resolveCustomBanner( + makeSettings({ userUi: { customBannerTitle: ' ' } }), + ); + expect(out.title).toBeUndefined(); + }); + + it('strips newlines from titles so the info panel layout is preserved', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customBannerTitle: 'Line1\nLine2' }, + }), + ); + expect(out.title).toBe('Line1 Line2'); + }); + + it('returns subtitle undefined when nothing is configured', () => { + const out = resolveCustomBanner(makeSettings({})); + expect(out.subtitle).toBeUndefined(); + }); + + it('sanitizes the subtitle and trims whitespace', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customBannerSubtitle: ' \x1b[31mPowered by something\x1b[0m ', + }, + }), + ); + expect(out.subtitle).toBe('Powered by something'); + }); + + it('caps the subtitle at 160 characters (looser than title for taglines)', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customBannerSubtitle: 'x'.repeat(400) }, + }), + ); + expect(out.subtitle?.length).toBe(160); + }); + + it('treats empty subtitle as undefined (header keeps the blank spacer row)', () => { + const out = resolveCustomBanner( + makeSettings({ userUi: { customBannerSubtitle: ' ' } }), + ); + expect(out.subtitle).toBeUndefined(); + }); + + it('strips newlines and C1 controls from the subtitle', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customBannerSubtitle: 'Line1\nLine2\x9b31m end' }, + }), + ); + // Newline folds to a single space; the 0x9b (single-byte CSI) is + // replaced with a space; the literal "31m" parameter chars survive + // as plain text (they were never going to be interpreted without the + // leading control byte) — exactly the same shape the title sanitizer + // produces for the equivalent input. + expect(out.subtitle).not.toMatch(/[\x80-\x9f]/); + expect(out.subtitle).not.toContain('\n'); + expect(out.subtitle).toContain('Line1 Line2'); + expect(out.subtitle).toContain('end'); + }); +}); + +describe('pickAsciiArtTier', () => { + const measure = (s: string) => s.length; + + it('prefers large when it fits', () => { + expect(pickAsciiArtTier('small', 'BIGGER', 100, 2, 40, measure)).toBe( + 'BIGGER', + ); + }); + + it('falls back to small when large is too wide', () => { + expect(pickAsciiArtTier('sml', 'a'.repeat(200), 60, 2, 40, measure)).toBe( + 'sml', + ); + }); + + it('returns undefined when neither tier fits', () => { + expect( + pickAsciiArtTier('a'.repeat(80), 'a'.repeat(120), 50, 2, 40, measure), + ).toBeUndefined(); + }); + + it('skips missing tiers', () => { + expect(pickAsciiArtTier(undefined, 'fits', 100, 2, 40, measure)).toBe( + 'fits', + ); + expect(pickAsciiArtTier('fits', undefined, 100, 2, 40, measure)).toBe( + 'fits', + ); + expect( + pickAsciiArtTier(undefined, undefined, 100, 2, 40, measure), + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/ui/utils/customBanner.ts b/packages/cli/src/ui/utils/customBanner.ts new file mode 100644 index 0000000000..163439a343 --- /dev/null +++ b/packages/cli/src/ui/utils/customBanner.ts @@ -0,0 +1,470 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + createDebugLogger, + stripTerminalControlSequences, + TERMINAL_OSC_REGEX, + TERMINAL_CSI_REGEX, + TERMINAL_SHIFT_DCS_REGEX, +} from '@qwen-code/qwen-code-core'; +import type { LoadedSettings, SettingsFile } from '../../config/settings.js'; +import type { + AsciiArtSource, + CustomAsciiArtSetting, +} from '../../config/settingsSchema.js'; +import { getCachedStringWidth, toCodePoints } from './textUtils.js'; + +const debugLogger = createDebugLogger('BANNER'); + +/** Hard cap on the size of an ASCII-art file the resolver will read. */ +const MAX_FILE_BYTES = 64 * 1024; +/** Hard cap on the number of lines kept after sanitization. */ +const MAX_ART_LINES = 200; +/** Hard cap on the visual width (columns) kept per line after sanitization. */ +const MAX_ART_COLS = 200; +/** Hard cap on title length after sanitization. */ +const MAX_TITLE_LENGTH = 80; +/** + * Hard cap on subtitle length after sanitization. Larger than the title cap + * because the subtitle commonly carries a tagline / "powered by" line that + * runs longer than the brand name itself; still bounded so a single + * pasted paragraph can't blow out the info panel. + */ +const MAX_SUBTITLE_LENGTH = 160; + +export interface ResolvedBanner { + asciiArt: { small?: string; large?: string }; + title?: string; + /** + * Optional subtitle rendered between the title and the auth/model line. + * Sanitized like the title (control sequences stripped, newlines folded + * to spaces). When undefined, `
` keeps the existing blank + * spacer row for back-compat. + */ + subtitle?: string; +} + +/** + * Per-resolver-call memo so the same source isn't read or sanitized twice + * when the user sets `customAsciiArt` to a single value (which becomes both + * the small and large tier). + */ +type CacheEntry = { value: string | undefined }; + +/** + * Resolve the user's banner customization into the shape `
` + * expects. Soft-fails on every error path: any malformed input, missing + * file, oversized file, or sanitization rejection logs a `[BANNER]` warn + * and falls back to the locked default for that field. The CLI must never + * crash on a banner config error. + */ +export function resolveCustomBanner(settings: LoadedSettings): ResolvedBanner { + const ui = settings.merged.ui; + const cache = new Map(); + + const title = sanitizeTitle(ui?.customBannerTitle); + const subtitle = sanitizeSubtitle(ui?.customBannerSubtitle); + + // Tiers are resolved per-scope so each `{path}` resolves against the file + // it was declared in — not the merged view, which would hide which scope + // contributed the inner `small` / `large` keys after deep-merge. + const scoped = collectScopedTiers(settings); + + return { + asciiArt: { + small: + scoped.small && + resolveTier(scoped.small.source, scoped.small.dir, cache), + large: + scoped.large && + resolveTier(scoped.large.source, scoped.large.dir, cache), + }, + title, + subtitle, + }; +} + +interface ScopedSource { + source: AsciiArtSource; + dir: string; +} + +/** + * Walk settings scopes in merge-precedence order (highest first) and pick, + * for each tier, the first scope that defines it. Each tier carries its + * scope's directory so relative `{path}` entries resolve against the file + * that declared them. + * + * Workspace settings are skipped entirely when `settings.isTrusted` is + * false. The standard `settings.merged` view already drops untrusted + * workspace data; this resolver bypasses that view (it needs per-scope + * file paths to resolve relative `{path}` entries), so the trust check + * has to be re-applied here. Without it, an untrusted checkout could + * influence startup rendering and trigger local file reads through a + * `{path}` entry before the user has opted in. + */ +function collectScopedTiers(settings: LoadedSettings): { + small?: ScopedSource; + large?: ScopedSource; +} { + const order: SettingsFile[] = [ + settings.system, + ...(settings.isTrusted ? [settings.workspace] : []), + settings.user, + settings.systemDefaults, + ]; + let small: ScopedSource | undefined; + let large: ScopedSource | undefined; + for (const file of order) { + if (small && large) break; + const raw = file.settings.ui?.customAsciiArt; + if (raw === undefined || raw === null) continue; + const tiers = normalizeTiers(raw); + if (!tiers) continue; + // `dir` is only meaningful for `{path}` entries (relative paths + // resolve against the file that declared them). Inline-string tiers + // don't need it, so a scope with no associated file path (e.g. + // `systemDefaults`, future SDK-injected scopes) can still contribute + // string art. When a `{path}` lands in a path-less scope we soft-fail + // that tier specifically and log a `[BANNER]` warn — dropping the + // entire scope was unnecessary coupling. + const dir = file.path ? path.dirname(file.path) : ''; + const considerTier = ( + tier: AsciiArtSource | undefined, + label: 'small' | 'large', + ): ScopedSource | undefined => { + if (tier === undefined) return undefined; + const isPathSource = typeof tier === 'object'; + if (isPathSource && !dir) { + debugLogger.warn( + `Ignoring ui.customAsciiArt.${label}: {path} entry has no owning settings file directory to resolve against.`, + ); + return undefined; + } + return { source: tier, dir }; + }; + if (!small) { + const next = considerTier(tiers.small, 'small'); + if (next) small = next; + } + if (!large) { + const next = considerTier(tiers.large, 'large'); + if (next) large = next; + } + } + return { small, large }; +} + +interface NormalizedTiers { + small?: AsciiArtSource; + large?: AsciiArtSource; +} + +function normalizeTiers( + value: CustomAsciiArtSetting, +): NormalizedTiers | undefined { + if (typeof value === 'string') { + return { small: value, large: value }; + } + if (!value || typeof value !== 'object') { + debugLogger.warn( + 'Ignoring ui.customAsciiArt: expected a string, {path}, or {small,large} object.', + ); + return undefined; + } + + // Mirror the JSON schema's mutually-exclusive object branches: an object + // with `path` cannot also carry `small` / `large`, and vice versa. The + // schema rejects this shape in VS Code; without the same check at + // runtime, JSON parsed at startup would silently let `path` win and + // drop the tier keys (or vice versa). + const hasPath = 'path' in value && typeof value.path === 'string'; + const hasTierKeys = 'small' in value || 'large' in value; + if (hasPath && hasTierKeys) { + debugLogger.warn( + 'Ignoring ui.customAsciiArt: object combines `path` with `small` / `large`. Use one shape or the other.', + ); + return undefined; + } + + if (hasPath) { + return { small: value, large: value }; + } + + if (hasTierKeys) { + const tiered = value as { + small?: unknown; + large?: unknown; + }; + return { + small: validateSource(tiered.small), + large: validateSource(tiered.large), + }; + } + + debugLogger.warn( + 'Ignoring ui.customAsciiArt: expected a string, {path}, or {small,large} object.', + ); + return undefined; +} + +function validateSource(source: unknown): AsciiArtSource | undefined { + if (source === undefined || source === null) return undefined; + if (typeof source === 'string') return source; + if ( + typeof source === 'object' && + 'path' in source && + typeof (source as { path: unknown }).path === 'string' + ) { + return { path: (source as { path: string }).path }; + } + debugLogger.warn( + 'Ignoring ui.customAsciiArt tier: expected a string or {path} object.', + ); + return undefined; +} + +function resolveTier( + source: AsciiArtSource | undefined, + ownerDir: string, + cache: Map, +): string | undefined { + if (source === undefined) return undefined; + + if (typeof source === 'string') { + const trimmed = source.trim(); + if (!trimmed) return undefined; + const key = `inline:${source}`; + return memo(cache, key, () => sanitizeArt(source)); + } + + const resolvedPath = path.isAbsolute(source.path) + ? source.path + : path.resolve(ownerDir, source.path); + + return memo(cache, `path:${resolvedPath}`, () => { + const raw = readArtFile(resolvedPath); + if (raw === undefined) return undefined; + return sanitizeArt(raw); + }); +} + +function memo( + cache: Map, + key: string, + compute: () => string | undefined, +): string | undefined { + const hit = cache.get(key); + if (hit) return hit.value; + const value = compute(); + cache.set(key, { value }); + return value; +} + +function readArtFile(absolutePath: string): string | undefined { + let fd: number | undefined; + try { + // Step 1: refuse non-regular files BEFORE opening. On POSIX, opening a + // FIFO / named pipe read-only blocks until a writer connects — which + // means a misconfigured `customAsciiArt: { "path": "/tmp/some-fifo" }` + // would hang CLI startup forever. `O_NOFOLLOW` does not help here; it + // refuses symlinks at the final path component, not FIFOs / sockets / + // devices. `lstatSync` (rather than `statSync`) also covers the + // "configured path is itself a symlink" case so we soft-fail before + // opening. + let preOpenStat: fs.Stats; + try { + preOpenStat = fs.lstatSync(absolutePath); + } catch (err) { + debugLogger.warn( + `Failed to stat ui.customAsciiArt at ${absolutePath}: ${(err as Error).message}`, + ); + return undefined; + } + if (!preOpenStat.isFile()) { + debugLogger.warn( + `Ignoring ui.customAsciiArt: ${absolutePath} is not a regular file.`, + ); + return undefined; + } + + // Step 2: open with O_NOFOLLOW (POSIX only) so a TOCTOU symlink swap + // between the lstat above and this open also soft-fails. Windows has + // no equivalent constant, so it falls back to a plain read. + const flags = + typeof fs.constants.O_NOFOLLOW === 'number' + ? fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW + : fs.constants.O_RDONLY; + fd = fs.openSync(absolutePath, flags); + // Re-check via fstat on the FD: if anything changed between lstat and + // open, refuse rather than reading whatever the FD now points at. + const stat = fs.fstatSync(fd); + if (!stat.isFile()) { + debugLogger.warn( + `Ignoring ui.customAsciiArt: ${absolutePath} is not a regular file.`, + ); + return undefined; + } + const size = Math.min(stat.size, MAX_FILE_BYTES); + const buffer = Buffer.alloc(size); + fs.readSync(fd, buffer, 0, size, 0); + if (stat.size > MAX_FILE_BYTES) { + debugLogger.warn( + `Truncated ui.customAsciiArt at ${absolutePath}: file is ${stat.size} bytes, capped at ${MAX_FILE_BYTES}.`, + ); + } + return buffer.toString('utf8'); + } catch (err) { + debugLogger.warn( + `Failed to read ui.customAsciiArt at ${absolutePath}: ${(err as Error).message}`, + ); + return undefined; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + // ignore + } + } + } +} + +/** + * Banner-specific sanitizer. Re-uses the OSC / CSI / SS2 / SS3 patterns + * exported from `stripTerminalControlSequences` (in + * `@qwen-code/qwen-code-core`) so the regexes are authored once, but + * preserves `\n` and `\t` — multi-line / tab-aligned ASCII art needs + * those, while the shared core helper strips them. The fallback range + * here matches the core helper's C0/C1/DEL strip but carves out + * `\t` (0x09) and `\n` (0x0a) so they survive into the rendered art. + */ +function sanitizeArt(input: string): string { + // Normalize CRLF / CR to LF so the column cap is computed against the + // same line boundaries the renderer will see. + let s = input.replace(/\r\n?/g, '\n'); + s = s + .replace(TERMINAL_OSC_REGEX, ' ') + .replace(TERMINAL_CSI_REGEX, ' ') + .replace(TERMINAL_SHIFT_DCS_REGEX, ' '); + // Remaining C0 controls + DEL + C1 controls (0x80-0x9f, e.g. single-byte + // CSI 0x9b) → space. Keep \n (0x0a) and \t (0x09) so multi-line ASCII art + // and tab-aligned art survive. + // eslint-disable-next-line no-control-regex + s = s.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ' '); + + const rawLines = s.split('\n'); + const truncatedRows = rawLines.length > MAX_ART_LINES; + const limitedLines = truncatedRows + ? rawLines.slice(0, MAX_ART_LINES) + : rawLines; + + let truncatedCols = false; + const cappedLines = limitedLines.map((line) => { + // Replace tabs with two spaces so the column count is meaningful and + // doesn't expand differently per terminal. + const detabbed = line.replace(/\t/g, ' '); + const trimmed = detabbed.replace(/\s+$/u, ''); + // Cap by *visual* width (terminal cells), not UTF-16 length: 200 CJK + // fullwidth characters render as ~400 cells, and a `.length` slice + // could split a fullwidth code point or surrogate pair down the + // middle. We walk code points until adding the next one would push + // the cell width past the cap. + if (getCachedStringWidth(trimmed) <= MAX_ART_COLS) { + return trimmed; + } + truncatedCols = true; + const codePoints = toCodePoints(trimmed); + let kept = ''; + for (const cp of codePoints) { + if (getCachedStringWidth(kept + cp) > MAX_ART_COLS) break; + kept += cp; + } + return kept; + }); + + // Drop trailing empty lines so width measurement isn't skewed by a + // hanging blank row. + while (cappedLines.length > 0 && cappedLines[cappedLines.length - 1] === '') { + cappedLines.pop(); + } + + if (cappedLines.length === 0) return ''; + + if (truncatedRows) { + debugLogger.warn(`Truncated ui.customAsciiArt to ${MAX_ART_LINES} lines.`); + } + if (truncatedCols) { + debugLogger.warn( + `Truncated ui.customAsciiArt to ${MAX_ART_COLS} columns per line.`, + ); + } + + return cappedLines.join('\n'); +} + +function sanitizeTitle(raw: unknown): string | undefined { + return sanitizeSingleLine(raw, MAX_TITLE_LENGTH, 'ui.customBannerTitle'); +} + +function sanitizeSubtitle(raw: unknown): string | undefined { + return sanitizeSingleLine( + raw, + MAX_SUBTITLE_LENGTH, + 'ui.customBannerSubtitle', + ); +} + +/** + * Shared cleaner for any single-line info-panel string (title, subtitle). + * Delegates the escape-sequence + C0/C1 stripping to the core + * `stripTerminalControlSequences` helper (which already handles `\n` / + * `\t` because single-line fields don't need them), then folds any + * remaining whitespace into a single space and trims the ends. Returns + * `undefined` for empty input so `
` knows to fall back to its + * default rendering. + */ +function sanitizeSingleLine( + raw: unknown, + maxLength: number, + fieldLabel: string, +): string | undefined { + if (typeof raw !== 'string') return undefined; + let t = stripTerminalControlSequences(raw).replace(/\s+/g, ' ').trim(); + if (!t) return undefined; + if (t.length > maxLength) { + debugLogger.warn(`Truncated ${fieldLabel} to ${maxLength} characters.`); + t = t.slice(0, maxLength); + } + return t; +} + +/** + * Shared with `
` so the renderer doesn't reinvent the same width + * arithmetic. Tries `large` first, then `small`; returns the first tier + * that fits in the available width, or `undefined` to signal "hide the + * logo column entirely (fall back to the default Qwen logo or no logo)". + */ +export function pickAsciiArtTier( + small: string | undefined, + large: string | undefined, + availableWidth: number, + logoGap: number, + minInfoPanelWidth: number, + measureWidth: (art: string) => number, +): string | undefined { + for (const candidate of [large, small]) { + if (!candidate) continue; + const w = measureWidth(candidate); + if (availableWidth >= w + logoGap + minInfoPanelWidth) { + return candidate; + } + } + return undefined; +} diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index 6e88c3d387..453a6404bf 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -10,16 +10,20 @@ import { stripVTControlCharacters } from 'node:util'; import stringWidth from 'string-width'; /** - * Calculates the maximum width of a multi-line ASCII art string. + * Calculates the maximum *visual* width (terminal cells) of a multi-line + * ASCII art string. Uses `string-width` semantics via `getCachedStringWidth` + * so CJK fullwidth characters count as 2 cells and emoji are sized + * correctly — `.length` would undercount these and let oversized art slip + * past the width budget that `pickAsciiArtTier` applies. * @param asciiArt The ASCII art string. - * @returns The length of the longest line in the ASCII art. + * @returns The widest line's terminal-cell width. */ export const getAsciiArtWidth = (asciiArt: string): number => { if (!asciiArt) { return 0; } const lines = asciiArt.split('\n'); - return Math.max(...lines.map((line) => line.length)); + return Math.max(...lines.map((line) => getCachedStringWidth(line))); }; /* diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 329cbb6fef..063f4f53e4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -147,7 +147,12 @@ export * from './services/gitWorktreeService.js'; export * from './services/sessionRecap.js'; export * from './services/sessionService.js'; export * from './services/sessionTitle.js'; -export { stripTerminalControlSequences } from './utils/terminalSafe.js'; +export { + stripTerminalControlSequences, + TERMINAL_OSC_REGEX, + TERMINAL_CSI_REGEX, + TERMINAL_SHIFT_DCS_REGEX, +} from './utils/terminalSafe.js'; export * from './services/shellExecutionService.js'; export * from './services/monitorRegistry.js'; export * from './services/backgroundShellRegistry.js'; diff --git a/packages/core/src/utils/terminalSafe.ts b/packages/core/src/utils/terminalSafe.ts index 17951ba51c..be97d4dce0 100644 --- a/packages/core/src/utils/terminalSafe.ts +++ b/packages/core/src/utils/terminalSafe.ts @@ -4,6 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * Regex constants shared with banner customization (`packages/cli/src/ui/ + * utils/customBanner.ts`) so the OSC / CSI / SS2 / SS3 patterns are + * authored once and stay aligned across call sites. Exported via + * `@qwen-code/qwen-code-core` so the CLI sanitizer can re-use them when + * it has to preserve `\n` (which `stripTerminalControlSequences` strips). + */ +/* eslint-disable no-control-regex */ +/** OSC: `ESC ]` followed by any non-BEL/non-ESC bytes terminated by BEL or `ESC \`. */ +export const TERMINAL_OSC_REGEX = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g; +/** CSI: `ESC [` parameters then a final letter (cursor / color / erase family). */ +export const TERMINAL_CSI_REGEX = /\x1b\[[\d;?]*[a-zA-Z]/g; +/** SS2 / SS3 / DCS leader bytes after ESC. */ +export const TERMINAL_SHIFT_DCS_REGEX = /\x1b[NOP]/g; +/* eslint-enable no-control-regex */ + /** * Strip the terminal control sequences from arbitrary text so the result can * safely render in a TTY without painting cursor moves, clearing the screen, @@ -15,29 +31,23 @@ * - CSI sequences (`\x1b[...`) — the common "cursor/color/erase" * family. * - SS2/SS3 / DCS leaders (`\x1b[NOP]`). - * - Any remaining C0/C1 control bytes plus DEL, flattened to a space. This - * backstop means a bare `\x1b` that wasn't part of a recognized sequence - * still can't execute — the terminal only interprets ESC followed by - * specific bytes. + * - Any remaining C0 controls + DEL + C1 controls (`0x80-0x9F`, e.g. + * single-byte CSI `0x9B`, DCS `0x90`, ST `0x9C`), flattened to a space. + * This backstop means a bare `\x1b` that wasn't part of a recognized + * sequence still can't execute — and 8-bit terminals can't interpret + * the C1 codes that some legacy shells still honor. * * Used for LLM-returned text that ends up in the session picker (titles); * without this, a compromised or prompt-injected fast model could paint on * the user's terminal on every render. */ export function stripTerminalControlSequences(s: string): string { - // These regexes deliberately match control characters; the whole point of - // this module is to neutralize them. The no-control-regex rule is - // suppressed per-line rather than file-wide so any future additions still - // opt in explicitly. return ( s + .replace(TERMINAL_OSC_REGEX, ' ') + .replace(TERMINAL_CSI_REGEX, ' ') + .replace(TERMINAL_SHIFT_DCS_REGEX, ' ') // eslint-disable-next-line no-control-regex - .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, ' ') - // eslint-disable-next-line no-control-regex - .replace(/\x1b\[[\d;?]*[a-zA-Z]/g, ' ') - // eslint-disable-next-line no-control-regex - .replace(/\x1b[NOP]/g, ' ') - // eslint-disable-next-line no-control-regex - .replace(/[\x00-\x1f\x7f]/g, ' ') + .replace(/[\x00-\x1f\x7f-\x9f]/g, ' ') ); } diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 885097375e..804e3bbdd4 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -260,6 +260,85 @@ "description": "Max number of shell output lines shown inline. Set to 0 to disable the cap and show full output. The hidden line count is still surfaced via the `+N lines` indicator.", "type": "number", "default": 5 + }, + "hideBanner": { + "description": "Hide the startup ASCII banner and info panel.", + "type": "boolean", + "default": false + }, + "customBannerTitle": { + "description": "Replace the default \">_ Qwen Code\" title shown in the banner info panel. The version suffix is always appended.", + "type": "string", + "default": "" + }, + "customBannerSubtitle": { + "description": "Optional subtitle line rendered between the banner title and the auth/model line. When unset, the info panel keeps its blank spacer row.", + "type": "string", + "default": "" + }, + "customAsciiArt": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "small": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ] + }, + "large": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + ], + "description": "Replace the default QWEN ASCII art. Accepts an inline string, {\"path\": \"...\"}, or {\"small\": ..., \"large\": ...} for width-aware selection." } } }, diff --git a/scripts/generate-settings-schema.ts b/scripts/generate-settings-schema.ts index a8b8f732bd..0494437dfb 100644 --- a/scripts/generate-settings-schema.ts +++ b/scripts/generate-settings-schema.ts @@ -39,6 +39,9 @@ interface JsonSchemaProperty { default?: unknown; additionalProperties?: boolean | JsonSchemaProperty; required?: string[]; + oneOf?: JsonSchemaProperty[]; + anyOf?: JsonSchemaProperty[]; + allOf?: JsonSchemaProperty[]; } function convertItemDefinitionToJsonSchema( @@ -94,6 +97,18 @@ function convertItemDefinitionToJsonSchema( 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) {