diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 280690992..27f62e1a0 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -20,19 +20,27 @@ const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize); const createSettings = (options?: { hideTips?: boolean; hideBanner?: boolean; -}): LoadedSettings => - ({ - merged: { - ui: { - hideTips: options?.hideTips ?? true, - hideBanner: options?.hideBanner, - }, - }, + customBannerTitle?: string; + customAsciiArt?: unknown; +}): LoadedSettings => { + const ui = { + hideTips: options?.hideTips ?? true, + hideBanner: options?.hideBanner, + customBannerTitle: options?.customBannerTitle, + customAsciiArt: options?.customAsciiArt, + }; + return { + merged: { ui }, system: { settings: {}, originalSettings: {}, path: '' }, systemDefaults: { settings: {}, originalSettings: {}, path: '' }, - user: { settings: {}, originalSettings: {}, path: '' }, + user: { + settings: { ui }, + originalSettings: { ui }, + path: '/home/u/.qwen/settings.json', + }, workspace: { settings: {}, originalSettings: {}, path: '' }, - }) as never; + } as never; +}; const createMockConfig = (overrides = {}) => ({ getContentGeneratorConfig: vi.fn(() => ({ authType: undefined })), @@ -108,4 +116,20 @@ describe('', () => { expect(lastFrame()).not.toContain('>_ Qwen Code'); expect(lastFrame()).not.toContain('██╔═══██╗'); }); + + 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('██╔═══██╗'); + }); }); diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 42e88bb71..14f80072f 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -137,13 +137,27 @@ describe('
', () => { expect(lastFrame()).not.toContain('X'.repeat(60)); }); - it('falls back to the default Qwen logo when no custom tier fits', () => { + 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(
, ); - expect(lastFrame()).toContain('██╔═══██╗'); + 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(
); + // 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'); }); }); diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index b92132ead..d828f2ef3 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -74,8 +74,14 @@ export const Header: React.FC = ({ terminalWidth - containerMarginX * 2, ); - // Pick the widest custom tier that fits. If neither tier fits or no - // custom art was provided, fall through to the bundled `shortAsciiLogo`. + // Two distinct fallback paths: + // - User supplied a custom tier and at least one tier fits → render that. + // - User supplied custom art but neither tier fits → hide the logo column. + // Falling back to the bundled QWEN logo here would silently undo a + // white-label deployment on narrow terminals. + // - User supplied no custom art → fall through to `shortAsciiLogo` and let + // the existing width gate decide whether to show or hide it. + const hasCustomArt = Boolean(customAsciiArt?.small || customAsciiArt?.large); const customTier = pickAsciiArtTier( customAsciiArt?.small, customAsciiArt?.large, @@ -84,13 +90,14 @@ export const Header: React.FC = ({ minInfoPanelWidth, getAsciiArtWidth, ); - const displayLogo = customTier ?? shortAsciiLogo; + const displayLogo = customTier ?? (hasCustomArt ? '' : shortAsciiLogo); const logoWidth = getAsciiArtWidth(displayLogo); // Check if we have enough space for logo + gap + minimum info panel. - // For the default logo this can still be false on very narrow terminals; - // for a custom tier we already proved it fits inside `pickAsciiArtTier`. + // When `displayLogo` is empty (custom art too wide for both tiers) showLogo + // will be false, hiding the column entirely. const showLogo = + displayLogo !== '' && availableTerminalWidth >= logoWidth + logoGap + minInfoPanelWidth; // Calculate available width for info panel (use all remaining space) diff --git a/packages/cli/src/ui/utils/customBanner.test.ts b/packages/cli/src/ui/utils/customBanner.test.ts index b682d2370..275750f76 100644 --- a/packages/cli/src/ui/utils/customBanner.test.ts +++ b/packages/cli/src/ui/utils/customBanner.test.ts @@ -8,12 +8,12 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - pickAsciiArtTier, - resolveCustomBanner, -} from './customBanner.js'; +import { pickAsciiArtTier, resolveCustomBanner } from './customBanner.js'; import type { LoadedSettings, SettingsFile } from '../../config/settings.js'; -import type { CustomAsciiArtSetting, Settings } from '../../config/settingsSchema.js'; +import type { + CustomAsciiArtSetting, + Settings, +} from '../../config/settingsSchema.js'; function makeSettings(opts: { workspaceUi?: Settings['ui']; @@ -46,7 +46,10 @@ function makeSettings(opts: { : empty, systemDefaults: empty, user: opts.userUi - ? file({ ui: opts.userUi }, opts.userPath ?? '/home/u/.qwen/settings.json') + ? file( + { ui: opts.userUi }, + opts.userPath ?? '/home/u/.qwen/settings.json', + ) : empty, workspace: opts.workspaceUi ? file( @@ -156,6 +159,20 @@ describe('resolveCustomBanner', () => { expect(out.asciiArt.small).toContain('ART'); }); + it('strips C1 control characters (0x80-0x9f) including single-byte CSI', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customAsciiArt: 'A\x9b31mB\x9c\x90X' }, + }), + ); + // 0x9b (single-byte CSI), 0x9c (ST), 0x90 (DCS) are all C1 — must be + // replaced with space, not interpreted by the terminal. + expect(out.asciiArt.small).not.toMatch(/[\x80-\x9f]/); + expect(out.asciiArt.small).toContain('A'); + expect(out.asciiArt.small).toContain('B'); + expect(out.asciiArt.small).toContain('X'); + }); + it('strips OSC-8 hyperlinks from inline art', () => { const out = resolveCustomBanner( makeSettings({ @@ -174,7 +191,11 @@ describe('resolveCustomBanner', () => { userUi: { customAsciiArt: 'line1\nline2\nline3' }, }), ); - expect(out.asciiArt.small?.split('\n')).toEqual(['line1', 'line2', 'line3']); + expect(out.asciiArt.small?.split('\n')).toEqual([ + 'line1', + 'line2', + 'line3', + ]); }); it('caps art at 200 lines × 200 cols', () => { @@ -191,6 +212,39 @@ describe('resolveCustomBanner', () => { expect(out2.asciiArt.small?.length).toBe(200); }); + it('reads from an absolute path verbatim', () => { + const file = path.join(tmpDir, 'absolute.txt'); + fs.writeFileSync(file, 'ABS\nART'); + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { path: file } as CustomAsciiArtSetting, + }, + userPath: '/some/other/dir/settings.json', + }), + ); + expect(out.asciiArt.small).toBe('ABS\nART'); + }); + + it('refuses to follow a symlink at the configured path on POSIX', () => { + if (process.platform === 'win32') return; + const real = path.join(tmpDir, 'real.txt'); + fs.writeFileSync(real, 'REAL\nART'); + const link = path.join(tmpDir, 'link.txt'); + fs.symlinkSync(real, link); + const out = resolveCustomBanner( + makeSettings({ + userUi: { + customAsciiArt: { path: 'link.txt' } as CustomAsciiArtSetting, + }, + userPath: path.join(tmpDir, 'settings.json'), + }), + ); + // O_NOFOLLOW makes openSync throw ELOOP — resolver soft-fails, so the + // tier ends up undefined rather than reading through the symlink. + expect(out.asciiArt.small).toBeUndefined(); + }); + it('falls back when the {path} target is missing', () => { const out = resolveCustomBanner( makeSettings({ @@ -272,6 +326,17 @@ describe('resolveCustomBanner', () => { expect(out.title).toBe('Acme CLI'); }); + it('strips C1 control characters from the title', () => { + const out = resolveCustomBanner( + makeSettings({ + userUi: { customBannerTitle: 'Acme\x9b31m CLI\x9c' }, + }), + ); + expect(out.title).not.toMatch(/[\x80-\x9f]/); + expect(out.title).toContain('Acme'); + expect(out.title).toContain('CLI'); + }); + it('caps the title at 80 characters', () => { const out = resolveCustomBanner( makeSettings({ @@ -320,12 +385,12 @@ describe('pickAsciiArtTier', () => { }); it('skips missing tiers', () => { - expect( - pickAsciiArtTier(undefined, 'fits', 100, 2, 40, measure), - ).toBe('fits'); - expect( - pickAsciiArtTier('fits', undefined, 100, 2, 40, measure), - ).toBe('fits'); + expect(pickAsciiArtTier(undefined, 'fits', 100, 2, 40, measure)).toBe( + 'fits', + ); + expect(pickAsciiArtTier('fits', undefined, 100, 2, 40, measure)).toBe( + 'fits', + ); expect( pickAsciiArtTier(undefined, undefined, 100, 2, 40, measure), ).toBeUndefined(); diff --git a/packages/cli/src/ui/utils/customBanner.ts b/packages/cli/src/ui/utils/customBanner.ts index 401c14c12..0ae1b365a 100644 --- a/packages/cli/src/ui/utils/customBanner.ts +++ b/packages/cli/src/ui/utils/customBanner.ts @@ -43,9 +43,7 @@ type CacheEntry = { value: string | undefined }; * and falls back to the locked default for that field. The CLI must never * crash on a banner config error. */ -export function resolveCustomBanner( - settings: LoadedSettings, -): ResolvedBanner { +export function resolveCustomBanner(settings: LoadedSettings): ResolvedBanner { const ui = settings.merged.ui; const cache = new Map(); @@ -59,9 +57,11 @@ export function resolveCustomBanner( return { asciiArt: { small: - scoped.small && resolveTier(scoped.small.source, scoped.small.dir, cache), + scoped.small && + resolveTier(scoped.small.source, scoped.small.dir, cache), large: - scoped.large && resolveTier(scoped.large.source, scoped.large.dir, cache), + scoped.large && + resolveTier(scoped.large.source, scoped.large.dir, cache), }, title, }; @@ -261,8 +261,10 @@ function sanitizeArt(input: string): string { s = s.replace(/\x1b\[[\d;?]*[a-zA-Z]/g, ' '); // SS2/SS3/DCS leaders s = s.replace(/\x1b[NOP]/g, ' '); - // Remaining C0/C1 controls + DEL → space, but keep \n and \t. - s = s.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ' '); + // Remaining C0 controls + DEL + C1 controls (0x80-0x9f, e.g. single-byte + // CSI 0x9b) → space. Keep \n (0x0a) and \t (0x09) so multi-line ASCII art + // and tab-aligned art survive. + s = s.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ' '); /* eslint-enable no-control-regex */ const rawLines = s.split('\n'); @@ -293,9 +295,7 @@ function sanitizeArt(input: string): string { if (cappedLines.length === 0) return ''; if (truncatedRows) { - debugLogger.warn( - `Truncated ui.customAsciiArt to ${MAX_ART_LINES} lines.`, - ); + debugLogger.warn(`Truncated ui.customAsciiArt to ${MAX_ART_LINES} lines.`); } if (truncatedCols) { debugLogger.warn( @@ -313,7 +313,10 @@ function sanitizeTitle(raw: unknown): string | undefined { .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, ' ') .replace(/\x1b\[[\d;?]*[a-zA-Z]/g, ' ') .replace(/\x1b[NOP]/g, ' ') - .replace(/[\x00-\x1f\x7f]/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. + .replace(/[\x00-\x1f\x7f-\x9f]/g, ' '); /* eslint-enable no-control-regex */ t = t.replace(/\s+/g, ' ').trim(); if (!t) return undefined; diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 3d866b793..2b9d7bf38 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -273,8 +273,38 @@ }, "customAsciiArt": { "description": "Replace the default QWEN ASCII art. Accepts an inline string, {\"path\": \"...\"}, or {\"small\": ..., \"large\": ...} for width-aware selection.", - "type": "object", - "additionalProperties": true + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "path": { "type": "string" }, + "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 + } + ] } } },