mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-13 15:32:19 +00:00
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.
This commit is contained in:
parent
417e428aaf
commit
c7aa4a4019
10 changed files with 289 additions and 43 deletions
|
|
@ -57,10 +57,15 @@ 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 three lines:
|
||||
- **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②** Status: `<auth display type> | <model> ( /model to change)`.
|
||||
- **B③** Path: a tildeified, shortened working directory.
|
||||
- **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
|
||||
|
|
@ -73,17 +78,19 @@ falls back to plain output).
|
|||
| **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②. 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. |
|
||||
| **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 three settings, no more:
|
||||
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.customAsciiArt` | unset | Replaces region A. Three accepted shapes (see below). Falls back to default on any error. | A |
|
||||
| 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:
|
||||
|
||||
|
|
@ -101,12 +108,14 @@ fields above.
|
|||
|
||||
### Limits at a glance
|
||||
|
||||
Three caps apply to every banner customization. Keep them in mind before
|
||||
hand-crafting art so the resolver doesn't truncate or reject your input.
|
||||
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. |
|
||||
|
|
@ -118,7 +127,7 @@ a denser font in another; the limiting factor is visual width, not letters.
|
|||
|
||||
### Where settings live
|
||||
|
||||
All three settings live under `ui` in `settings.json`. Both user-level
|
||||
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).
|
||||
|
|
@ -167,6 +176,42 @@ 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
|
||||
|
|
|
|||
|
|
@ -49,32 +49,38 @@ Logo 和一个带边框的信息面板。多种真实场景需要对这一区域
|
|||
- **A. Logo 列** —— 单块带渐变色的 ASCII art。
|
||||
当前来源:`packages/cli/src/ui/components/AsciiArt.ts` 中的
|
||||
`shortAsciiLogo`。
|
||||
- **B. 信息面板** —— 带边框的信息盒,包含三行:
|
||||
- **B. 信息面板** —— 带边框的信息盒,共四行。第二行默认是空白视觉
|
||||
spacer,可选地切换为调用方提供的副标题:
|
||||
- **B①** 标题:`>_ Qwen Code (vX.Y.Z)` —— 品牌文字 + 版本号后缀。
|
||||
- **B②** 状态:`<鉴权显示类型> | <模型> ( /model 切换)`。
|
||||
- **B③** 路径:经过 tildeify 与缩短的工作目录。
|
||||
- **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②. 状态行**(鉴权 + 模型) | `formattedAuthType`、`model` prop | **锁定** | 运营与安全信号。用户必须看到当前使用的凭据以及实际消耗 token 的模型。任何隐藏/替换都是 footgun,即便在白标场景下也不应允许。 |
|
||||
| **B③. 路径行**(工作目录) | `workingDirectory` prop | **锁定** | 运营信息。「我现在在哪个目录?」是高频问题;Banner 是其唯一权威答案。 |
|
||||
| **整个 Banner** (A + B) | `AppHeader.tsx` 中 `<Header>` 挂载点 | **可隐藏** | 一个 `ui.hideBanner: true` 同时跳过 A、B 两个区块 —— 形态与现有屏读模式开关一致。`<Tips>` 仍由独立的 `ui.hideTips` 控制。 |
|
||||
| 区域 | 当前来源 | 自定义类别 | 锁定/开放原因 |
|
||||
| ---------------------------------- | ------------------------------------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **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.customAsciiArt` | 未设置 | 替换区域 A。支持三种数据形态(见下文)。任何错误均回退为默认。 | A |
|
||||
| 设置 | 默认值 | 效果 | 影响区域 |
|
||||
| ------------------------- | ------- | ---------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| `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 |
|
||||
|
||||
**有意不提供**的能力:
|
||||
|
||||
|
|
@ -91,12 +97,13 @@ Logo 和一个带边框的信息面板。多种真实场景需要对这一区域
|
|||
|
||||
### 限制总览
|
||||
|
||||
每次 banner 自定义都会受这三组上限约束。手写 art 前先看一遍,免得被
|
||||
每次 banner 自定义都会受这几组上限约束。手写 art 前先看一遍,免得被
|
||||
解析器静默截断或拒绝。
|
||||
|
||||
| 项目 | 上限 |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| **标题字符数** | **80 字符上限**(清洗后计数)。超出截断并打 `[BANNER]` warn。换行符与控制字符在计数前已被剥离。 |
|
||||
| **副标题字符数** | **160 字符上限**(清洗后计数)。清洗管线与标题一致;超出截断同样打 `[BANNER]` warn。 |
|
||||
| **ASCII art 块尺寸** | **每档 200 行 × 200 列上限**。超出截断并打 `[BANNER]` warn。 |
|
||||
| **ASCII art 文件大小** | **64 KB 上限**。文件大于上限时只读取上限以内的字节,剩余忽略。 |
|
||||
| **ASCII art 实际可渲染宽度** | 由启动时终端列数决定,**不是固定字符数**。具体公式与各种终端宽度下的可用值见下文「Logo 能多大?—— 宽度预算」。 |
|
||||
|
|
@ -107,7 +114,7 @@ ASCII art **没有固定的字符数上限** —— 只有上面这两组列/行
|
|||
|
||||
### 配置存放位置
|
||||
|
||||
三个设置都位于 `settings.json` 的 `ui` 节点下。同时支持用户级
|
||||
四个设置都位于 `settings.json` 的 `ui` 节点下。同时支持用户级
|
||||
(`~/.qwen/settings.json`)和工作区级(项目根目录的
|
||||
`.qwen/settings.json`),按标准合并优先级生效(workspace 覆盖
|
||||
user,system 覆盖 workspace)。
|
||||
|
|
@ -153,6 +160,39 @@ Tips 仍会显示。
|
|||
`>_` 字符;如需保留,请自己写进去:
|
||||
`"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
|
||||
|
|
|
|||
|
|
@ -774,6 +774,16 @@ const SETTINGS_SCHEMA = {
|
|||
'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',
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@ 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 {
|
||||
|
|
@ -117,6 +119,22 @@ describe('<AppHeader />', () => {
|
|||
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(),
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
|||
workingDirectory={targetDir}
|
||||
customAsciiArt={resolvedBanner?.asciiArt}
|
||||
customBannerTitle={resolvedBanner?.title}
|
||||
customBannerSubtitle={resolvedBanner?.subtitle}
|
||||
/>
|
||||
)}
|
||||
{showTips && <Tips />}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,33 @@ 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" />,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ interface HeaderProps {
|
|||
* 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;
|
||||
|
|
@ -50,6 +56,7 @@ interface HeaderProps {
|
|||
export const Header: React.FC<HeaderProps> = ({
|
||||
customAsciiArt,
|
||||
customBannerTitle,
|
||||
customBannerSubtitle,
|
||||
version,
|
||||
authDisplayType,
|
||||
model,
|
||||
|
|
@ -181,8 +188,14 @@ export const Header: React.FC<HeaderProps> = ({
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -399,6 +399,55 @@ describe('resolveCustomBanner', () => {
|
|||
);
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -23,10 +23,24 @@ const MAX_ART_LINES = 200;
|
|||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -48,6 +62,7 @@ export function resolveCustomBanner(settings: LoadedSettings): ResolvedBanner {
|
|||
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
|
||||
|
|
@ -64,6 +79,7 @@ export function resolveCustomBanner(settings: LoadedSettings): ResolvedBanner {
|
|||
resolveTier(scoped.large.source, scoped.large.dir, cache),
|
||||
},
|
||||
title,
|
||||
subtitle,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -315,24 +331,46 @@ function sanitizeArt(input: string): string {
|
|||
}
|
||||
|
||||
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).
|
||||
* Strips OSC / CSI / SS2 / SS3 leaders, replaces every other C0 / C1
|
||||
* control byte (and DEL) with a space, then folds any internal whitespace
|
||||
* (including the newlines we just dropped from controls) 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;
|
||||
/* eslint-disable no-control-regex */
|
||||
let t = raw
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, ' ')
|
||||
.replace(/\x1b\[[\d;?]*[a-zA-Z]/g, ' ')
|
||||
.replace(/\x1b[NOP]/g, ' ')
|
||||
// C0 + DEL + C1 controls. Titles never need newlines or tabs (they live
|
||||
// on a single line of the info panel) so the range is denser than the
|
||||
// art sanitizer's.
|
||||
// C0 + DEL + C1 controls. Single-line fields never need newlines or
|
||||
// tabs (they live on a single line of the info panel) so the range is
|
||||
// denser than the art sanitizer's.
|
||||
.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
|
||||
/* eslint-enable no-control-regex */
|
||||
t = t.replace(/\s+/g, ' ').trim();
|
||||
if (!t) return undefined;
|
||||
if (t.length > MAX_TITLE_LENGTH) {
|
||||
debugLogger.warn(
|
||||
`Truncated ui.customBannerTitle to ${MAX_TITLE_LENGTH} characters.`,
|
||||
);
|
||||
t = t.slice(0, MAX_TITLE_LENGTH);
|
||||
if (t.length > maxLength) {
|
||||
debugLogger.warn(`Truncated ${fieldLabel} to ${maxLength} characters.`);
|
||||
t = t.slice(0, maxLength);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -271,6 +271,11 @@
|
|||
"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": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue