Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
776 changes: 776 additions & 0 deletions docs/design/customize-banner-area/customize-banner-area.md

Large diffs are not rendered by default.

720 changes: 720 additions & 0 deletions docs/design/customize-banner-area/customize-banner-area.zh-CN.md

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ export interface SettingDefinition {
options?: readonly SettingEnumOption[];
/** Schema for array items when type is 'array' */
items?: SettingItemDefinition;
/**
* Escape hatch for the JSON Schema generator: when set, this object is
* emitted verbatim under the setting's properties entry instead of the
* shape derived from `type`/`properties`/etc. The `description` is still
* carried forward from the SettingDefinition.
*
* Use sparingly — for most settings the generator's normal mapping is
* preferable so the source schema stays the single source of truth. The
* one valid case so far is settings whose accepted runtime shape is a
* union (e.g. string | { path } | { small, large }) that the
* SettingDefinition `type` field cannot express.
*/
jsonSchemaOverride?: Record<string, unknown>;
}

/**
Expand Down Expand Up @@ -105,6 +118,20 @@ export interface SettingsSchema {
[key: string]: SettingDefinition;
}

/**
* Source for a single tier of custom ASCII art. Either an inline string
* or a reference to a file on disk that contains the art.
*/
export type AsciiArtSource = string | { path: string };

/**
* Setting value for `ui.customAsciiArt`. Accepts a bare source (treated as
* both width tiers), or a width-aware `{small, large}` object.
*/
export type CustomAsciiArtSetting =
| AsciiArtSource
| { small?: AsciiArtSource; large?: AsciiArtSource };

/**
* Common items schema for hook definitions.
* Used by all hook event types in the hooks configuration.
Expand Down Expand Up @@ -728,6 +755,97 @@ const SETTINGS_SCHEMA = {
'Max number of shell output lines shown inline. Set to 0 to disable the cap and show full output. The hidden line count is still surfaced via the `+N lines` indicator.',
showInDialog: true,
},
hideBanner: {
type: 'boolean',
label: 'Hide Banner',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Hide the startup ASCII banner and info panel.',
showInDialog: true,
},
customBannerTitle: {
type: 'string',
label: 'Custom Banner Title',
category: 'UI',
requiresRestart: false,
default: '' as string,
description:
'Replace the default ">_ Qwen Code" title shown in the banner info panel. The version suffix is always appended.',
showInDialog: false,
},
customBannerSubtitle: {
type: 'string',
label: 'Custom Banner Subtitle',
category: 'UI',
requiresRestart: false,
default: '' as string,
description:
'Optional subtitle line rendered between the banner title and the auth/model line. When unset, the info panel keeps its blank spacer row.',
showInDialog: false,
},
customAsciiArt: {
type: 'object',
label: 'Custom ASCII Art',
category: 'UI',
requiresRestart: false,
default: undefined as CustomAsciiArtSetting | undefined,
description:
'Replace the default QWEN ASCII art. Accepts an inline string, {"path": "..."}, or {"small": ..., "large": ...} for width-aware selection.',
showInDialog: false,
// The runtime accepts three shapes (inline string, {path}, or
// {small,large} where each tier is itself string-or-{path}). The
// SettingDefinition `type: 'object'` keeps the in-app dialog out of
// the way (we don't want a multi-line ASCII editor in the TUI), but
// the JSON Schema needs a real union so VS Code stops flagging the
// documented bare-string form.
// The `oneOf` here uses three *mutually exclusive* branches rather
// than one permissive object branch, so VS Code rejects nonsense
// like `{ path, small, large }` (which the runtime would also
// reject — see `normalizeTiers` in `customBanner.ts`).
jsonSchemaOverride: {
oneOf: [
{ type: 'string' },
// Bare `{path}` — no tier keys allowed.
{
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
additionalProperties: false,
},
// Width-aware `{small?, large?}` — `path` not allowed at this
// level; each tier is itself string-or-`{path}`.
{
type: 'object',
properties: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This object schema allows path, small, and large to appear together because it restricts property names but does not make the {path} and tiered shapes mutually exclusive. Runtime behavior is different: normalizeTiers() checks path first and treats { path, small } as a bare {path} source, silently ignoring small. Please split the schema into exclusive object branches and mirror that at runtime by rejecting objects that combine path with tier keys.

— gpt-5.5 via Qwen Code /review

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion, still open from previous review] The schema (and the regenerated settings.schema.json) still allows { "path": "p", "small": "s", "large": "l" } to validate as a single object — additionalProperties: false only constrains which keys may appear, not which combinations. normalizeTiers() matches path first and silently drops small / large, so a misconfigured user gets no error and a result they didn't ask for.

Recommended split: make the second oneOf branch two mutually-exclusive object branches — one with required: ['path'] and additionalProperties: false (so small / large cannot appear), and one with properties: { small, large } and additionalProperties: false (so path cannot appear). Then mirror the runtime: in normalizeTiers, if path and (small | large) co-exist, log the [BANNER] warn and return undefined instead of letting path silently win.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7ccbfae.

The jsonSchemaOverride is now three mutually-exclusive oneOf branches: string, {path} (additionalProperties: false, no tier keys allowed), and {small?, large?} (no path allowed at this level; each tier itself string-or-{path}). normalizeTiers() mirrors that — an object combining path with small / large is now soft-rejected with a [BANNER] warn rather than letting path win and silently drop the tier values. Regression test pins the runtime side.

small: {
oneOf: [
{ type: 'string' },
{
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
additionalProperties: false,
},
],
},
large: {
oneOf: [
{ type: 'string' },
{
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
additionalProperties: false,
},
],
},
},
additionalProperties: false,
},
],
},
},
},
},

Expand Down
73 changes: 66 additions & 7 deletions packages/cli/src/ui/components/AppHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,32 @@ import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);

const createSettings = (options?: { hideTips?: boolean }): LoadedSettings =>
({
merged: {
ui: {
hideTips: options?.hideTips ?? true,
},
const createSettings = (options?: {
hideTips?: boolean;
hideBanner?: boolean;
customBannerTitle?: string;
customBannerSubtitle?: string;
customAsciiArt?: unknown;
}): LoadedSettings => {
const ui = {
hideTips: options?.hideTips ?? true,
hideBanner: options?.hideBanner,
customBannerTitle: options?.customBannerTitle,
customBannerSubtitle: options?.customBannerSubtitle,
customAsciiArt: options?.customAsciiArt,
};
return {
merged: { ui },
system: { settings: {}, originalSettings: {}, path: '' },
systemDefaults: { settings: {}, originalSettings: {}, path: '' },
user: {
settings: { ui },
originalSettings: { ui },
path: '/home/u/.qwen/settings.json',
},
}) as never;
workspace: { settings: {}, originalSettings: {}, path: '' },
} as never;
};

const createMockConfig = (overrides = {}) => ({
getContentGeneratorConfig: vi.fn(() => ({ authType: undefined })),
Expand Down Expand Up @@ -91,4 +109,45 @@ describe('<AppHeader />', () => {
expect(lastFrame()).toContain('gemini-pro');
expect(lastFrame()).toContain('/projects/qwen-code');
});

it('hides the banner when ui.hideBanner is set, but keeps tips intact', () => {
const { lastFrame } = renderWithProviders(
createMockUIState(),
createSettings({ hideTips: false, hideBanner: true }),
);
expect(lastFrame()).not.toContain('>_ Qwen Code');
expect(lastFrame()).not.toContain('██╔═══██╗');
});

it('renders the custom subtitle end-to-end through resolveCustomBanner (replaces the blank spacer between title and auth line)', () => {
const { lastFrame } = renderWithProviders(
createMockUIState(),
createSettings({
customBannerTitle: 'DataWorks DataAgent',
customBannerSubtitle: 'Built-in DataWorks Official Skills',
}),
);
const frame = lastFrame() ?? '';
expect(frame).toContain('DataWorks DataAgent');
expect(frame).toContain('Built-in DataWorks Official Skills');
const titleIdx = frame.indexOf('DataWorks DataAgent');
const subtitleIdx = frame.indexOf('Built-in DataWorks Official Skills');
expect(titleIdx).toBeLessThan(subtitleIdx);
});

it('renders custom banner title and inline ASCII art end-to-end through resolveCustomBanner', () => {
const { lastFrame } = renderWithProviders(
createMockUIState(),
createSettings({
customBannerTitle: 'Acme CLI',
customAsciiArt: ' ACME\n ----',
}),
);
const frame = lastFrame() ?? '';
expect(frame).toContain('Acme CLI');
expect(frame).not.toContain('>_ Qwen Code');
expect(frame).toContain('ACME');
// Default Qwen logo must NOT bleed through when the user supplied art.
expect(frame).not.toContain('██╔═══██╗');
});
});
16 changes: 15 additions & 1 deletion packages/cli/src/ui/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { useMemo } from 'react';
import { Box } from 'ink';
import { AuthType, isCodingPlanConfig } from '@qwen-code/qwen-code-core';
import { Header, AuthDisplayType } from './Header.js';
import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { resolveCustomBanner } from '../utils/customBanner.js';

interface AppHeaderProps {
version: string;
Expand Down Expand Up @@ -50,7 +52,8 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
const authType = contentGeneratorConfig?.authType;
const model = uiState.currentModel;
const targetDir = config.getTargetDir();
const showBanner = !config.getScreenReader();
const showBanner =
!config.getScreenReader() && !settings.merged.ui?.hideBanner;
const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader());

const authDisplayType = getAuthDisplayType(
Expand All @@ -59,6 +62,14 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
contentGeneratorConfig?.apiKeyEnvKey,
);

// Resolve once per (settings identity) — file reads and sanitization are
// not free, and the merged settings reference is stable across renders
// until a settings hot-reload swaps it.
const resolvedBanner = useMemo(
() => (showBanner ? resolveCustomBanner(settings) : undefined),
[showBanner, settings],
);

return (
<Box flexDirection="column">
{showBanner && (
Expand All @@ -67,6 +78,9 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
authDisplayType={authDisplayType}
model={model}
workingDirectory={targetDir}
customAsciiArt={resolvedBanner?.asciiArt}
customBannerTitle={resolvedBanner?.title}
customBannerSubtitle={resolvedBanner?.subtitle}
/>
)}
{showTips && <Tips />}
Expand Down
87 changes: 87 additions & 0 deletions packages/cli/src/ui/components/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,91 @@ describe('<Header />', () => {

expect(lastFrame()).toContain('██╔═══██╗');
});

it('renders the custom subtitle in place of the blank spacer row', () => {
const { lastFrame } = render(
<Header
{...defaultProps}
customBannerSubtitle="Built-in DataWorks Official Skills"
/>,
);
const frame = lastFrame() ?? '';
expect(frame).toContain('Built-in DataWorks Official Skills');
// Subtitle sits between the title and the auth line.
const titleIdx = frame.indexOf('>_ Qwen Code');
const subtitleIdx = frame.indexOf('Built-in DataWorks Official Skills');
const authIdx = frame.indexOf('Qwen OAuth');
expect(titleIdx).toBeLessThan(subtitleIdx);
expect(subtitleIdx).toBeLessThan(authIdx);
});

it('keeps the blank spacer row when no subtitle is set (back-compat)', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
const frame = lastFrame() ?? '';
// Title and auth still both render at their usual positions; the
// spacer between them is just whitespace-padding, so we assert the
// visible chrome the user sees.
expect(frame).toContain('>_ Qwen Code');
expect(frame).toContain('Qwen OAuth');
});

it('renders the custom banner title in place of the default brand', () => {
const { lastFrame } = render(
<Header {...defaultProps} customBannerTitle="Acme CLI" />,
);
expect(lastFrame()).toContain('Acme CLI');
expect(lastFrame()).not.toContain('>_ Qwen Code');
// version suffix is still appended
expect(lastFrame()).toContain('v1.0.0');
});

it('renders the custom large tier when it fits', () => {
const { lastFrame } = render(
<Header
{...defaultProps}
customAsciiArt={{ small: 'SMALL', large: 'LARGE_LOGO' }}
/>,
);
expect(lastFrame()).toContain('LARGE_LOGO');
expect(lastFrame()).not.toContain('██╔═══██╗');
});

it('falls back to the small tier when the large one does not fit', () => {
useTerminalSizeMock.mockReturnValue({ columns: 70, rows: 24 });
const { lastFrame } = render(
<Header
{...defaultProps}
customAsciiArt={{
small: 'sm',
large: 'X'.repeat(60),
}}
/>,
);
expect(lastFrame()).toContain('sm');
expect(lastFrame()).not.toContain('X'.repeat(60));
});

it('hides the logo column when neither custom tier fits — does NOT fall back to the default Qwen logo (preserves white-label intent)', () => {
const { lastFrame } = render(
<Header
{...defaultProps}
customAsciiArt={{ small: 'X'.repeat(150), large: 'Y'.repeat(150) }}
/>,
);
expect(lastFrame()).not.toContain('██╔═══██╗');
expect(lastFrame()).not.toContain('X'.repeat(150));
expect(lastFrame()).not.toContain('Y'.repeat(150));
// Info panel still renders.
expect(lastFrame()).toContain('Qwen OAuth');
});

it('falls back to the default Qwen logo when no custom art was provided at all', () => {
useTerminalSizeMock.mockReturnValue({ columns: 60, rows: 24 });
const { lastFrame } = render(<Header {...defaultProps} />);
// With no customAsciiArt, narrow widths still hide the QWEN logo, but a
// wide enough terminal would show it — the previous test already covers
// the wide case. This one just confirms the no-custom-art path doesn't
// incidentally hide the logo.
expect(lastFrame()).toContain('>_ Qwen Code');
});
});
Loading
Loading