From c7aa4a40193e74441f7400b37ece3cfc7233742f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 6 May 2026 11:49:23 +0800 Subject: [PATCH] feat(cli): add ui.customBannerSubtitle for the spacer row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth opt-in setting to the banner customization surface. The info panel renders four rows (title, subtitle/spacer, status, path); the second row was a hard-coded single-space spacer up to now. With this change a fork or white-label deployment can set `ui.customBannerSubtitle` to a one-line subtitle (e.g. "Built-in DataWorks Official Skills") and have it render in the secondary text color in place of the spacer. Empty/unset preserves the previous blank-spacer layout, so the change is back-compat. The subtitle is sanitized through the same `sanitizeSingleLine` helper as the title (now factored out): OSC / CSI / SS2 / SS3 leaders dropped, every other C0/C1 control byte replaced with a space, internal whitespace collapsed, ends trimmed. Capped at 160 characters — looser than the title's 80 because tagline / "powered by" copy commonly runs longer — with the same `[BANNER]` warn on truncation. Wiring: - `settingsSchema.ts` — new `customBannerSubtitle` entry next to `customBannerTitle`, `showInDialog: false` (free-form text in the TUI dialog isn't worth its own picker). - `customBanner.ts` — `ResolvedBanner.subtitle` field; `resolveCustomBanner` populates it; `sanitizeTitle` and the new `sanitizeSubtitle` share the same helper. - `Header.tsx` — when `customBannerSubtitle` is truthy the spacer row renders the string (secondary color, single line) instead of ` `. Auth/model and path still sit at their usual positions. - `AppHeader.tsx` — pipes `resolvedBanner.subtitle` through. - VSCode JSON schema regenerated from source (idempotent). Tests: 5 new resolver tests (default, sanitize, length cap, empty, newline + C1 strip), 2 new Header tests (renders subtitle between title and auth; spacer preserved when unset), 1 new AppHeader integration test (end-to-end through resolver). Banner suite is now 35 + 17 + 6 + 16 = 74 tests, all green. Design docs (EN + zh-CN) updated: region taxonomy now lists four B-rows; "Limits at a glance" table grows a subtitle row; "Customization rules" matrix and "How to modify" section gain a "Add a brand subtitle" example with a rendered four-row preview. --- .../customize-banner-area.md | 73 +++++++++++++---- .../customize-banner-area.zh-CN.md | 78 ++++++++++++++----- packages/cli/src/config/settingsSchema.ts | 10 +++ .../cli/src/ui/components/AppHeader.test.tsx | 18 +++++ packages/cli/src/ui/components/AppHeader.tsx | 1 + .../cli/src/ui/components/Header.test.tsx | 27 +++++++ packages/cli/src/ui/components/Header.tsx | 17 +++- .../cli/src/ui/utils/customBanner.test.ts | 49 ++++++++++++ packages/cli/src/ui/utils/customBanner.ts | 54 +++++++++++-- .../schemas/settings.schema.json | 5 ++ 10 files changed, 289 insertions(+), 43 deletions(-) diff --git a/docs/design/customize-banner-area/customize-banner-area.md b/docs/design/customize-banner-area/customize-banner-area.md index dd2191f8c..c2a77a877 100644 --- a/docs/design/customize-banner-area/customize-banner-area.md +++ b/docs/design/customize-banner-area/customize-banner-area.md @@ -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: ` | ( /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: ` | ( /model to change)`. + - **B④** Path: a tildeified, shortened working directory. The whole thing is wrapped by ``, which already gates the banner on `showBanner = !config.getScreenReader()` (screen-reader mode @@ -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) | `
` mount in `AppHeader.tsx` | **Hideable** | A single `ui.hideBanner: true` skips both regions — same shape as the existing screen-reader gate. `` continues to be governed independently by `ui.hideTips`. | -The matrix translates to 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 diff --git a/docs/design/customize-banner-area/customize-banner-area.zh-CN.md b/docs/design/customize-banner-area/customize-banner-area.zh-CN.md index 66e449514..770abce11 100644 --- a/docs/design/customize-banner-area/customize-banner-area.zh-CN.md +++ b/docs/design/customize-banner-area/customize-banner-area.zh-CN.md @@ -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 与缩短的工作目录。 外层 `` 已经基于 `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` 中 `
` 挂载点 | **可隐藏** | 一个 `ui.hideBanner: true` 同时跳过 A、B 两个区块 —— 形态与现有屏读模式开关一致。`` 仍由独立的 `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` 中 `
` 挂载点 | **可隐藏** | 一个 `ui.hideBanner: true` 同时跳过 A、B 两个区块 —— 形态与现有屏读模式开关一致。`` 仍由独立的 `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 diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index c9b11716f..ced6393fd 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 27f62e1a0..392d9f74b 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -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('', () => { 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(), diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index 5938c1c6b..5f88fd211 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -80,6 +80,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => { workingDirectory={targetDir} customAsciiArt={resolvedBanner?.asciiArt} customBannerTitle={resolvedBanner?.title} + customBannerSubtitle={resolvedBanner?.subtitle} /> )} {showTips && } diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 14f80072f..a8cafb2ad 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -101,6 +101,33 @@ describe('
', () => { expect(lastFrame()).toContain('██╔═══██╗'); }); + it('renders the custom subtitle in place of the blank spacer row', () => { + const { lastFrame } = render( +
, + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Built-in DataWorks Official Skills'); + // Subtitle sits between the title and the auth line. + const titleIdx = frame.indexOf('>_ Qwen Code'); + const subtitleIdx = frame.indexOf('Built-in DataWorks Official Skills'); + const authIdx = frame.indexOf('Qwen OAuth'); + expect(titleIdx).toBeLessThan(subtitleIdx); + expect(subtitleIdx).toBeLessThan(authIdx); + }); + + it('keeps the blank spacer row when no subtitle is set (back-compat)', () => { + const { lastFrame } = render(
); + const frame = lastFrame() ?? ''; + // Title and auth still both render at their usual positions; the + // spacer between them is just whitespace-padding, so we assert the + // visible chrome the user sees. + expect(frame).toContain('>_ Qwen Code'); + expect(frame).toContain('Qwen OAuth'); + }); + it('renders the custom banner title in place of the default brand', () => { const { lastFrame } = render(
, diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index d828f2ef3..14f374764 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -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 = ({ customAsciiArt, customBannerTitle, + customBannerSubtitle, version, authDisplayType, model, @@ -181,8 +188,14 @@ export const Header: React.FC = ({ (v{version}) - {/* Empty line for spacing */} - + {/* Subtitle (when set) replaces the blank spacer row. We always + emit a row here so the auth/model line stays at the same + vertical position regardless of whether the subtitle is set. */} + {customBannerSubtitle ? ( + {customBannerSubtitle} + ) : ( + + )} {/* Auth and Model line */} {authModelText} diff --git a/packages/cli/src/ui/utils/customBanner.test.ts b/packages/cli/src/ui/utils/customBanner.test.ts index acbf9654e..f220856a3 100644 --- a/packages/cli/src/ui/utils/customBanner.test.ts +++ b/packages/cli/src/ui/utils/customBanner.test.ts @@ -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', () => { diff --git a/packages/cli/src/ui/utils/customBanner.ts b/packages/cli/src/ui/utils/customBanner.ts index 77a3b0297..751b7c739 100644 --- a/packages/cli/src/ui/utils/customBanner.ts +++ b/packages/cli/src/ui/utils/customBanner.ts @@ -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, `
` 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(); 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 + * `
` 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; } diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 79c433612..38257e1d4 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -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": [ {