feat(cli): customize banner area (logo, title, hide) (#3710)

* docs(design): add banner customization design (#3005)

Document the design for issue #3005 (customize CLI banner area). Covers
the banner region taxonomy and what is replaceable vs. locked, the three
proposed settings (`ui.hideBanner`, `ui.customBannerTitle`,
`ui.customAsciiArt`) and their resolution pipeline, the schema additions
and wiring touch points, five alternative shapes considered, and the
security / failure-handling guards. Mirrored EN + zh-CN under
`docs/design/customize-banner-area/`. No code changes in this commit;
implementation lands in a follow-up PR.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(cli): customize banner area (logo, title, hide)

Adds three opt-in `ui.*` settings that let users replace brand chrome on
startup while keeping the operational lines (version, auth, model, path)
locked: `hideBanner`, `customBannerTitle`, `customAsciiArt` (string,
{path}, or {small,large}).

A new resolver in `packages/cli/src/ui/utils/customBanner.ts` walks the
loaded settings, normalizes each tier per scope (so {path} resolves
against the file that declared it), reads the file with O_NOFOLLOW and a
64 KB cap on POSIX, sanitizes via a banner-specific stripper that drops
OSC/CSI/SS2/SS3 sequences while preserving newlines, and caps art at 200
lines × 200 cols and titles at 80 chars. Every soft failure logs a
`[BANNER]` warn and falls through to the bundled QWEN logo or default
brand title — banner config can never crash the CLI.

`<Header />` now picks the widest custom tier that fits via a shared
`pickAsciiArtTier` helper and falls back to `shortAsciiLogo` otherwise;
`<AppHeader />` extends the existing `showBanner` gate to honor
`hideBanner` alongside the screen-reader fallback.

Tracks #3005 and the design merged in #3671.

* docs(design): apply prettier to banner customization design

Reformats the EN and zh-CN design docs in
`docs/design/customize-banner-area/` to satisfy `npx prettier --check`:
table column alignment and trailing commas in `jsonc` examples. No
content changes — the words, tables, and code blocks all say the same
thing as before.

Carries forward the only actionable feedback from the now-closed
docs-only PR #3671, where the prettier check was the sole change
requested.

* fix(cli): address banner audit findings

Three audit-driven fixes for the banner customization feature:

1. **VSCode JSON schema accepts every documented shape.** The
   `ui.customAsciiArt` entry in
   `packages/vscode-ide-companion/schemas/settings.schema.json` was
   declared as `type: object`, which made VSCode flag the inline-string
   form (`"customAsciiArt": "  ___"`) — a shape the runtime accepts and
   the design doc recommends — as a schema violation. Replaced with a
   `oneOf` covering string, `{path}`, and `{small,large}` (with each
   tier itself string-or-`{path}`).

2. **Narrow terminals no longer leak the QWEN logo over a white-label
   deployment.** When a user supplied custom ASCII art but neither tier
   fit the terminal, `Header.tsx` previously fell back to the bundled
   `shortAsciiLogo` — silently undoing the white-label intent on small
   windows. The fallback now distinguishes "user supplied custom art"
   from "no custom art at all": in the first case the logo column is
   hidden entirely (info panel still renders); in the second case the
   default logo shows as before. Soft-failure paths (missing file,
   sanitization rejection) still fall through to `shortAsciiLogo`.

3. **Sanitizer strips C1 control bytes (0x80-0x9F).** The art and title
   strippers previously stopped at 0x7F, leaving single-byte CSI
   (`0x9B`), DCS (`0x90`), ST (`0x9C`) and other C1 controls intact —
   which legacy 8-bit terminals would still interpret. Aligned the
   ranges with the repo's existing `stripUnsafeCharacters` (in
   `textUtils.ts`) so banner content can't carry interpreted control
   bytes through.

New tests cover: C1 strip in art and title, absolute path reads,
symlink rejection on POSIX, narrow-terminal hide-on-custom-art, and
end-to-end `<AppHeader />` rendering through `resolveCustomBanner`.
The full banner suite is 48 tests (was 42).

* docs(design): clarify cross-scope tier merge and white-label fallback

Two clarifications surfaced by the audit on the implementation PR:

1. The design said `customAsciiArt` follows standard merge precedence,
   but the resolver actually walks scopes per-tier so workspace can
   override only `large` while user keeps `small`. Document that this
   per-tier walk is intentional — both because each `{path}` has to
   resolve against the file that declared it (the merged view loses
   that information) and because it lets users keep a personal default
   tier and override the other one per-workspace.

2. The render-time tier-selection step now distinguishes "user
   supplied custom art but neither tier fits" (hide the logo column
   entirely; falling back to `shortAsciiLogo` would silently undo a
   white-label deployment on narrow terminals) from "user supplied no
   custom art at all" (fall through to `shortAsciiLogo` and let the
   default-logo width gate decide). Step 5's pure soft-failure
   fallback (missing file, sanitization rejection) is unchanged —
   still `shortAsciiLogo`.

Mirrored both edits in the zh-CN translation.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(design): add size budget section to banner customization

Question raised on the implementation PR: "why is the test logo `CCA`
instead of the full `Custom Code Agent` — is there a character limit?"

There is no character-count limit on titles or art. There is a
**width budget** driven by terminal columns, plus an absolute
hard cap (200×200 art, 80-char title) to keep malformed input from
freezing layout. The existing user-facing guide didn't quantify the
budget anywhere, so users were guessing why long inline names didn't
render.

Add a "How wide can the logo be? — the size budget" subsection that
spells out the formula
(`availableLogoWidth = terminalCols − 4 − 2 − 44`), tabulates it at
80 / 100 / 120 / 200 cols, calls out that a 17-char brand like
"Custom Code Agent" can't render as a single ANSI Shadow line on most
terminals (~120 cols of art), and shows the stacked-words
`{ small, large }` recipe — including the `figlet` one-liner that
generates the corresponding `banner-large.txt`.

Mirrored in the zh-CN translation.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(design): add limits-at-a-glance table; switch demo to Custom Agent

The banner-customization design now has the size budget written down,
but the per-cap limits (80-char title, 200×200 art, 64 KB file) were
buried inside the size-budget formula table. Surface them as their own
"Limits at a glance" subsection at the top of the user-configuration
guide so users see the hard caps before they start hand-crafting art.

Also switch the running example from "Custom Code Agent" (17 chars,
~120 cols of ANSI Shadow art on one line — too wide for any common
terminal) to "Custom Agent" (12 chars, two-word stack at ~54 cols ×
12 lines, fits any terminal ≥ 104 cols). The figlet recipe is now a
two-word pipeline so a copy-paste run produces art the size the doc
claims.

Mirrored both changes in the zh-CN translation. The implementation
itself is unchanged.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address PR review + CI Lint failure

Two reviewer findings on PR #3710 (and the Lint job that fails for the
same root cause):

1. **Schema regen now reproduces the committed JSON Schema.** The CI
   Lint step runs `npm run generate:settings-schema` and fails when
   the worktree dirties — my earlier hand-authored `oneOf` got blown
   away because `customAsciiArt` is `type: 'object'` in the source
   schema and the generator had no way to emit a union.

   Add a `jsonSchemaOverride` escape-hatch field on `SettingDefinition`:
   when set, the generator emits the override verbatim (description
   carried forward) instead of the type-driven shape. Set it on
   `customAsciiArt` to express the runtime union (string | {path} |
   {small,large} where each tier is itself string-or-{path}). The
   committed schema is now regenerated from source and CI's
   regenerate-and-diff check passes; two back-to-back regens produce
   identical output.

2. **Untrusted workspace settings no longer influence the banner.**
   `collectScopedTiers()` walked `settings.workspace` directly because
   per-scope file paths are needed to resolve relative `{path}`
   entries — but that bypassed the trust gate that
   `settings.merged` enforces. An untrusted checkout could therefore
   render its own ASCII art and trigger local file reads through a
   `{path}` entry before the user trusts the folder. Skip
   `settings.workspace` entirely when `settings.isTrusted` is false.
   Two regression tests cover the gate (untrusted = workspace
   silenced, falls through to user; trusted = workspace honored).

Test suite for the banner is now 30 resolver tests + the existing
Header / AppHeader / settingsSchema tests = 66 total, all green.

* feat(cli): add ui.customBannerSubtitle for the spacer row

Adds a fourth opt-in setting to the banner customization surface.
The info panel renders four rows (title, subtitle/spacer, status,
path); the second row was a hard-coded single-space spacer up to
now. With this change a fork or white-label deployment can set
`ui.customBannerSubtitle` to a one-line subtitle (e.g. "Built-in
DataWorks Official Skills") and have it render in the secondary
text color in place of the spacer. Empty/unset preserves the
previous blank-spacer layout, so the change is back-compat.

The subtitle is sanitized through the same
`sanitizeSingleLine` helper as the title (now factored out): OSC /
CSI / SS2 / SS3 leaders dropped, every other C0/C1 control byte
replaced with a space, internal whitespace collapsed, ends
trimmed. Capped at 160 characters — looser than the title's 80
because tagline / "powered by" copy commonly runs longer — with
the same `[BANNER]` warn on truncation.

Wiring:

- `settingsSchema.ts` — new `customBannerSubtitle` entry next to
  `customBannerTitle`, `showInDialog: false` (free-form text in
  the TUI dialog isn't worth its own picker).
- `customBanner.ts` — `ResolvedBanner.subtitle` field;
  `resolveCustomBanner` populates it; `sanitizeTitle` and the new
  `sanitizeSubtitle` share the same helper.
- `Header.tsx` — when `customBannerSubtitle` is truthy the spacer
  row renders the string (secondary color, single line) instead
  of `<Text> </Text>`. Auth/model and path still sit at their
  usual positions.
- `AppHeader.tsx` — pipes `resolvedBanner.subtitle` through.
- VSCode JSON schema regenerated from source (idempotent).

Tests: 5 new resolver tests (default, sanitize, length cap,
empty, newline + C1 strip), 2 new Header tests (renders subtitle
between title and auth; spacer preserved when unset), 1 new
AppHeader integration test (end-to-end through resolver). Banner
suite is now 35 + 17 + 6 + 16 = 74 tests, all green.

Design docs (EN + zh-CN) updated: region taxonomy now lists four
B-rows; "Limits at a glance" table grows a subtitle row;
"Customization rules" matrix and "How to modify" section gain a
"Add a brand subtitle" example with a rendered four-row preview.

* docs(design): sweep stale 3-setting references after subtitle add

Self-review found several sections of the banner customization design
doc still framed for the original three settings; bring them in line
with the four-setting reality landed 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 <qwen-coder@alibabacloud.com>

* fix(cli): address banner re-review (FIFO, mutex schema, display width, regex dedupe)

Addresses the five findings on PR #3710 from the latest re-review:

1. **[Critical] FIFO/pipe at `customAsciiArt.path` no longer hangs
   startup.** The resolver was calling `openSync(path, O_NOFOLLOW)`
   *before* the `fstatSync(...).isFile()` check; on POSIX, opening a
   FIFO read-only blocks until a writer connects, and `O_NOFOLLOW`
   doesn't help — it only refuses symlinks at the final path
   component. `readArtFile` now `lstatSync()`s first and refuses
   non-regular files (FIFO / socket / device / symlink) before the
   open, while keeping the post-open `fstatSync` check for TOCTOU
   safety against a swap between the lstat and the open. New
   POSIX-only regression test `mkfifo`s a named pipe and asserts the
   resolver soft-fails inside 1 s; if the open ever regresses to
   blocking, the test will hang past the timeout and the assertion
   will catch it.

2. **[Suggestion] `{path}` and `{small,large}` are now mutually
   exclusive in both schema and runtime.** The `jsonSchemaOverride`
   on `ui.customAsciiArt` is split into three branches (string,
   `{path}`, `{small?, large?}`); none of them allow `path` and tier
   keys to co-exist. `normalizeTiers()` mirrors that — an object
   carrying both kinds of keys is now soft-rejected with a `[BANNER]`
   warn rather than letting `path` silently win and dropping the
   tier values. New regression test pins the runtime side.

3. **[Suggestion] Column cap and tier-fit selection now measure in
   terminal cells.** `getAsciiArtWidth` (in `textUtils.ts`) and the
   `MAX_ART_COLS` cap in `customBanner.ts` were both using UTF-16
   `.length`, so 200 CJK fullwidth characters would slip the cap and
   render at ~400 cells, and `pickAsciiArtTier`'s width-fit check
   was wrong for any non-ASCII art. Switched both to
   `getCachedStringWidth` (string-width semantics, already in the
   repo); art truncation walks code points until adding another
   would push the cell width past the cap, so we never split a
   fullwidth code point or surrogate pair down the middle. New
   regression test exercises the CJK fullwidth case.

4. **[Suggestion] `collectScopedTiers()` no longer drops a whole
   scope just because it has no `file.path`.** Inline-string tiers
   don't need an owning settings directory; only `{path}` tiers do.
   The path-presence check was moved into the `{path}` branch, so a
   path-less scope (e.g. `systemDefaults`, future SDK-injected
   scopes) can still contribute inline art. `{path}` entries in such
   a scope soft-fail with a tier-specific `[BANNER]` warn rather
   than killing the whole scope. Two regression tests cover both
   sides.

5. **[Suggestion] OSC / CSI / SS2-3 regex are now authored once.**
   Extracted `TERMINAL_OSC_REGEX`, `TERMINAL_CSI_REGEX`,
   `TERMINAL_SHIFT_DCS_REGEX` from `stripTerminalControlSequences`
   in `@qwen-code/qwen-code-core` and re-export them from the
   package index. `customBanner.ts` reuses the constants for
   `sanitizeArt` (which still has to preserve `\n` / `\t`) and
   delegates the title/subtitle pipeline directly to
   `stripTerminalControlSequences`. Also backported the C1 control
   strip (0x80-0x9F) into the core helper so all callers
   (session-title, etc.) benefit from the same coverage; banner
   sanitizer was the only place catching single-byte CSI / DCS / ST.

Banner suite is now 40 + 17 + 6 + 16 = 79 tests, all green. Schema
regen is still byte-for-byte idempotent. `npm run typecheck` and
prettier clean on touched files.

* fix(cli): replace require() with ES6 import in FIFO test (lint)

The FIFO regression test 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: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
ChiGao 2026-05-07 10:17:53 +08:00 committed by GitHub
parent 15342b8932
commit 4f084352f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 3024 additions and 35 deletions

View file

@ -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: `<auth display type> | <model> ( /model to change)`.
- **B④** Path: a tildeified, shortened working directory.
The whole thing is wrapped by `<AppHeader>`, 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) | `<Header>` mount in `AppHeader.tsx` | **Hideable** | A single `ui.hideBanner: true` skips both regions — same shape as the existing screen-reader gate. `<Tips>` 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 ~711 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 ~79 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/<sessionId>.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": … │
} │
} ▼
<Header
▼ customAsciiArt={resolved.asciiArt}
loadSettings() customBannerTitle={resolved.title}
merge user / workspace customBannerSubtitle={resolved.subtitle}
│ version=… model=… authType=…
▼ workingDirectory=… />
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 `<Header>`:
```tsx
<Header
version={version}
authDisplayType={authDisplayType}
model={model}
workingDirectory={targetDir}
customAsciiArt={resolvedBanner?.asciiArt /* { small?, large? } */}
customBannerTitle={resolvedBanner?.title /* string | undefined */}
customBannerSubtitle={resolvedBanner?.subtitle /* string | undefined */}
/>
```
`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
<Text bold color={theme.text.accent}>
{customBannerTitle ? customBannerTitle : '>_ Qwen Code'}
</Text>
{customBannerSubtitle ? (
<Text color={theme.text.secondary}>{customBannerSubtitle}</Text>
) : (
<Text> </Text>
)}
```
**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 (~23 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/<sessionId>.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).

View file

@ -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 与缩短的工作目录。
外层 `<AppHeader>` 已经基于 `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``<Header>` 挂载点 | **可隐藏** | 一个 `ui.hideBanner: true` 同时跳过 A、B 两个区块 —— 形态与现有屏读模式开关一致。`<Tips>` 仍由独立的 `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 覆盖
usersystem 覆盖 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" 字母 711 列,最多 3 个字。 |
| 100 | 50 | ANSI Shadow 写一个短词(约 6 字母)或两个短词堆叠。 |
| 120 | 70 | 多行单词堆叠的 art 完全够。 |
| 200 | 150 | 单行长串(例如完整产品名的 ANSI Shadow也能装下。 |
设计 art 时的两条经验法则:
1. **多单词品牌名通常无法在多数终端上用一行 ANSI Shadow 渲染。**
ANSI Shadow 每字母约 79 列,即便像 `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/<sessionId>.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": … │
} │
} ▼
<Header
▼ customAsciiArt={resolved.asciiArt}
loadSettings() customBannerTitle={resolved.title}
merge user / workspace customBannerSubtitle={resolved.subtitle}
│ version=… model=… authType=…
▼ workingDirectory=… />
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 数据传入 `<Header>`
```tsx
<Header
version={version}
authDisplayType={authDisplayType}
model={model}
workingDirectory={targetDir}
customAsciiArt={resolvedBanner?.asciiArt /* { small?, large? } */}
customBannerTitle={resolvedBanner?.title /* string | undefined */}
customBannerSubtitle={resolvedBanner?.subtitle /* string | undefined */}
/>
```
`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
<Text bold color={theme.text.accent}>
{customBannerTitle ? customBannerTitle : '>_ Qwen Code'}
</Text>
{customBannerSubtitle ? (
<Text color={theme.text.secondary}>{customBannerSubtitle}</Text>
) : (
<Text> </Text>
)}
```
**新增文件**`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 或字符串)做 memoizereload 重新跑一遍解析器并重新读受影响的文件。 |
失败模式总结:所有软失败最终都会落到 `shortAsciiLogo`(或锁定的
默认标题)+ 一行调试日志 warn。任何分支都不允许产生硬失败
(向上抛出异常)。
## 不在本设计范围内
下列项被有意排除。每一项都可以视用户反馈做后续单独提案。
| 项目 | 不做的理由 |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 文案转 ASCII art`{ text: "xxxCode" }` 形态) | v1 评估后**拒绝**。要么引入 `figlet` 运行时依赖(含一套可用字体后约 23 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` 启动无 BannerTips 与正文
照常渲染。
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/<sessionId>.txt` 出现 `[BANNER]` warn
渲染默认 art。
8. 在工作区信任关闭的状态下打开 worktree → workspace 提供的
`customAsciiArt`(含 `{ path }`被静默忽略user scope
的设置仍然生效。

View file

@ -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<string, unknown>;
}
/**
@ -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,
},
],
},
},
},
},

View file

@ -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('<AppHeader />', () => {
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('██╔═══██╗');
});
});

View file

@ -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 (
<Box flexDirection="column">
{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 && <Tips />}

View file

@ -100,4 +100,91 @@ describe('<Header />', () => {
expect(lastFrame()).toContain('██╔═══██╗');
});
it('renders the custom subtitle in place of the blank spacer row', () => {
const { lastFrame } = render(
<Header
{...defaultProps}
customBannerSubtitle="Built-in DataWorks Official Skills"
/>,
);
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(<Header {...defaultProps} />);
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(
<Header {...defaultProps} customBannerTitle="Acme CLI" />,
);
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(
<Header
{...defaultProps}
customAsciiArt={{ small: 'SMALL', large: 'LARGE_LOGO' }}
/>,
);
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(
<Header
{...defaultProps}
customAsciiArt={{
small: 'sm',
large: 'X'.repeat(60),
}}
/>,
);
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(
<Header
{...defaultProps}
customAsciiArt={{ small: 'X'.repeat(150), large: 'Y'.repeat(150) }}
/>,
);
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(<Header {...defaultProps} />);
// 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');
});
});

View file

@ -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<HeaderProps> = ({
customAsciiArt,
customBannerTitle,
customBannerSubtitle,
version,
authDisplayType,
model,
@ -42,8 +64,6 @@ export const Header: React.FC<HeaderProps> = ({
}) => {
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<HeaderProps> = ({
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<HeaderProps> = ({
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. */}
<Text>
<Text bold color={theme.text.accent}>
&gt;_ Qwen Code
{customBannerTitle ? customBannerTitle : '>_ Qwen Code'}
</Text>
<Text color={theme.text.secondary}> (v{version})</Text>
</Text>
{/* Empty line for spacing */}
<Text> </Text>
{/* Subtitle (when set) replaces the blank spacer row. We always
emit a row here so the auth/model line stays at the same
vertical position regardless of whether the subtitle is set. */}
{customBannerSubtitle ? (
<Text color={theme.text.secondary}>{customBannerSubtitle}</Text>
) : (
<Text> </Text>
)}
{/* Auth and Model line */}
<Text>
<Text color={theme.text.secondary}>{authModelText}</Text>

View file

@ -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();
});
});

View file

@ -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, `<Header />` 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 `<Header />`
* 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<string, CacheEntry>();
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, CacheEntry>,
): 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<string, CacheEntry>,
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 `<Header />` 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 `<Header />` 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;
}

View file

@ -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)));
};
/*

View file

@ -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';

View file

@ -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[...<letter>`) 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, ' ')
);
}

View file

@ -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."
}
}
},

View file

@ -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) {