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 { 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 { Header, AuthDisplayType } from './Header.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js'; import * as useTerminalSize from '../hooks/useTerminalSize.js';
@ -20,10 +20,21 @@ const defaultProps = {
}; };
describe('<Header />', () => { describe('<Header />', () => {
const originalNoColor = process.env['NO_COLOR'];
beforeEach(() => { beforeEach(() => {
delete process.env['NO_COLOR'];
useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 }); 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', () => { it('renders the ASCII logo on wide terminal', () => {
const { lastFrame } = render(<Header {...defaultProps} />); const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('██╔═══██╗'); expect(lastFrame()).toContain('██╔═══██╗');
@ -81,4 +92,12 @@ describe('<Header />', () => {
expect(lastFrame()).toContain('┌'); expect(lastFrame()).toContain('┌');
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 { shortAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js'; import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { getRenderableGradientColors } from '../utils/gradientUtils.js';
/** /**
* Auth display type for the Header component. * Auth display type for the Header component.
@ -98,12 +99,11 @@ export const Header: React.FC<HeaderProps> = ({
? shortenedPath.slice(0, maxPathLength) ? shortenedPath.slice(0, maxPathLength)
: shortenedPath; : shortenedPath;
// Use theme gradient colors if available, otherwise use text colors (excluding primary) const gradientColors = getRenderableGradientColors(theme.ui.gradient, [
const gradientColors = theme.ui.gradient || [
theme.text.secondary, theme.text.secondary,
theme.text.link, theme.text.link,
theme.text.accent, theme.text.accent,
]; ]);
return ( return (
<Box <Box
@ -116,9 +116,13 @@ export const Header: React.FC<HeaderProps> = ({
{showLogo && ( {showLogo && (
<> <>
<Box flexShrink={0}> <Box flexShrink={0}>
<Gradient colors={gradientColors}> {gradientColors ? (
<Gradient colors={gradientColors}>
<Text>{displayLogo}</Text>
</Gradient>
) : (
<Text>{displayLogo}</Text> <Text>{displayLogo}</Text>
</Gradient> )}
</Box> </Box>
{/* Fixed gap between logo and info panel */} {/* Fixed gap between logo and info panel */}
<Box width={logoGap} /> <Box width={logoGap} />

View file

@ -5,7 +5,7 @@
*/ */
import { render } from 'ink-testing-library'; 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 { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js'; import * as SessionContext from '../contexts/SessionContext.js';
import type { import type {
@ -14,6 +14,7 @@ import type {
SessionMetrics, SessionMetrics,
} from '../contexts/SessionContext.js'; } from '../contexts/SessionContext.js';
import { MAIN_SOURCE } from '@qwen-code/qwen-code-core'; 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 // 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 // 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 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) => { const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({ useSessionStatsMock.mockReturnValue({
@ -558,5 +574,35 @@ describe('<StatsDisplay />', () => {
expect(output).not.toContain('Session Stats'); expect(output).not.toContain('Session Stats');
expect(output).toMatchSnapshot(); 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_HIGH,
USER_AGREEMENT_RATE_MEDIUM, USER_AGREEMENT_RATE_MEDIUM,
} from '../utils/displayUtils.js'; } from '../utils/displayUtils.js';
import { getRenderableGradientColors } from '../utils/gradientUtils.js';
import { computeSessionStats } from '../utils/computeStats.js'; import { computeSessionStats } from '../utils/computeStats.js';
import { flattenModelsBySource } from '../utils/modelsBySource.js'; import { flattenModelsBySource } from '../utils/modelsBySource.js';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
@ -194,8 +195,9 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
const renderTitle = () => { const renderTitle = () => {
if (title) { if (title) {
return theme.ui.gradient && theme.ui.gradient.length > 0 ? ( const gradientColors = getRenderableGradientColors(theme.ui.gradient);
<Gradient colors={theme.ui.gradient}> return gradientColors ? (
<Gradient colors={gradientColors}>
<Text bold color={theme.text.primary}> <Text bold color={theme.text.primary}>
{title} {title}
</Text> </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,
),
);
}