From cc9e65365d61130ee70ae731bd29059cd9d6453e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 26 Apr 2026 17:00:51 +0800 Subject: [PATCH] fix(cli): guard gradient rendering without colors --- .../cli/src/ui/components/Header.test.tsx | 21 +++++++- packages/cli/src/ui/components/Header.tsx | 14 ++++-- .../src/ui/components/StatsDisplay.test.tsx | 48 ++++++++++++++++++- .../cli/src/ui/components/StatsDisplay.tsx | 6 ++- packages/cli/src/ui/utils/gradientUtils.ts | 18 +++++++ 5 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/ui/utils/gradientUtils.ts diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 72da62aba..087618654 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -5,7 +5,7 @@ */ import { render } from 'ink-testing-library'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Header, AuthDisplayType } from './Header.js'; import * as useTerminalSize from '../hooks/useTerminalSize.js'; @@ -20,10 +20,21 @@ const defaultProps = { }; describe('
', () => { + const originalNoColor = process.env['NO_COLOR']; + beforeEach(() => { + delete process.env['NO_COLOR']; useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 }); }); + afterEach(() => { + if (originalNoColor === undefined) { + delete process.env['NO_COLOR']; + } else { + process.env['NO_COLOR'] = originalNoColor; + } + }); + it('renders the ASCII logo on wide terminal', () => { const { lastFrame } = render(
); expect(lastFrame()).toContain('██╔═══██╗'); @@ -81,4 +92,12 @@ describe('
', () => { expect(lastFrame()).toContain('┌'); expect(lastFrame()).toContain('┐'); }); + + it('renders plain text when NO_COLOR disables gradient colors', () => { + process.env['NO_COLOR'] = '1'; + + const { lastFrame } = render(
); + + expect(lastFrame()).toContain('██╔═══██╗'); + }); }); diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 2d919385f..345bb9fe4 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -12,6 +12,7 @@ import { theme } from '../semantic-colors.js'; import { shortAsciiLogo } from './AsciiArt.js'; import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { getRenderableGradientColors } from '../utils/gradientUtils.js'; /** * Auth display type for the Header component. @@ -98,12 +99,11 @@ export const Header: React.FC = ({ ? shortenedPath.slice(0, maxPathLength) : shortenedPath; - // Use theme gradient colors if available, otherwise use text colors (excluding primary) - const gradientColors = theme.ui.gradient || [ + const gradientColors = getRenderableGradientColors(theme.ui.gradient, [ theme.text.secondary, theme.text.link, theme.text.accent, - ]; + ]); return ( = ({ {showLogo && ( <> - + {gradientColors ? ( + + {displayLogo} + + ) : ( {displayLogo} - + )} {/* Fixed gap between logo and info panel */} diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx index b75b3c2dc..0a4540ca2 100644 --- a/packages/cli/src/ui/components/StatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx @@ -5,7 +5,7 @@ */ import { render } from 'ink-testing-library'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { StatsDisplay } from './StatsDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; import type { @@ -14,6 +14,7 @@ import type { SessionMetrics, } from '../contexts/SessionContext.js'; import { MAIN_SOURCE } from '@qwen-code/qwen-code-core'; +import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js'; // Wraps a core metrics object as a ModelMetrics with a single `main` source // bucket, matching the shape produced by processing an API call with no @@ -33,6 +34,21 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => { }); const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); +const originalNoColor = process.env['NO_COLOR']; + +beforeEach(() => { + delete process.env['NO_COLOR']; +}); + +afterEach(() => { + if (originalNoColor === undefined) { + delete process.env['NO_COLOR']; + } else { + process.env['NO_COLOR'] = originalNoColor; + } + themeManager.loadCustomThemes({}); + themeManager.setActiveTheme(DEFAULT_THEME.name); +}); const renderWithMockedStats = (metrics: SessionMetrics) => { useSessionStatsMock.mockReturnValue({ @@ -558,5 +574,35 @@ describe('', () => { expect(output).not.toContain('Session Stats'); expect(output).toMatchSnapshot(); }); + + it('renders a custom title as plain text when the theme has too few gradient colors', () => { + themeManager.loadCustomThemes({ + OneColorGradient: { + name: 'OneColorGradient', + type: 'custom', + ui: { gradient: ['red'] }, + }, + }); + themeManager.setActiveTheme('OneColorGradient'); + useSessionStatsMock.mockReturnValue({ + stats: { + sessionId: 'test-session-id', + sessionStartTime: new Date(), + metrics: zeroMetrics, + lastPromptTokenCount: 0, + promptCount: 5, + }, + + getPromptCount: () => 5, + startNewPrompt: vi.fn(), + }); + + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toContain('Agent powering down. Goodbye!'); + expect(output).not.toContain('Invalid number of stops'); + }); }); }); diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index 2a766f698..e66d4cd19 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -18,6 +18,7 @@ import { USER_AGREEMENT_RATE_HIGH, USER_AGREEMENT_RATE_MEDIUM, } from '../utils/displayUtils.js'; +import { getRenderableGradientColors } from '../utils/gradientUtils.js'; import { computeSessionStats } from '../utils/computeStats.js'; import { flattenModelsBySource } from '../utils/modelsBySource.js'; import { t } from '../../i18n/index.js'; @@ -194,8 +195,9 @@ export const StatsDisplay: React.FC = ({ const renderTitle = () => { if (title) { - return theme.ui.gradient && theme.ui.gradient.length > 0 ? ( - + const gradientColors = getRenderableGradientColors(theme.ui.gradient); + return gradientColors ? ( + {title} diff --git a/packages/cli/src/ui/utils/gradientUtils.ts b/packages/cli/src/ui/utils/gradientUtils.ts new file mode 100644 index 000000000..0c164768a --- /dev/null +++ b/packages/cli/src/ui/utils/gradientUtils.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export function getRenderableGradientColors( + ...candidates: Array +): string[] | undefined { + return candidates.find( + (colors): colors is string[] => + Array.isArray(colors) && + colors.length >= 2 && + colors.every( + (color) => typeof color === 'string' && color.trim().length > 0, + ), + ); +}