mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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 inc7aa4a401: - Region taxonomy ASCII diagram now shows four B-rows (① title, ② subtitle, ③ status, ④ path). - Resolution-pipeline ASCII diagram and step list pick up customBannerSubtitle on the input side and the title/subtitle sanitize step on the resolver side. - "Settings schema additions" section lists the fourth entry, customBannerSubtitle, and notes the customAsciiArt jsonSchemaOverride that landed for VS Code schema reproducibility. - "Wiring changes" section updates the Header prop list and the HeaderProps interface, replaces the brittle line-number anchors with file-level anchors, drops the obsolete `paths` second arg from resolveCustomBanner, and adds the trust-gate sentence. - "Security & failure handling" table replaces the stripTerminalControlSequences shorthand with the actual banner-specific stripper, splits the title/subtitle row to cover both, and adds the untrusted-workspace gate as its own row. - "Verification plan" gains two scenarios: the subtitle row, and the untrusted-workspace check that the Critical reviewer comment on the impl PR explicitly asked us to lock down. Mirrored every edit in the zh-CN translation. The implementation itself is unchanged. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address banner re-review (FIFO, mutex schema, display width, regex dedupe) Addresses the five findings on PR #3710 from the latest re-review: 1. **[Critical] FIFO/pipe at `customAsciiArt.path` no longer hangs startup.** The resolver was calling `openSync(path, O_NOFOLLOW)` *before* the `fstatSync(...).isFile()` check; on POSIX, opening a FIFO read-only blocks until a writer connects, and `O_NOFOLLOW` doesn't help — it only refuses symlinks at the final path component. `readArtFile` now `lstatSync()`s first and refuses non-regular files (FIFO / socket / device / symlink) before the open, while keeping the post-open `fstatSync` check for TOCTOU safety against a swap between the lstat and the open. New POSIX-only regression test `mkfifo`s a named pipe and asserts the resolver soft-fails inside 1 s; if the open ever regresses to blocking, the test will hang past the timeout and the assertion will catch it. 2. **[Suggestion] `{path}` and `{small,large}` are now mutually exclusive in both schema and runtime.** The `jsonSchemaOverride` on `ui.customAsciiArt` is split into three branches (string, `{path}`, `{small?, large?}`); none of them allow `path` and tier keys to co-exist. `normalizeTiers()` mirrors that — an object carrying both kinds of keys is now soft-rejected with a `[BANNER]` warn rather than letting `path` silently win and dropping the tier values. New regression test pins the runtime side. 3. **[Suggestion] Column cap and tier-fit selection now measure in terminal cells.** `getAsciiArtWidth` (in `textUtils.ts`) and the `MAX_ART_COLS` cap in `customBanner.ts` were both using UTF-16 `.length`, so 200 CJK fullwidth characters would slip the cap and render at ~400 cells, and `pickAsciiArtTier`'s width-fit check was wrong for any non-ASCII art. Switched both to `getCachedStringWidth` (string-width semantics, already in the repo); art truncation walks code points until adding another would push the cell width past the cap, so we never split a fullwidth code point or surrogate pair down the middle. New regression test exercises the CJK fullwidth case. 4. **[Suggestion] `collectScopedTiers()` no longer drops a whole scope just because it has no `file.path`.** Inline-string tiers don't need an owning settings directory; only `{path}` tiers do. The path-presence check was moved into the `{path}` branch, so a path-less scope (e.g. `systemDefaults`, future SDK-injected scopes) can still contribute inline art. `{path}` entries in such a scope soft-fail with a tier-specific `[BANNER]` warn rather than killing the whole scope. Two regression tests cover both sides. 5. **[Suggestion] OSC / CSI / SS2-3 regex are now authored once.** Extracted `TERMINAL_OSC_REGEX`, `TERMINAL_CSI_REGEX`, `TERMINAL_SHIFT_DCS_REGEX` from `stripTerminalControlSequences` in `@qwen-code/qwen-code-core` and re-export them from the package index. `customBanner.ts` reuses the constants for `sanitizeArt` (which still has to preserve `\n` / `\t`) and delegates the title/subtitle pipeline directly to `stripTerminalControlSequences`. Also backported the C1 control strip (0x80-0x9F) into the core helper so all callers (session-title, etc.) benefit from the same coverage; banner sanitizer was the only place catching single-byte CSI / DCS / ST. Banner suite is now 40 + 17 + 6 + 16 = 79 tests, all green. Schema regen is still byte-for-byte idempotent. `npm run typecheck` and prettier clean on touched files. * fix(cli): replace require() with ES6 import in FIFO test (lint) The FIFO regression test in7ccbfaeb1used a synchronous `require()` to pull in `node:child_process` so the test could lazy-load `execFileSync` only when needed. CI Lint flagged it under `no-restricted-syntax` — the repo enforces ES6 imports throughout, including in tests, with no exception for `require()`. Move the import to the top of the file alongside the other `node:` / vitest imports. The `try/catch` around `execFileSync('mkfifo', ...)` still gates the test on `mkfifo` being available (rare on a fresh container, so we skip rather than fail). 40 / 40 tests still pass and ESLint is clean on the touched file. --------- Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
15342b8932
commit
4f084352f4
14 changed files with 3024 additions and 35 deletions
776
docs/design/customize-banner-area/customize-banner-area.md
Normal file
776
docs/design/customize-banner-area/customize-banner-area.md
Normal 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 ~7–11 cols — 3 letters max. |
|
||||
| 100 | 50 | A short word in ANSI Shadow (~6 letters), or two short words stacked. |
|
||||
| 120 | 70 | Stacked multi-line word art fits comfortably. |
|
||||
| 200 | 150 | Long inline strings like full product names in ANSI Shadow fit. |
|
||||
|
||||
Two practical implications when designing your art:
|
||||
|
||||
1. **A multi-word brand often won't render as a single ANSI Shadow line
|
||||
on most terminals.** At ~7–9 cols per ANSI Shadow letter, even a
|
||||
12-character brand like `Custom Agent` is roughly 95 cols of art on
|
||||
one line — already more than a 100-col terminal can spare alongside
|
||||
the info panel. Either stack the words on multiple lines, pick a
|
||||
denser figlet font, or use a compact single-line text decoration
|
||||
like `▶ Custom Agent ◀`.
|
||||
2. **Use the width-aware `{ small, large }` form** when a single tier
|
||||
would force you to choose between "looks great wide / dies narrow"
|
||||
and "looks fine narrow / wastes space wide". The example below
|
||||
stacks the words for a ≥104-col terminal in `large` and falls
|
||||
through to a 16-col single-line decoration in `small`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ui": {
|
||||
"customBannerTitle": "Custom Agent",
|
||||
"customAsciiArt": {
|
||||
"small": "▶ Custom Agent ◀",
|
||||
"large": { "path": "./banner-large.txt" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Where `banner-large.txt` contains the stacked-words ANSI Shadow output
|
||||
(~54 cols × 12 lines), e.g., generated by:
|
||||
|
||||
```bash
|
||||
( npx figlet -f "ANSI Shadow" CUSTOM
|
||||
npx figlet -f "ANSI Shadow" AGENT ) > banner-large.txt
|
||||
```
|
||||
|
||||
### Combine all three
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ui": {
|
||||
"hideBanner": false,
|
||||
"customBannerTitle": "Acme CLI",
|
||||
"customAsciiArt": {
|
||||
"small": " ACME\n ----",
|
||||
"large": { "path": "./brand-wide.txt" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### How to verify your change
|
||||
|
||||
1. Save `settings.json` and start a fresh `qwen` session — banner
|
||||
resolution runs once at startup.
|
||||
2. Resize the terminal to confirm `small` / `large` tiers swap as
|
||||
expected, and that the logo column disappears at very narrow widths.
|
||||
3. If something does not appear as expected, look at
|
||||
`~/.qwen/debug/<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 (~2–3 MB unpacked once a usable set of fonts is included) or a vendored single-font renderer (~200 lines + a `.flf` font file we'd own). Both options bring ongoing surface area: font selection, font-license tracking, "my font doesn't render right on terminal X" issues, and CJK / wide-character handling. The driving use case for this feature (white-label / multi-tenant) almost always has a designer producing intentional ASCII art, not relying on a default figlet font. Users who want one-line generation can already get it with `npx figlet "xxxCode" > brand.txt` + `customAsciiArt: { "path": "./brand.txt" }` — same outcome, no added dependency, no support burden inside Qwen Code. If demand surfaces later this form is purely additive: extend `AsciiArtSource` to `string \| {path} \| {text, font?}` without breaking any existing config. |
|
||||
| `/banner` slash command for live editing | The settings UI is the canonical edit surface. A live editor for multi-line ASCII art is its own project. |
|
||||
| Custom gradient colors / per-line color overrides | Theme owns colors. A separate proposal can extend the theme contract; banner customization should not duplicate that surface. |
|
||||
| URL-loaded ASCII art | Network fetch at startup is its own can of worms — failure modes, caching, security review. The file-path form is the lower-risk equivalent. |
|
||||
| Animation (spinning logo, marquee title) | Adds rendering load and a11y concerns; nothing in the use cases needs it. |
|
||||
| VSCode / Web UI banner parity | Those surfaces don't render the Ink banner today. If they grow a banner, this design is the reference. |
|
||||
| Dynamic reload on file change | The resolver runs at startup and on settings reload only. Mid-session art changes are rare enough that "restart to take effect" is the acceptable trade. |
|
||||
| Hiding only individual locked regions (version, auth, model, path) | These are operational signals; suppressing them harms support and security posture more than it helps white-label scenarios. |
|
||||
|
||||
## Verification plan
|
||||
|
||||
For the eventual implementation PR, the following end-to-end checks
|
||||
should pass.
|
||||
|
||||
1. `~/.qwen/settings.json` with `customBannerTitle: "Acme CLI"` and an
|
||||
inline `customAsciiArt` string → `qwen` shows the new title and art;
|
||||
version suffix still present.
|
||||
2. `customBannerSubtitle: "Built-in Acme Skills"` → the subtitle row
|
||||
renders between the title and the auth/model line in the secondary
|
||||
text color; auth, model, and path still visible. Unsetting it
|
||||
restores the blank spacer row (back-compat).
|
||||
3. `hideBanner: true` → `qwen` starts with no banner; tips and chat
|
||||
render normally.
|
||||
4. `customAsciiArt: { "path": "./brand.txt" }` in a workspace
|
||||
`settings.json`, with `brand.txt` next to it in `.qwen/` → loads
|
||||
from disk on workspace open.
|
||||
5. `customAsciiArt: { "small": "...", "large": "..." }` → resize the
|
||||
terminal between wide / medium / narrow; large at wide widths,
|
||||
small at medium widths, logo column hidden at narrow widths, info
|
||||
panel always visible.
|
||||
6. Inject `\x1b[31mhostile` into `customBannerTitle` _and_
|
||||
`customBannerSubtitle` → both render as literal text, not
|
||||
interpreted as red.
|
||||
7. Point `path` at a missing file → CLI starts; `[BANNER]` warn
|
||||
appears in `~/.qwen/debug/<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).
|
||||
720
docs/design/customize-banner-area/customize-banner-area.zh-CN.md
Normal file
720
docs/design/customize-banner-area/customize-banner-area.zh-CN.md
Normal 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 覆盖
|
||||
user,system 覆盖 workspace)。
|
||||
|
||||
`customAsciiArt` 是特例:解析器不把整个对象当成一个值由更高优先级的
|
||||
scope 直接替换,而是按 tier 逐个穿越所有 scope。如果 user 设置定义
|
||||
了 `{ small }`、workspace 设置定义了 `{ large }`,两边都会生效 ——
|
||||
`small` 取自 user,`large` 取自 workspace。这样能同时满足两件事:
|
||||
|
||||
1. 每个 `{ path }` 项相对于声明它的那个文件解析(workspace `.qwen/`
|
||||
vs. user `~/.qwen/`);只看 merged 视图就丢失了 scope 信息。
|
||||
2. 用户可以把默认的 `large` tier 留在个人设置里,按工作区只覆盖
|
||||
`small`,而不必每次重写整个对象。
|
||||
|
||||
同一 tier 在多个 scope 都定义时,仍按正常优先级生效(system >
|
||||
workspace > user)。在任意 scope 把 `customAsciiArt` 设为单条字符串
|
||||
或 `{ path }` 时,仍然会同时填充该 scope 的两个 tier。
|
||||
|
||||
### 整体隐藏 Banner
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ui": {
|
||||
"hideBanner": true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
启动输出会跳过 Logo 列和信息面板。除非也设置了 `ui.hideTips`,否则
|
||||
Tips 仍会显示。
|
||||
|
||||
### 替换品牌标题
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ui": {
|
||||
"customBannerTitle": "Acme CLI",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
信息面板将渲染为 `Acme CLI (vX.Y.Z)`。设置自定义标题后默认不再带
|
||||
`>_` 字符;如需保留,请自己写进去:
|
||||
`"customBannerTitle": ">_ Acme CLI"`。
|
||||
|
||||
### 添加品牌副标题
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ui": {
|
||||
"customBannerSubtitle": "Built-in DataWorks Official Skills",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
副标题会以次要文字色单独成一行,**取代**默认的空白 spacer 行(即原本
|
||||
位于标题与鉴权 / 模型行之间那一行):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ DataWorks DataAgent (vX.Y.Z) │ ← B① 标题
|
||||
│ Built-in DataWorks Official Skills │ ← B② 副标题
|
||||
│ Qwen OAuth | qwen-coder ( /model 切换) │ ← B③ 状态
|
||||
│ ~/projects/example │ ← B④ 路径
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 仅允许单行。换行符以及其他控制字节会被剥离 / 折叠为空格,避免
|
||||
粘贴事故撕坏信息面板布局。
|
||||
- 清洗后上限 160 字符(比标题宽松一些 —— 副标语 / "powered by" 之
|
||||
类的文案常常会比品牌名长)。
|
||||
- 留空(或设置为空字符串 / 全空白)= 保留默认的空白 spacer 行 ——
|
||||
向后兼容是默认行为。
|
||||
- 副标题不会改变锁定行的行为;鉴权、模型与工作目录始终可见,与副
|
||||
标题状态无关。
|
||||
|
||||
### 替换 ASCII art —— 内联字符串
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ui": {
|
||||
"customAsciiArt": " ___ _ _ ____ \n / _ \\| | / |/ _\\\n| |_| | |__| | __/\n \\___/|____|_|___|",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
JSON 字符串中用 `\n` 表示换行。该 ASCII art 会与默认 Logo 一样应用
|
||||
当前主题的渐变色。
|
||||
|
||||
> **手头没有 ASCII art?** 任何外部生成器都行,把生成结果粘贴
|
||||
> 进来即可。最简路径是 `figlet`:
|
||||
> `npx figlet -f "ANSI Shadow" "xxxCode" > brand.txt`,然后把
|
||||
> `customAsciiArt: { "path": "./brand.txt" }` 指向该文件。CLI **不会**
|
||||
> 在运行时把文案渲染成 ASCII art —— 原因见下文「不在本设计范围内」。
|
||||
|
||||
### 替换 ASCII art —— 外部文件
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ui": {
|
||||
"customAsciiArt": { "path": "./brand.txt" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
避免在 JSON 中转义大段多行字符串。路径解析规则:
|
||||
|
||||
- **工作区级设置**:相对路径相对于 workspace 的 `.qwen/` 目录。
|
||||
- **用户级设置**:相对路径相对于 `~/.qwen/`。
|
||||
- 绝对路径直接使用。
|
||||
- 文件**仅在启动时读取一次**,经过清洗后写入缓存。会话进行中修改
|
||||
文件不会重新渲染 —— 请重启 CLI。
|
||||
|
||||
### 替换 ASCII art —— 宽度自适应
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ui": {
|
||||
"customAsciiArt": {
|
||||
"small": " ACME\n ----",
|
||||
"large": { "path": "./brand-wide.txt" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
终端足够宽时优先使用 `large`;否则使用 `small`;再否则隐藏 Logo 列
|
||||
(沿用当前的双列回退策略)。`small` 与 `large` 各自既可以是字符串
|
||||
也可以是 `{ path }`。任意一档可省略:缺失时直接进入下一档。
|
||||
|
||||
### Logo 能多大?—— 宽度预算
|
||||
|
||||
标题和 art 都没有"字符数硬上限",只有由终端列数决定的**宽度预算**,
|
||||
以及防止畸形输入冻结布局的绝对硬上限:
|
||||
|
||||
| 项 | 上限 |
|
||||
| ---------------------------------- | ----------------------------------------------- |
|
||||
| 启动时终端列数 | 用户终端报告多少就是多少。 |
|
||||
| 容器外边距 | 4 列(左 2 + 右 2)。 |
|
||||
| Logo 列与信息面板之间的间距 | 2 列。 |
|
||||
| 信息面板最小宽度 | 44 列(40 路径 + 边框 + 内边距)。 |
|
||||
| **每档 art 在渲染时的可用宽度** | `终端列数 − 4 − 2 − 44 = 终端列数 − 50`。 |
|
||||
| 单档 art 清洗后的硬上限 | 200 列 × 200 行。超出截断并打 `[BANNER]` warn。 |
|
||||
| `customBannerTitle` 清洗后的硬上限 | 80 字符。超出截断并打 `[BANNER]` warn。 |
|
||||
|
||||
常见终端宽度对应的 logo 上限:
|
||||
|
||||
| 终端列数 | 可渲染最大 logo 宽度 | 实际意味着什么 |
|
||||
| -------- | -------------------- | ------------------------------------------------------- |
|
||||
| 80 | 30 | 大部分 figlet "ANSI Shadow" 字母 7–11 列,最多 3 个字。 |
|
||||
| 100 | 50 | ANSI Shadow 写一个短词(约 6 字母)或两个短词堆叠。 |
|
||||
| 120 | 70 | 多行单词堆叠的 art 完全够。 |
|
||||
| 200 | 150 | 单行长串(例如完整产品名的 ANSI Shadow)也能装下。 |
|
||||
|
||||
设计 art 时的两条经验法则:
|
||||
|
||||
1. **多单词品牌名通常无法在多数终端上用一行 ANSI Shadow 渲染。**
|
||||
ANSI Shadow 每字母约 7–9 列,即便像 `Custom Agent` 这样 12 字符的
|
||||
品牌名,单行就要约 95 列 art —— 100 列的终端在装下信息面板后已经
|
||||
不够。要么把单词换行堆叠,要么换更窄的 figlet 字体,要么直接用
|
||||
紧凑的单行装饰,例如 `▶ Custom Agent ◀`。
|
||||
2. **当单档既要"宽屏好看"又要"窄屏不死"时,用 `{ small, large }`
|
||||
宽度自适应形态**。下面这个例子里 `large` 是 ≥ 104 列终端用的堆叠
|
||||
多行 art,`small` 是 16 列的单行装饰,窄到装不下两者就直接隐藏
|
||||
logo 列。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ui": {
|
||||
"customBannerTitle": "Custom Agent",
|
||||
"customAsciiArt": {
|
||||
"small": "▶ Custom Agent ◀",
|
||||
"large": { "path": "./banner-large.txt" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`banner-large.txt` 里放堆叠后的 ANSI Shadow 输出(约 54 列 × 12 行),
|
||||
可以用下面的命令生成:
|
||||
|
||||
```bash
|
||||
( npx figlet -f "ANSI Shadow" CUSTOM
|
||||
npx figlet -f "ANSI Shadow" AGENT ) > banner-large.txt
|
||||
```
|
||||
|
||||
### 三项组合
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ui": {
|
||||
"hideBanner": false,
|
||||
"customBannerTitle": "Acme CLI",
|
||||
"customAsciiArt": {
|
||||
"small": " ACME\n ----",
|
||||
"large": { "path": "./brand-wide.txt" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 如何验证
|
||||
|
||||
1. 保存 `settings.json`,重新启动 `qwen` —— Banner 解析仅在启动时
|
||||
运行一次。
|
||||
2. 调整终端宽度,确认 `small` / `large` 切换符合预期,并且在极窄
|
||||
宽度下 Logo 列正确隐藏。
|
||||
3. 若结果与预期不符,查看
|
||||
`~/.qwen/debug/<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 或字符串)做 memoize;reload 重新跑一遍解析器并重新读受影响的文件。 |
|
||||
|
||||
失败模式总结:所有软失败最终都会落到 `shortAsciiLogo`(或锁定的
|
||||
默认标题)+ 一行调试日志 warn。任何分支都不允许产生硬失败
|
||||
(向上抛出异常)。
|
||||
|
||||
## 不在本设计范围内
|
||||
|
||||
下列项被有意排除。每一项都可以视用户反馈做后续单独提案。
|
||||
|
||||
| 项目 | 不做的理由 |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 文案转 ASCII art(`{ text: "xxxCode" }` 形态) | v1 评估后**拒绝**。要么引入 `figlet` 运行时依赖(含一套可用字体后约 2–3 MB unpacked),要么自己 vendor 一份单字体渲染器(~200 行代码 + 一份 `.flf` 字体我们自己维护)。两条路都带来长期的维护面:字体选型、字体 license 审计、「我的字体在 X 终端渲染不对」类 issue、CJK / 全角字符处理。本特性的驱动用例(白标 / 多租户)几乎一定有设计师交付成品 ASCII art,不会依赖 figlet 默认字体。希望一行命令生成的用户今天就能 `npx figlet "xxxCode" > brand.txt` + `customAsciiArt: { "path": "./brand.txt" }` —— 等价效果、零新增依赖、零 Qwen Code 内部支持负担。如果未来诉求增多,这一形态是纯叠加:把 `AsciiArtSource` 扩展为 `string \| {path} \| {text, font?}`,不会破坏任何已有配置。 |
|
||||
| `/banner` slash 命令在线编辑 | 设置 UI 是规范化的编辑入口;多行 ASCII 在线编辑器是另一个项目。 |
|
||||
| 自定义渐变色 / 单行颜色 | 颜色由 theme 拥有。如需扩展应另立提案,Banner 自定义不重复造该面。 |
|
||||
| URL 加载 ASCII art | 启动期网络请求自带一堆问题:失败模式、缓存、安全评审。`{path}` 文件加载是低风险等价物。 |
|
||||
| 动画(旋转 Logo、跑马灯标题) | 增加渲染负担与无障碍问题;本特性的用例不需要。 |
|
||||
| VSCode / Web UI banner 对齐 | 这两个端目前不渲染 Ink Banner。若未来引入,本设计为参考。 |
|
||||
| 文件变更的动态 reload | 解析器仅在启动与设置 reload 时运行。会话中途换 art 的需求很少,「重启生效」是可以接受的折中。 |
|
||||
| 单独隐藏锁定区域(version / auth / model / path) | 这些是运行时信号;屏蔽它们对支持与安全姿态的损害,远大于白标场景的收益。 |
|
||||
|
||||
## 验证计划
|
||||
|
||||
后续实施 PR 应通过以下端到端检查:
|
||||
|
||||
1. `~/.qwen/settings.json` 设置 `customBannerTitle: "Acme CLI"`
|
||||
与一段内联 `customAsciiArt` → `qwen` 启动后展示新标题与新
|
||||
ASCII art;版本号后缀仍在。
|
||||
2. 设置 `customBannerSubtitle: "Built-in Acme Skills"` → 副标题
|
||||
行以次要文字色出现在标题与鉴权 / 模型行之间;鉴权、模型、
|
||||
路径仍可见。取消设置后回到空白 spacer 行(向后兼容)。
|
||||
3. 设置 `hideBanner: true` → `qwen` 启动无 Banner;Tips 与正文
|
||||
照常渲染。
|
||||
4. workspace `settings.json` 设置
|
||||
`customAsciiArt: { "path": "./brand.txt" }`,`brand.txt` 与
|
||||
之同处 `.qwen/` 目录 → 打开工作区时从磁盘加载。
|
||||
5. `customAsciiArt: { "small": "...", "large": "..." }` →
|
||||
在宽 / 中 / 窄三档下调整终端尺寸;宽时取 large、中时取
|
||||
small、窄时隐藏 Logo 列;信息面板始终可见。
|
||||
6. `customBannerTitle` **与** `customBannerSubtitle` 中分别
|
||||
注入 `\x1b[31mhostile` → 两处都渲染为字面文本,不会被
|
||||
解释为红色。
|
||||
7. `path` 指向不存在的文件 → CLI 正常启动;
|
||||
`~/.qwen/debug/<sessionId>.txt` 出现 `[BANNER]` warn;
|
||||
渲染默认 art。
|
||||
8. 在工作区信任关闭的状态下打开 worktree → workspace 提供的
|
||||
`customAsciiArt`(含 `{ path }` 项)被静默忽略;user scope
|
||||
的设置仍然生效。
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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('██╔═══██╗');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
>_ 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>
|
||||
|
|
|
|||
583
packages/cli/src/ui/utils/customBanner.test.ts
Normal file
583
packages/cli/src/ui/utils/customBanner.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
470
packages/cli/src/ui/utils/customBanner.ts
Normal file
470
packages/cli/src/ui/utils/customBanner.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)));
|
||||
};
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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, ' ')
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue