Banner 自定义区域设计方案
允许用户替换 QWEN ASCII Logo、替换品牌标题、整体隐藏 Banner —— 但不允许抹掉用于排障与可信度的运行时信息(版本号、鉴权方式、模型、 工作目录)。
概述
Qwen Code CLI 启动时会在终端顶部打印一个 Banner,包含 QWEN ASCII Logo 和一个带边框的信息面板。多种真实场景需要对这一区域进行控制:
- 白标 / 第三方品牌集成:将 Qwen Code 嵌入企业或团队自有产品时, 需要展示自家品牌而非默认的 “Qwen Code”。
- 个性化:个人用户希望让终端 Banner 与团队规范或个人审美一致。
- 多租户 / 多实例区分:在共享环境下,不同团队希望快速辨认自己 正在使用哪个实例。
设计立场十分简单:品牌外观可替换;运行时信息不可替换。 自定义只允许用户把自己的品牌叠在上面,不允许屏蔽用于排障的关键 信息。本文档后续每一处「可改 / 不可改」的判定都来自这一立场。
对应 issue:#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 与缩短的工作目录。
- B① 标题:
外层 <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 |
| Намеренно не предоставляемые возможности: |
- Нет переключателя для «скрыть только суффикс версии».
- Нет переключателя для «скрыть только строку аутентификации/модели».
- Нет переключателя для «скрыть только строку пути».
- Нет возможности изменить цвет градиента логотипа (цвета определяются темой).
- Нет возможности изменить порядок или структуру информационной панели.
Если в будущем возникнет реальная потребность, её следует выносить как новое поле с отдельной оценкой, а не порождать из трёх перечисленных выше полей.
Руководство пользователя по настройке — как изменять
Обзор ограничений
Каждое изменение баннера подчиняется следующим группам лимитов. Перед тем как писать ASCII-арт вручную, просмотрите их, чтобы парсер не обрезал или не отклонил результат молча.
| Пункт | Лимит |
|---|---|
| Количество символов в заголовке | Максимум 80 символов (счёт после очистки). При превышении обрезается с предупреждением [BANNER]. Символы перевода строки и управляющие удаляются до подсчёта. |
| Количество символов в подзаголовке | Максимум 160 символов (счёт после очистки). Конвейер очистки такой же, как для заголовка; при превышении также выдаётся предупреждение [BANNER]. |
| Размер блока ASCII art | Максимум 200 строк × 200 столбцов на уровень. При превышении обрезается с предупреждением [BANNER]. |
| Размер файла ASCII art | Максимум 64 КБ. Если файл больше, читаются только первые 64 КБ, остальное игнорируется. |
| Реальная ширина рендеринга ASCII art | Определяется шириной терминала при запуске, не фиксированное число символов. Точная формула и доступные значения для разных ширин терминала — см. ниже «Насколько большим может быть логотип? — Бюджет ширины». |
Для ASCII art нет фиксированного лимита по количеству символов — есть только две абсолютные границы по столбцам/строкам выше и бюджет ширины, вычисляемый по ширине терминала при запуске. Одно и то же название бренда из 17 символов может поместиться в одну строку или нет в зависимости от визуальной ширины шрифта, а не от количества букв.
Размещение конфигурации
Все четыре настройки находятся в узле ui файла settings.json. Поддерживаются настройки уровня пользователя (~/.qwen/settings.json) и уровня рабочей области (.qwen/settings.json в корне проекта). Приоритет слияния стандартный (рабочая область переопределяет пользователя, системный уровень переопределяет рабочую область).
customAsciiArt — исключение: парсер не заменяет весь объект целиком значением из более приоритетной области, а проходит по всем областям для каждого уровня (tier). Если настройка пользователя определяет { small }, а настройка рабочей области — { large }, то оба вступают в силу: small берётся от пользователя, large — от рабочей области. Это позволяет одновременно:
- Разрешать
{ path }относительно того файла, где он объявлен (.qwen/рабочей области vs.~/.qwen/пользователя); при простом слиянии без учёта области эта информация теряется. - Пользователь может оставить стандартный уровень
largeв личных настройках, а для конкретной рабочей области переопределить толькоsmall, не переписывая весь объект каждый раз.
Если один и тот же уровень определён в нескольких областях, действует обычный приоритет (системный > рабочая область > пользователь). Если в любой области customAsciiArt задан как простая строка или { path }, то он одновременно заполняет оба уровня для этой области.
Полное скрытие баннера
{
"ui": {
"hideBanner": true,
},
}При запуске пропускаются колонка с логотипом и информационная панель. Если также не установлен ui.hideTips, подсказки всё равно отображаются.
Замена брендового заголовка
{
"ui": {
"customBannerTitle": "Acme CLI",
},
}Информационная панель отобразит Acme CLI (vX.Y.Z). При установке пользовательского заголовка по умолчанию символы >_ не добавляются; если нужно их сохранить, укажите их явно:
"customBannerTitle": ">_ Acme CLI".
Добавление брендового подзаголовка
{
"ui": {
"customBannerSubtitle": "Built-in DataWorks Official Skills",
},
}Подзаголовок отображается отдельной строкой вторичным цветом текста, заменяя стандартную пустую строку-разделитель (которая находится между заголовком и строкой аутентификации/модели):
┌─────────────────────────────────────────────────────────┐
│ DataWorks DataAgent (vX.Y.Z) │ ← B① Заголовок
│ Built-in DataWorks Official Skills │ ← B② Подзаголовок
│ Qwen OAuth | qwen-coder ( /model для переключения) │ ← B③ Состояние
│ ~/projects/example │ ← B④ Путь
└─────────────────────────────────────────────────────────┘Ограничения:
- Только одна строка. Символы перевода строки и другие управляющие байты удаляются или заменяются пробелами, чтобы не нарушать макет панели.
- После очистки — максимум 160 символов (немного больше, чем для заголовка; строки «при поддержке» и т.п. часто длиннее названия бренда).
- Пустое значение (или пустая строка / строка из пробелов) = сохраняется стандартная пустая строка-разделитель — обратная совместимость по умолчанию.
- Подзаголовок не влияет на поведение «закреплённых» строк; аутентификация, модель и рабочий каталог всегда видны независимо от наличия подзаголовка.
Замена ASCII art — встроенная строка
{
"ui": {
"customAsciiArt": " ___ _ _ ____ \n / _ \\| | / |/ _\\\n| |_| | |__| | __/\n \\___/|____|_|___|",
},
}В JSON-строке перевод строки обозначается как \n. На этот ASCII art будет применён текущий градиент темы, как и для стандартного логотипа.
Нет ASCII art под рукой? Подойдёт любой внешний генератор; просто вставьте результат. Проще всего через
figlet:npx figlet -f "ANSI Shadow" "xxxCode" > brand.txt, затем укажитеcustomAsciiArt: { "path": "./brand.txt" }. CLI не выполняет рендеринг текста в ASCII art во время выполнения — причина см. ниже «Что не входит в область видимости».
Замена ASCII art — внешний файл
{
"ui": {
"customAsciiArt": { "path": "./brand.txt" },
},
}Избегайте экранирования больших многострочных строк в JSON. Правила разрешения путей:
- Настройки уровня рабочей области: относительный путь разрешается относительно каталога
.qwen/рабочей области. - Настройки уровня пользователя: относительный путь разрешается относительно
~/.qwen/. - Абсолютные пути используются как есть.
- Файл читается только один раз при запуске, очищается и кэшируется. Изменение файла во время сессии не приводит к перерисовке — перезапустите CLI.
Замена ASCII art — адаптивная ширина
{
"ui": {
"customAsciiArt": {
"small": " ACME\n ----",
"large": { "path": "./brand-wide.txt" },
},
},
}Если терминал достаточно широк, приоритетно используется large; иначе small; если ни один не подходит, колонка логотипа скрывается (по текущей двухколоночной стратегии). small и large могут быть как строкой, так и { path }. Любой уровень можно опустить: при отсутствии переходит к следующему.
Насколько большим может быть логотип? — Бюджет ширины
Ни у заголовка, ни у ASCII art нет «жёсткого лимита по количеству символов» — есть только бюджет ширины, зависящий от ширины терминала, и абсолютные жёсткие лимиты для защиты от деформирующего ввода:
| Пункт | Лимит |
|---|---|
| Ширина терминала при запуске | Сколько сообщит терминал пользователя. |
| Внешние отступы контейнера | 4 колонки (2 слева + 2 справа). |
| Промежуток между колонкой логотипа и информационной панелью | 2 колонки. |
| Минимальная ширина информационной панели | 44 колонки (40 путь + рамки + отступы). |
| Доступная ширина для рендеринга art каждого уровня | ширина_терминала − 4 − 2 − 44 = ширина_терминала − 50. |
| Жёсткий лимит art одного уровня после очистки | 200 колонок × 200 строк. При превышении обрезается с предупреждением [BANNER]. |
Жёсткий лимит customBannerTitle после очистки | 80 символов. При превышении обрезается с предупреждением [BANNER]. |
Соответствие между типичной шириной терминала и максимальной шириной логотипа:
| Ширина терминала | Максимальная ширина рендеринга логотипа | Что это означает на практике |
|---|---|---|
| 80 | 30 | Большинство букв figlet «ANSI Shadow» занимают 7–11 колонок, максимум 3 символа. |
| 100 | 50 | ANSI Shadow для короткого слова (примерно 6 букв) или два слова в стопку. |
| 120 | 70 | Для многострочного art в стопку достаточно. |
| 200 | 150 | Одна длинная строка (например, полное название продукта в ANSI Shadow) тоже поместится. |
| Два эмпирических правила при разработке art: |
- Многословные названия брендов обычно не помещаются на одной строке в терминале при использовании шрифта ANSI Shadow.
ANSI Shadow занимает около 7–9 столбцов на букву, даже для 12-символьного названия вроде
Custom Agentтребуется около 95 столбцов art — в терминале шириной 100 столбцов после размещения информационной панели места уже не хватает. Либо разбивайте слова по строкам, либо используйте более узкий figlet-шрифт, либо применяйте компактное однострочное оформление, например▶ Custom Agent ◀. - Когда один документ должен одновременно «хорошо выглядеть на широком экране» и «не умирать на узком», используйте адаптивную форму с шириной
{ small, large }. В примере нижеlarge— это многострочный art, уложенный для терминалов шириной ≥ 104 столбцов,small— однострочное оформление на 16 столбцов, а если ширина слишком мала для обоих, колонка с логотипом просто скрывается.
{
"ui": {
"customBannerTitle": "Custom Agent",
"customAsciiArt": {
"small": "▶ Custom Agent ◀",
"large": { "path": "./banner-large.txt" },
},
},
}В banner-large.txt помещается уложенный вывод ANSI Shadow (примерно 54 столбца × 12 строк), его можно сгенерировать следующей командой:
( npx figlet -f "ANSI Shadow" CUSTOM
npx figlet -f "ANSI Shadow" AGENT ) > banner-large.txtТройная комбинация
{
"ui": {
"hideBanner": false,
"customBannerTitle": "Acme CLI",
"customAsciiArt": {
"small": " ACME\n ----",
"large": { "path": "./brand-wide.txt" },
},
},
}Как проверить
- Сохраните
settings.json, перезапуститеqwen— разбор баннера выполняется только один раз при запуске. - Измените ширину терминала, убедитесь, что переключение
small/largeработает как ожидается, а на очень узкой ширине колонка с логотипом корректно скрывается. - Если результат не соответствует ожиданиям, посмотрите
~/.qwen/debug/<sessionId>.txt(символическая ссылкаlatest.txtуказывает на текущую сессию), выполните поиск[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 │ Рендеринг колонки Logo
│ ≤ 64 KB │ Рендеринг информационной панели:
│ 3. Очистка art: │ Title = customBannerTitle
│ stripControlSeqs │ ?? '>_ Qwen Code'
│ ≤ 200 строк × 200 │ Subtitle = customBannerSubtitle
│ столбцов │ ?? пустая строка-разделитель
│ 4. Очистка title + │ Status = фиксировано
│ subtitle (одна │ Path = фиксировано
│ строка, ≤ 80 / 160 │
│ символов) │
│ 5. Memoization по │
│ источнику │
└─────────────────────────┘Пятишаговый алгоритм разбора выполняется один раз при загрузке настроек и повторно только при событии горячей перезагрузки настроек:
- Нормализация. Простая
stringили{ path }преобразуется в{ small: x, large: x }. Объект{ small, large }передаётся без изменений. - Разбор по уровням. Для каждого
AsciiArtSource:- строка: используется как есть.
{ path }: синхронное чтение с использованиемO_NOFOLLOWдля защиты от атак на символические ссылки (на Windows откатывается к обычному read-only чтению — эта константа не экспортируется), лимит 64 КБ. Относительный путь считается относительно каталога файла настроек: workspace-настройки — относительно workspace.qwen/, пользовательские — относительно~/.qwen/. Ошибка чтения → warn[BANNER], этот уровень откатывается к умолчанию.
- Очистка. Специализированный stripper для баннера: удаляет управляющие последовательности OSC/CSI/SS2/SS3, заменяет остальные управляющие байты C0/C1 (включая DEL) на пробелы, сохраняя
\nдля многострочного ASCII art. После обрезки хвостовых пробелов в каждой строке обрезается до 200 строк × 200 столбцов, лишнее отбрасывается с выводом warn[BANNER]. - Выбор уровня при рендеринге. В
Header.tsx, имея очищенныеsmallиlarge, в зависимости от доступной ширины (availableTerminalWidth ≥ logoWidth + logoGap + minInfoPanelWidth):- Если
largeпомещается — приоритетlarge. - Иначе если
smallпомещается — откат наsmall. - Иначе если пользователь предоставил кастомный art — колонка Logo просто скрывается (используется ветка
showLogo = false) — в этом случае откат к встроенному логотипу QWEN на узком терминале незаметно нарушит white-label развёртывание. Информационная панель продолжает рендериться. - Иначе (пользователь вообще не предоставил кастомный art) — откат на
shortAsciiLogo, отображаемый в зависимости от ширинного шлюза стандартного логотипа.
- Если
- Запасной вариант. Если оба уровня из-за «мягких» ошибок (отсутствие файла, пустота после очистки, некорректная конфигурация) в итоге пусты или невалидны, рендерится
shortAsciiLogoкак некастомизированный, с ширинным шлюзом стандартного логотипа. CLI ни в коем случае не должен падать из-за неправильной конфигурации баннера.
Псевдокод выбора уровня:
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
}Добавление в схему настроек
В packages/cli/src/config/settingsSchema.ts в объект ui сразу после shellOutputMaxLines добавляются четыре свойства:
hideBanner: {
type: 'boolean',
label: 'Hide Banner',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Скрыть стартовый ASCII-баннер и информационную панель.',
showInDialog: true,
},
customBannerTitle: {
type: 'string',
label: 'Custom Banner Title',
category: 'UI',
requiresRestart: false,
default: '' as string,
description:
'Заменить стандартный заголовок ">_ Qwen Code", отображаемый в информационной панели баннера. Суффикс версии всегда добавляется.',
showInDialog: false,
},
customBannerSubtitle: {
type: 'string',
label: 'Custom Banner Subtitle',
category: 'UI',
requiresRestart: false,
default: '' as string,
description:
'Необязательная строка подзаголовка, отображаемая между заголовком баннера и строкой auth/model. Если не задана, информационная панель сохраняет пустую строку-разделитель.',
showInDialog: false,
},
customAsciiArt: {
type: 'object',
label: 'Custom ASCII Art',
category: 'UI',
requiresRestart: false,
default: undefined,
description:
'Заменить стандартный ASCII-арт QWEN. Принимает встроенную строку, {"path": "..."} или {"small": ..., "large": ...} для выбора в зависимости от ширины.',
showInDialog: false,
// Во время выполнения принимает объединённую форму, которую нельзя выразить через SettingDefinition `type`.
// override выводится генератором JSON-схемы как есть, чтобы VS Code принимал все задокументированные формы (string, {path}, {small,large}) и не подсвечивал простые строки как ошибки.
jsonSchemaOverride: { /* string | {path} | {small,large} oneOf … */ },
},hideBanner использует существующий шаблон hideTips (showInDialog: true);
Остальные три поля произвольного текста (заголовок, подзаголовок, art) не попадают в диалог настроек приложения —
многострочный ASCII-редактор в диалоге TUI — это другой проект, продвинутые пользователи могут напрямую редактировать
settings.json.
Изменения в коде
Изменения минимальны. Ниже указаны файлы и диапазоны строк в текущей ветке main.
packages/cli/src/ui/components/AppHeader.tsx:53 — расширение
showBanner:
const showBanner = !config.getScreenReader() && !settings.merged.ui?.hideBanner;packages/cli/src/ui/components/AppHeader.tsx — передача разобранных
данных Banner в компонент <Header>:
<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:
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 выбирается уровень, с запасным вариантом по умолчанию:
const tier = pickTier(
customAsciiArt?.small,
customAsciiArt?.large,
availableTerminalWidth,
logoGap,
minInfoPanelWidth,
);
const displayLogo = tier ?? shortAsciiLogo;packages/cli/src/ui/components/Header.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 — парсер.
Внешний интерфейс:
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)
{
"ui": {
"customAsciiArt": "...", // string | {path} | {small,large}
"customBannerTitle": "Acme CLI",
"hideBanner": false,
},
}- Результат: минимальный интерфейс для пользователя, полное соответствие описанию issue.
- Преимущества: нулевой порог обучения; очень простая документация; согласованность с существующими плоскими полями
ui.*(hideTips,customWittyPhrasesи т.д.). - Недостатки: три семантически связанных ключа разбросаны на верхнем уровне
ui; если в будущем появятся новые переключатели, специфичные для баннера (градиент, подзаголовок и т.д.), их придётся добавлять как братские поля вui, без естественной группировки.
Вариант 2 — вложенное пространство имён ui.banner
{
"ui": {
"banner": {
"hide": false,
"title": "Acme CLI",
"asciiArt": { "path": "./brand.txt" },
},
},
}- Результат: функциональность идентична варианту 1, сгруппирована по функциям.
- Преимущества: чистое пространство имён для будущих переключателей, специфичных для баннера; лучшая обнаруживаемость в
/settings. - Недостатки: не полностью соответствует синтаксису исходного issue; существующие настройки UI в основном плоские (только
ui.accessibilityиui.statusLineявляются вложенными), нарушение согласованности; дополнительный уровень для запоминания.
Вариант 3 — пресеты профилей баннера + переопределение слотов
{
"ui": {
"bannerProfile": "minimal" | "default" | "branded" | "hidden",
"banner": { /* переопределение слотов для 'branded' */ }
}
}- Результат: пользователь выбирает из именованных пресетов; продвинутые пользователи переопределяют отдельные слоты в выбранном пресете.
- Преимущества: лучший опыт onboarding; пресеты могут поставляться с CLI.
- Недостатки: значительное усложнение; пресеты — это долгосрочное обязательство по поддержке; issue требует открытой кастомизации, а не контроля контента.
Вариант 4 — единый шаблон баннера в виде строки
{
"ui": {
"bannerTemplate": "{{logo}}\n>_ {{title}} ({{version}})\n{{auth}} | {{model}}\n{{path}}",
},
}- Результат: одна свободная строка шаблона с интерполяцией фиксированных полей.
- Преимущества: максимальная гибкость для нестандартных макетов.
- Недостатки: возлагает ответственность за макет на пользователя; теряется устойчивость двухколоночного макета Ink к ширине терминала; легко создать шаблон, который сломается на узких терминалах; слишком большой потенциал разрушения для такой выгоды.
Вариант 5 — API плагинов / хуков
Через систему расширений предоставить хук для рендеринга баннера.
- Результат: кастомизация на уровне кода; расширение может отображать произвольное содержимое.
- Преимущества: максимальная мощность; предприятие может упаковать готовый брендовый плагин.
- Недостатки: огромная поверхность API; произвольный рендеринг терминала требует проверки безопасности; полный овердизайн для этого issue.
Рекомендация
Принять вариант 1. Он напрямую удовлетворяет issue, соответствует существующему стилю ui.* и не будет заблокирован пространством имён до того, как мы чётко определим, какие ещё переключатели, специфичные для баннера, появятся. Если в будущем начнут накапливаться братские поля, миграция на вариант 2 будет аддитивной — ui.banner.title и ui.customBannerTitle могут сосуществовать в период устаревания.
Безопасность и обработка ошибок
Пользовательский контент баннера выводится дословно в терминал, а в случае с формой path также читается с диска. Оба пути достижимы при загрузке вредоносных или поддельных настроек. Та же модель угроз, что и для функции session-title, применима здесь.
| Проблема | Защита |
|---|---|
| Внедрение ANSI / OSC-8 / CSI в ASCII art / заголовок / подзаголовок | Специализированный stripper для баннера (sanitizeArt / sanitizeSingleLine): удаляет ESC-последовательности OSC / CSI / SS2 / SS3, заменяет остальные управляющие байты C0 / C1 (включая DEL) пробелами. Применяется как при рендеринге, так и перед записью в кэш. |
| Заморозка запуска из-за огромного файла | Жёсткое ограничение на чтение файла — 64 КБ. |
| Заморозка макета из-за патологического ASCII art | Максимум 200 строк × 200 столбцов на один результат парсинга; превышение обрезается + предупреждение [BANNER]. |
| Атака с использованием символической ссылки в форме path | Чтение файла с флагом O_NOFOLLOW (на Windows — откат к только для чтения; константа не экспортируется). |
| Файл отсутствует или не читается | Перехват → предупреждение [BANNER] → откат к умолчанию; никогда не выбрасывается в UI. |
| Заголовок / подзаголовок содержит перевод строки или слишком длинный | Символы перевода строки заменяются на пробелы, обрезается до 80 (заголовок) / 160 (подзаголовок) символов. |
| Недоверенная рабочая область влияет на рендеринг или чтение файлов | Если settings.isTrusted равно false, парсер полностью пропускает settings.workspace (согласовано с механизмом доверия для settings.merged). |
| Состояние гонки при горячей перезагрузке настроек | Результат парсинга мемоизируется по источнику (path или строка) в рамках каждого вызова; при 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 должны проходить следующие сквозные проверки:
- Установка в
~/.qwen/settings.jsonпараметраcustomBannerTitle: "Acme CLI"вместе с встроеннымcustomAsciiArt→ при запускеqwenотображается новый заголовок и новый ASCII‑арт; суффикс версии остаётся. - Установка
customBannerSubtitle: "Built-in Acme Skills"→ строка подзаголовка отображается второстепенным цветом между заголовком и строкой аутентификации / модели; аутентификация, модель, путь остаются видимыми. После снятия настройки возвращается пустая строка‑разделитель (обратная совместимость). - Установка
hideBanner: true→ при запускеqwenбаннер не показывается; подсказки и основной контент отображаются как обычно. - В workspace
settings.jsonзаданcustomAsciiArt: { "path": "./brand.txt" }, файлbrand.txtнаходится в том же каталоге.qwen/→ при открытии рабочей области загружается с диска. customAsciiArt: { "small": "...", "large": "..." }→ при изменении размера терминала (широкий / средний / узкий) для широкого используется large, для среднего – small, при узком колонка логотипа скрывается; информационная панель всегда видна.- В
customBannerTitleиcustomBannerSubtitleвставлен\x1b[31mhostile→ оба места отображают текст буквально, без интерпретации как красного цвета. - В
pathуказан несуществующий файл → CLI нормально запускается; в~/.qwen/debug/<sessionId>.txtпоявляется предупреждение[BANNER] warn; отображается стандартный art. - При открытии рабочего дерева с отключённым доверием к рабочей области → предоставленный рабочей областью
customAsciiArt(с элементом{ path }) молча игнорируется; настройки пользователя по‑прежнему действуют.