fix(cli): guard gradient rendering without colors

This commit is contained in:
yiliang114 2026-04-26 17:00:51 +08:00
parent a6b0b7e579
commit cc9e65365d
5 changed files with 98 additions and 9 deletions

View file

@ -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('<Header />', () => {
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(<Header {...defaultProps} />);
expect(lastFrame()).toContain('██╔═══██╗');
@ -81,4 +92,12 @@ describe('<Header />', () => {
expect(lastFrame()).toContain('┌');
expect(lastFrame()).toContain('┐');
});
it('renders plain text when NO_COLOR disables gradient colors', () => {
process.env['NO_COLOR'] = '1';
const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('██╔═══██╗');
});
});

View file

@ -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<HeaderProps> = ({
? 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 (
<Box
@ -116,9 +116,13 @@ export const Header: React.FC<HeaderProps> = ({
{showLogo && (
<>
<Box flexShrink={0}>
<Gradient colors={gradientColors}>
{gradientColors ? (
<Gradient colors={gradientColors}>
<Text>{displayLogo}</Text>
</Gradient>
) : (
<Text>{displayLogo}</Text>
</Gradient>
)}
</Box>
{/* Fixed gap between logo and info panel */}
<Box width={logoGap} />

View file

@ -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('<StatsDisplay />', () => {
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(
<StatsDisplay duration="1s" title="Agent powering down. Goodbye!" />,
);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('Invalid number of stops');
});
});
});

View file

@ -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<StatsDisplayProps> = ({
const renderTitle = () => {
if (title) {
return theme.ui.gradient && theme.ui.gradient.length > 0 ? (
<Gradient colors={theme.ui.gradient}>
const gradientColors = getRenderableGradientColors(theme.ui.gradient);
return gradientColors ? (
<Gradient colors={gradientColors}>
<Text bold color={theme.text.primary}>
{title}
</Text>

View file

@ -0,0 +1,18 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export function getRenderableGradientColors(
...candidates: Array<string[] | undefined>
): 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,
),
);
}