diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 31673e921a..02c6f3bb04 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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 diff --git a/packages/cli/src/ui/commands/colorsCommand.test.ts b/packages/cli/src/ui/commands/colorsCommand.test.ts new file mode 100644 index 0000000000..63e2dc7389 --- /dev/null +++ b/packages/cli/src/ui/commands/colorsCommand.test.ts @@ -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', + }), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/colorsCommand.ts b/packages/cli/src/ui/commands/colorsCommand.ts new file mode 100644 index 0000000000..3ed96a4fbd --- /dev/null +++ b/packages/cli/src/ui/commands/colorsCommand.ts @@ -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); + }, +}; diff --git a/packages/cli/src/ui/components/ColorsDisplay.test.tsx b/packages/cli/src/ui/components/ColorsDisplay.test.tsx new file mode 100644 index 0000000000..38b2ce8f62 --- /dev/null +++ b/packages/cli/src/ui/components/ColorsDisplay.test.tsx @@ -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( + , + ); + 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(); + }); +}); diff --git a/packages/cli/src/ui/components/ColorsDisplay.tsx b/packages/cli/src/ui/components/ColorsDisplay.tsx new file mode 100644 index 0000000000..49d5d4a76b --- /dev/null +++ b/packages/cli/src/ui/components/ColorsDisplay.tsx @@ -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 = { + '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 ( + + + + /colors - Theme Colors Demo + + + 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. + + + + Active Theme:{' '} + + {activeTheme.name} + {' '} + ({activeTheme.type}) + + + + + {/* Header */} + + + + Value + + + + + Name + + + + + Usage + + + + + {/* Standard Section */} + + {standardRows.map((row) => renderStandardRow(row))} + + + {/* Gradient Section */} + {gradientRow && ( + + {renderGradientRow(gradientRow)} + + )} + + {/* Background Section */} + + + + Background Colors + + + {backgroundRows.map((row) => renderBackgroundRow(row))} + + + ); +}; + +function renderStandardRow({ name, value }: StandardColorRow) { + const description = COLOR_DESCRIPTIONS[name] || ''; + const isHex = value.startsWith('#'); + + return ( + + + {value} + + + {name} + + + {description} + + + ); +} + +function renderGradientRow({ name, value }: GradientColorRow) { + const description = COLOR_DESCRIPTIONS[name] || ''; + + return ( + + + {value.map((c, i) => ( + + {c} + {i < value.length - 1 ? ', ' : ''} + + ))} + + + {name} + + + {description} + + + ); +} + +function renderBackgroundRow({ name, value }: BackgroundColorRow) { + const description = COLOR_DESCRIPTIONS[name] || ''; + + return ( + + + + {value || 'default'} + + + + {name} + + + {description} + + + ); +} diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 458452d795..d02c7acfca 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -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 = ({ tier={itemForDisplay.tier} /> )} + {itemForDisplay.type === 'colors' && } {itemForDisplay.type === 'help' && commands && ( )} diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c3f178ad1b..4595307663 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -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', diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 55048ef6bc..d0277965c9 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -360,6 +360,11 @@ export type HistoryItemHooksList = HistoryItemBase & { }>; }; +export type HistoryItemColors = HistoryItemBase & { + type: 'colors'; + timestamp: Date; +}; + // Using Omit 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;