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:
秦奇 2026-05-06 11:49:23 +08:00
parent 417e428aaf
commit c7aa4a4019
10 changed files with 289 additions and 43 deletions

View file

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

View file

@ -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 覆盖
usersystem 覆盖 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

View file

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

View file

@ -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(),

View file

@ -80,6 +80,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
workingDirectory={targetDir}
customAsciiArt={resolvedBanner?.asciiArt}
customBannerTitle={resolvedBanner?.title}
customBannerSubtitle={resolvedBanner?.subtitle}
/>
)}
{showTips && <Tips />}

View file

@ -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" />,

View file

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

View file

@ -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', () => {

View file

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

View file

@ -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": [
{