feat(ui): add /colors slash command to demo theme colors

This commit is contained in:
Keith Guerin
2026-02-26 12:50:31 -08:00
parent 703759cfae
commit 8ced1bcf38
8 changed files with 427 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
import { colorsCommand } from '../ui/commands/colorsCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { commandsCommand } from '../ui/commands/commandsCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
@@ -83,6 +84,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
authCommand,
bugCommand,
colorsCommand,
{
...chatCommand,
subCommands: isNightlyBuild

View File

@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { colorsCommand } from './colorsCommand.js';
import { CommandKind, type CommandContext } from './types.js';
describe('colorsCommand', () => {
it('should have the correct metadata', () => {
expect(colorsCommand.name).toBe('colors');
expect(colorsCommand.description).toBe(
'Visualize the current theme colors',
);
expect(colorsCommand.kind).toBe(CommandKind.BUILT_IN);
expect(colorsCommand.autoExecute).toBe(true);
});
it('should add a COLORS message to the UI', () => {
const mockAddItem = vi.fn();
const mockContext = {
ui: {
addItem: mockAddItem,
},
} as unknown as CommandContext;
void colorsCommand.action?.(mockContext, '');
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'colors',
}),
);
});
});

View File

@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import type { HistoryItemColors } from '../types.js';
export const colorsCommand: SlashCommand = {
name: 'colors',
description: 'Visualize the current theme colors',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => {
context.ui.addItem({
type: 'colors',
timestamp: new Date(),
} as HistoryItemColors);
},
};

View File

@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { ColorsDisplay } from './ColorsDisplay.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { themeManager } from '../themes/theme-manager.js';
import type { Theme, ColorsTheme } from '../themes/theme.js';
import type { SemanticColors } from '../themes/semantic-tokens.js';
describe('ColorsDisplay', () => {
beforeEach(() => {
vi.spyOn(themeManager, 'getSemanticColors').mockReturnValue({
text: {
primary: '#ffffff',
secondary: '#cccccc',
link: '#0000ff',
accent: '#ff00ff',
response: '#ffffff',
},
background: {
primary: '#000000',
message: '#111111',
input: '#222222',
diff: {
added: '#003300',
removed: '#330000',
},
},
border: {
default: '#555555',
focused: '#0000ff',
},
ui: {
comment: '#666666',
symbol: '#cccccc',
dark: '#333333',
gradient: undefined,
},
status: {
error: '#ff0000',
success: '#00ff00',
warning: '#ffff00',
},
});
vi.spyOn(themeManager, 'getActiveTheme').mockReturnValue({
name: 'Test Theme',
type: 'dark',
colors: {} as unknown as ColorsTheme,
semanticColors: {} as unknown as SemanticColors,
} as unknown as Theme);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ColorsDisplay />,
);
await waitUntilReady();
const output = lastFrame();
// Check for title and description
expect(output).toContain('/colors - Theme Colors Demo');
expect(output).toContain('visualize how colors are used');
// Check for Background section
expect(output).toContain('Background Colors');
// Check for active theme name
expect(output).toContain('Test Theme');
// Check for some color names and values
expect(output).toContain('text.primary');
expect(output).toContain('#ffffff');
expect(output).toContain('background.diff.added');
expect(output).toContain('#003300');
expect(output).toContain('border.default');
expect(output).toContain('#555555');
// Check for some descriptions
expect(output).toContain('Primary text color');
expect(output).toContain('Standard border color');
unmount();
});
});

View File

@@ -0,0 +1,255 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { themeManager } from '../themes/theme-manager.js';
import { theme } from '../semantic-colors.js';
const COLOR_DESCRIPTIONS: Record<string, string> = {
'text.primary': 'Primary text color',
'text.secondary': 'Secondary/dimmed text color',
'text.link': 'Hyperlink and highlighting color',
'text.accent': 'Accent color for emphasis',
'text.response': 'Color for model response text',
'background.primary': 'Main terminal background color',
'background.message': 'Subtle background for message blocks',
'background.input': 'Background for the input prompt',
'background.diff.added': 'Background for added lines in diffs',
'background.diff.removed': 'Background for removed lines in diffs',
'border.default': 'Standard border color',
'border.focused': 'Border color when an element is focused',
'ui.comment': 'Color for code comments and metadata',
'ui.symbol': 'Color for technical symbols and UI icons',
'ui.dark': 'Deeply dimmed color for subtle UI elements',
'ui.gradient': 'Array of colors used for UI gradients',
'status.error': 'Color for error messages and critical status',
'status.success': 'Color for success messages and positive status',
'status.warning': 'Color for warnings and cautionary status',
};
interface StandardColorRow {
type: 'standard';
name: string;
value: string;
}
interface GradientColorRow {
type: 'gradient';
name: string;
value: string[];
}
interface BackgroundColorRow {
type: 'background';
name: string;
value: string;
}
export const ColorsDisplay: React.FC = () => {
const semanticColors = themeManager.getSemanticColors();
const activeTheme = themeManager.getActiveTheme();
const standardRows: StandardColorRow[] = [];
const backgroundRows: BackgroundColorRow[] = [];
const gradientRow: GradientColorRow | null =
semanticColors.ui.gradient && semanticColors.ui.gradient.length > 0
? {
type: 'gradient',
name: 'ui.gradient',
value: semanticColors.ui.gradient,
}
: null;
// Flatten and categorize the SemanticColors object
for (const [category, subColors] of Object.entries(semanticColors)) {
if (category === 'ui' && 'gradient' in subColors) {
// Handled separately
continue;
}
for (const [name, value] of Object.entries(subColors)) {
const fullName = `${category}.${name}`;
if (value === undefined || value === null) {
continue;
}
if (typeof value === 'object' && !Array.isArray(value)) {
for (const [diffName, diffValue] of Object.entries(value)) {
if (typeof diffValue === 'string') {
if (category === 'background') {
backgroundRows.push({
type: 'background',
name: `${fullName}.${diffName}`,
value: diffValue,
});
} else {
standardRows.push({
type: 'standard',
name: `${fullName}.${diffName}`,
value: diffValue,
});
}
}
}
} else if (typeof value === 'string') {
if (category === 'background') {
backgroundRows.push({
type: 'background',
name: fullName,
value,
});
} else {
standardRows.push({
type: 'standard',
name: fullName,
value,
});
}
}
}
}
return (
<Box flexDirection="column" paddingX={1} marginY={1}>
<Box marginBottom={1} flexDirection="column">
<Text bold color={theme.text.accent}>
/colors - Theme Colors Demo
</Text>
<Text color={theme.text.secondary}>
The purpose of this feature is to visualize how colors are used in the
app, test across a variety of Terminals (Mac Terminal, Ghostty,
iTerm2, VSCode, etc), and see how the colors change across different
themes.
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Active Theme:{' '}
<Text color={theme.text.primary} bold>
{activeTheme.name}
</Text>{' '}
({activeTheme.type})
</Text>
</Box>
</Box>
{/* Header */}
<Box
flexDirection="row"
marginBottom={1}
borderStyle="single"
borderColor={theme.border.default}
paddingX={1}
>
<Box width="15%">
<Text bold color={theme.text.link}>
Value
</Text>
</Box>
<Box width="30%">
<Text bold color={theme.text.link}>
Name
</Text>
</Box>
<Box flexGrow={1}>
<Text bold color={theme.text.link}>
Usage
</Text>
</Box>
</Box>
{/* Standard Section */}
<Box flexDirection="column" marginBottom={1}>
{standardRows.map((row) => renderStandardRow(row))}
</Box>
{/* Gradient Section */}
{gradientRow && (
<Box flexDirection="column" marginBottom={1}>
{renderGradientRow(gradientRow)}
</Box>
)}
{/* Background Section */}
<Box flexDirection="column" marginTop={1}>
<Box marginBottom={1}>
<Text bold color={theme.text.accent}>
Background Colors
</Text>
</Box>
{backgroundRows.map((row) => renderBackgroundRow(row))}
</Box>
</Box>
);
};
function renderStandardRow({ name, value }: StandardColorRow) {
const description = COLOR_DESCRIPTIONS[name] || '';
const isHex = value.startsWith('#');
return (
<Box key={name} flexDirection="row" paddingX={1}>
<Box width="15%">
<Text color={isHex ? value : theme.text.primary}>{value}</Text>
</Box>
<Box width="30%">
<Text color={theme.text.primary}>{name}</Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.text.secondary}>{description}</Text>
</Box>
</Box>
);
}
function renderGradientRow({ name, value }: GradientColorRow) {
const description = COLOR_DESCRIPTIONS[name] || '';
return (
<Box key={name} flexDirection="row" paddingX={1}>
<Box width="15%" flexDirection="row">
{value.map((c, i) => (
<Text key={i} color={c}>
{c}
{i < value.length - 1 ? ', ' : ''}
</Text>
))}
</Box>
<Box width="30%">
<Text color={theme.text.primary}>{name}</Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.text.secondary}>{description}</Text>
</Box>
</Box>
);
}
function renderBackgroundRow({ name, value }: BackgroundColorRow) {
const description = COLOR_DESCRIPTIONS[name] || '';
return (
<Box key={name} flexDirection="row" paddingX={1} marginBottom={1}>
<Box
width="15%"
backgroundColor={value}
justifyContent="center"
paddingX={1}
>
<Text color={theme.text.primary} bold>
{value || 'default'}
</Text>
</Box>
<Box width="30%" paddingLeft={1}>
<Text color={theme.text.primary}>{name}</Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.text.secondary}>{description}</Text>
</Box>
</Box>
);
}

View File

@@ -36,6 +36,7 @@ import { HooksList } from './views/HooksList.js';
import { ModelMessage } from './messages/ModelMessage.js';
import { ThinkingMessage } from './messages/ThinkingMessage.js';
import { HintMessage } from './messages/HintMessage.js';
import { ColorsDisplay } from './ColorsDisplay.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { useSettings } from '../contexts/SettingsContext.js';
@@ -124,6 +125,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
tier={itemForDisplay.tier}
/>
)}
{itemForDisplay.type === 'colors' && <ColorsDisplay />}
{itemForDisplay.type === 'help' && commands && (
<Help commands={commands} />
)}

View File

@@ -172,6 +172,11 @@ export const useSlashCommandProcessor = (
type: 'help',
timestamp: message.timestamp,
};
} else if (message.type === MessageType.COLORS) {
historyItemContent = {
type: 'colors',
timestamp: message.timestamp,
};
} else if (message.type === MessageType.STATS) {
historyItemContent = {
type: 'stats',

View File

@@ -360,6 +360,11 @@ export type HistoryItemHooksList = HistoryItemBase & {
}>;
};
export type HistoryItemColors = HistoryItemBase & {
type: 'colors';
timestamp: Date;
};
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
// 'tools' in historyItem.
@@ -374,6 +379,7 @@ export type HistoryItemWithoutId =
| HistoryItemWarning
| HistoryItemAbout
| HistoryItemHelp
| HistoryItemColors
| HistoryItemToolGroup
| HistoryItemStats
| HistoryItemModelStats
@@ -401,6 +407,7 @@ export enum MessageType {
USER = 'user',
ABOUT = 'about',
HELP = 'help',
COLORS = 'colors',
STATS = 'stats',
MODEL_STATS = 'model_stats',
TOOL_STATS = 'tool_stats',
@@ -442,6 +449,10 @@ export type Message =
timestamp: Date;
content?: string; // Optional content, not really used for HELP
}
| {
type: MessageType.COLORS;
timestamp: Date;
}
| {
type: MessageType.STATS;
timestamp: Date;