mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
fix(cli): guard gradient rendering without colors
This commit is contained in:
parent
a6b0b7e579
commit
cc9e65365d
5 changed files with 98 additions and 9 deletions
|
|
@ -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('██╔═══██╗');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
18
packages/cli/src/ui/utils/gradientUtils.ts
Normal file
18
packages/cli/src/ui/utils/gradientUtils.ts
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue