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,
+ ),
+ );
+}