diff --git a/apps/tui/THEMES.md b/apps/tui/THEMES.md new file mode 100644 index 0000000..48d71e5 --- /dev/null +++ b/apps/tui/THEMES.md @@ -0,0 +1,24 @@ +# TUI themes + +DataFoundry separates theme presets from semantic color usage: + +- `src/ui/themes/types.ts` defines the semantic token contract. +- `src/ui/themes/presets.ts` contains complete theme presets. +- `src/ui/themes/theme-manager.ts` selects the active preset. +- `src/ui/theme.ts` exposes dynamic semantic accessors to components. + +Components should use semantic groups instead of literal colors: + +- `inkColors`: compatibility layer for the main canvas, structure, text, and status. +- `selectionColors`: slash completion, session picker, resource picker, and output picker. +- `tuiTheme`: full semantic token tree for new components. + +The default preset is `mist-dark`. To switch at startup: + +```bash +datafoundry-tui --theme legacy-dark +DATAFOUNDRY_TUI_THEME=legacy-dark datafoundry-tui +``` + +To add a style, define one complete `TuiThemePreset` in `presets.ts` and append it +to `builtInThemes`. Components consuming semantic tokens do not need to change. diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index fb91e79..10e6f89 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -10,6 +10,7 @@ import { store } from "./state/store.js"; import { installTerminalRedrawOptimizer } from "./terminal-redraw-optimizer.js"; import { withAlternateScreen } from "./terminal-screen.js"; import { App } from "./ui/App.js"; +import { themeManager } from "./ui/themes/theme-manager.js"; const args = process.argv.slice(2); const STARTUP_PREFLIGHT_TIMEOUT_MS = 1200; @@ -28,6 +29,8 @@ Options: (default: backend run-defaults; demo: api-duckdb-demo) --agent Agent name (default: dataFoundry) + --theme TUI color theme + (mist-dark or legacy-dark; env: DATAFOUNDRY_TUI_THEME) --resume [sessionId] Resume the latest server session, or a specific session --demo Show mock messages and use a local mock stream --help, -h Show this help message @@ -36,6 +39,7 @@ Examples: datafoundry-tui datafoundry-tui --runtime-url http://localhost:8787/api/copilotkit datafoundry-tui --datasource-id my-database + datafoundry-tui --theme mist-dark datafoundry-tui --resume datafoundry-tui --resume thread-001 datafoundry-tui --demo @@ -60,6 +64,13 @@ function getOptionalArg(name: string): string | undefined { return next && !next.startsWith("-") ? next : undefined; } +const requestedTheme = getOptionalArg("--theme") ?? process.env.DATAFOUNDRY_TUI_THEME; +if (requestedTheme && !themeManager.setActiveTheme(requestedTheme)) { + const availableThemes = themeManager.getAvailableThemes().map((theme) => theme.name).join(", "); + console.error(`Unknown TUI theme "${requestedTheme}". Available themes: ${availableThemes}.`); + process.exit(1); +} + function resolveResumeRequest(): { enabled: boolean; sessionId?: string | undefined } | undefined { if (args.includes("--resume")) { return { diff --git a/apps/tui/src/ui/OutputsView.tsx b/apps/tui/src/ui/OutputsView.tsx index a0eac3e..34d07f0 100644 --- a/apps/tui/src/ui/OutputsView.tsx +++ b/apps/tui/src/ui/OutputsView.tsx @@ -14,7 +14,7 @@ import { isMarkdownArtifact, } from './ArtifactCard.js'; import { textWidth, truncateToWidth } from './text-width.js'; -import { inkColors } from './theme.js'; +import { inkColors, selectionColors } from './theme.js'; interface OutputsViewProps { artifacts: DataArtifact[]; @@ -210,17 +210,17 @@ export const OutputsView: React.FC = ({ {artifacts.length === 0 ? ( - + {truncate('暂无产出。', contentWidth)} - + {truncate('Agent 生成 SQL 结果、图表、报告或文件后会显示在这里。', contentWidth)} ) : ( - + {truncate('最新产出排在前面。上下选择,Enter 查看详情。', contentWidth)} @@ -230,7 +230,7 @@ export const OutputsView: React.FC = ({ const isFirst = index === 0; const isLast = index === visibleArtifacts.length - 1; const prefix = selected - ? '> ' + ? '› ' : isFirst && showScrollUp ? '^ ' : isLast && showScrollDown @@ -250,25 +250,47 @@ export const OutputsView: React.FC = ({ key={artifact.id} flexDirection="column" marginBottom={isLast ? 0 : 1} + backgroundColor={selected + ? selectionColors.selectedBackground + : selectionColors.background} > - + {prefix} - + {title} - + {truncate(typeLabel, Math.max(1, titleWidth - textWidth(title)))} - + {truncate(artifact.summary, summaryWidth)} - + {truncate(artifactMetadata(artifact, events), metadataWidth)} @@ -333,7 +355,7 @@ export const OutputsSidebar: React.FC<{ - {'-'.repeat(separatorWidth)} + {'-'.repeat(separatorWidth)} @@ -392,7 +414,7 @@ export const OutputsSidebar: React.FC<{ - {'-'.repeat(separatorWidth)} + {'-'.repeat(separatorWidth)} @@ -425,7 +447,7 @@ const OutputsDetailView: React.FC<{ #{index + 1} - + {truncate(sourceLabel(artifact, events), Math.max(1, contentWidth - 4))} @@ -658,20 +680,21 @@ export const OutputsScreen: React.FC = ({ - {headerTitle} - + {headerTitle} + {truncate(headerSuffix, Math.max(1, contentWidth - textWidth(headerTitle)))} - {'-'.repeat(separatorWidth)} + {'-'.repeat(separatorWidth)} @@ -698,11 +721,11 @@ export const OutputsScreen: React.FC = ({ - {'-'.repeat(separatorWidth)} + {'-'.repeat(separatorWidth)} - + {truncate(footerText, contentWidth)} diff --git a/apps/tui/src/ui/ResourcePicker.tsx b/apps/tui/src/ui/ResourcePicker.tsx index bf6e45e..9408b58 100644 --- a/apps/tui/src/ui/ResourcePicker.tsx +++ b/apps/tui/src/ui/ResourcePicker.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Box, Text, useInput } from 'ink'; -import { inkColors } from './theme.js'; +import { inkColors, selectionColors } from './theme.js'; export interface ResourcePickerItem { id: string; @@ -133,7 +133,8 @@ export const ResourcePicker: React.FC = ({ = ({ paddingY={1} marginX={fullscreen ? 0 : 1} > - {title} + {title} - Type to search: - {query} + Type to search: + {query} @@ -162,13 +163,13 @@ export const ResourcePicker: React.FC = ({ overflow={fullscreen ? 'hidden' : undefined} > {loading ? ( - Loading... + Loading... ) : error ? ( {error} ) : items.length === 0 ? ( - {emptyMessage} + {emptyMessage} ) : filteredItems.length === 0 ? ( - No items match "{query}". + No items match "{query}". ) : ( visibleItems.map((item, index) => { const absoluteIndex = windowStart + index; @@ -177,21 +178,45 @@ export const ResourcePicker: React.FC = ({ const titleText = truncate(item.name || item.id, titleMaxWidth); const idText = truncate(item.id, idMaxWidth); const detailText = item.detail ? truncate(item.detail, detailMaxWidth) : ''; - const itemColor = selected ? inkColors.accent : item.enabled === false ? inkColors.muted : inkColors.text; + const itemColor = selected + ? selectionColors.selectedTitle + : item.enabled === false + ? selectionColors.disabled + : selectionColors.title; return ( - + - {selected ? '>' : ' '} - {stateMarker} + + {selected ? '› ' : ' '} + + + {stateMarker}{' '} + {titleText} - ({idText}) + + {' '}({idText}) + {(item.description || detailText) ? ( - + {truncate(item.description ?? detailText, descriptionMaxWidth)} {item.description && detailText ? ` - ${detailText}` : ''} @@ -204,7 +229,9 @@ export const ResourcePicker: React.FC = ({ - Up/Down Navigate - Enter Select - Esc Cancel - * active, + enabled + + Up/Down Navigate - Enter Select - Esc Cancel - * active, + enabled + ); diff --git a/apps/tui/src/ui/SessionPicker.tsx b/apps/tui/src/ui/SessionPicker.tsx index 186973a..ed8d847 100644 --- a/apps/tui/src/ui/SessionPicker.tsx +++ b/apps/tui/src/ui/SessionPicker.tsx @@ -3,7 +3,7 @@ import { Box, Text, useInput } from 'ink'; import type { SessionListItem } from '../config/index.js'; import { isMouseInput } from '../input/mouse-wheel.js'; import { textWidth, truncateToWidth } from './text-width.js'; -import { inkColors } from './theme.js'; +import { inkColors, selectionColors } from './theme.js'; interface SessionPickerProps { sessions: SessionListItem[]; @@ -154,17 +154,18 @@ export const SessionPicker: React.FC = ({ - + {titleText} {matchText ? ( - + {truncate(matchText, Math.max(1, contentWidth - textWidth(titleText)))} ) : null} @@ -173,24 +174,26 @@ export const SessionPicker: React.FC = ({ {query ? ( <> - Search: - {truncate(query, queryWidth)} + Search: + + {truncate(query, queryWidth)} + ) : ( - + {truncate('Type to search', contentWidth)} )} - {'-'.repeat(separatorWidth)} + {'-'.repeat(separatorWidth)} {loading ? ( - + {truncate('Loading recent sessions...', contentWidth)} @@ -202,13 +205,13 @@ export const SessionPicker: React.FC = ({ ) : sessions.length === 0 ? ( - + {truncate('No server sessions found.', contentWidth)} ) : filteredSessions.length === 0 ? ( - + {truncate(`No sessions match "${query}".`, contentWidth)} @@ -219,7 +222,7 @@ export const SessionPicker: React.FC = ({ const isFirst = index === 0; const isLast = index === visibleSessions.length - 1; const prefix = selected - ? '> ' + ? '› ' : isFirst && showScrollUp ? '^ ' : isLast && showScrollDown @@ -238,17 +241,29 @@ export const SessionPicker: React.FC = ({ key={session.threadId} flexDirection="column" marginBottom={isLast ? 0 : 1} + backgroundColor={selected + ? selectionColors.selectedBackground + : selectionColors.background} > - + {prefix} - + {title} - + {metadata} @@ -259,11 +274,11 @@ export const SessionPicker: React.FC = ({ - {'-'.repeat(separatorWidth)} + {'-'.repeat(separatorWidth)} - + {truncate('Up/Down/j/k navigate - Enter resume - Esc cancel', contentWidth)} diff --git a/apps/tui/src/ui/SlashCommandPopover.tsx b/apps/tui/src/ui/SlashCommandPopover.tsx index eaf552e..f8390ba 100644 --- a/apps/tui/src/ui/SlashCommandPopover.tsx +++ b/apps/tui/src/ui/SlashCommandPopover.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Box, Text } from 'ink'; -import { inkColors } from './theme.js'; +import { selectionColors } from './theme.js'; export interface SlashCommandItem { name: string; @@ -57,11 +57,18 @@ export const SlashCommandPopover: React.FC = ({ if (commands.length === 0) { return ( - No matching commands + No matching commands ); } @@ -83,9 +90,16 @@ export const SlashCommandPopover: React.FC = ({ return ( {visibleCommands.map((command, rowIndex) => { const isActive = offset + rowIndex === selectedIndex; @@ -96,23 +110,32 @@ export const SlashCommandPopover: React.FC = ({ width="100%" height={1} paddingRight={1} + backgroundColor={isActive + ? selectionColors.selectedBackground + : selectionColors.background} > - - {isActive ? '› ' : ' '} - + {isActive + ? + : } + + + / - + - /{command.name} + {command.name} {command.description ?? ''} diff --git a/apps/tui/src/ui/components/ConfigView.tsx b/apps/tui/src/ui/components/ConfigView.tsx index 6211d1b..c3ff7d4 100644 --- a/apps/tui/src/ui/components/ConfigView.tsx +++ b/apps/tui/src/ui/components/ConfigView.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Box, Text } from 'ink'; -import { inkColors } from '../theme.js'; +import { inkColors, selectionColors } from '../theme.js'; // Configuration types export type ConfigType = 'datasources' | 'models' | 'skills' | 'mcp' | 'kb'; @@ -154,7 +154,7 @@ export const ConfigView: React.FC = ({ [{TAB_NAMES[tabKey]}] @@ -183,7 +183,7 @@ export const ConfigView: React.FC = ({ {/* Config item header */} {/* Active indicator */} - + {item.active ? '● ' : '○ '} @@ -193,7 +193,7 @@ export const ConfigView: React.FC = ({ {/* Name */} - + {item.name} diff --git a/apps/tui/src/ui/components/EnhancedInputBox.test.tsx b/apps/tui/src/ui/components/EnhancedInputBox.test.tsx index aeb80a5..cb2c05d 100644 --- a/apps/tui/src/ui/components/EnhancedInputBox.test.tsx +++ b/apps/tui/src/ui/components/EnhancedInputBox.test.tsx @@ -2,10 +2,16 @@ import React from 'react'; import assert from 'node:assert/strict'; import { PassThrough } from 'node:stream'; import { afterEach, describe, it } from 'node:test'; +import chalk from 'chalk'; import { Box, render } from 'ink'; import { CommandHistory } from '../keybindings.js'; +import { OutputsScreen } from '../OutputsView.js'; +import { ResourcePicker } from '../ResourcePicker.js'; +import { SessionPicker } from '../SessionPicker.js'; import { SlashCommandPopover } from '../SlashCommandPopover.js'; import { StatusBar } from '../StatusBar.js'; +import { selectionColors } from '../theme.js'; +import { themeManager } from '../themes/theme-manager.js'; import { EnhancedInputBox, inputLineFragments } from './EnhancedInputBox.js'; const waitForInput = () => new Promise((resolve) => setImmediate(resolve)); @@ -253,19 +259,27 @@ describe('EnhancedInputBox history navigation', () => { }); describe('EnhancedInputBox slash command menu', () => { - it('inherits the terminal background instead of painting a color block', async () => { - const view = renderInputBox( - , - ); - await waitForInput(); - - assert.doesNotMatch(view.output.join(''), /\u001B\[48(?:;\d+)*m/); + it('paints a solid surface behind the overlaid command rows', async () => { + const previousColorLevel = chalk.level; + chalk.level = 3; + try { + const view = renderInputBox( + , + ); + await waitForInput(); + + const output = view.output.join(''); + assert.match(output, /\u001B\[48;2;17;23;25m/); + assert.match(output, /\u001B\[48;2;27;39;44m/); + } finally { + chalk.level = previousColorLevel; + } }); it('keeps the composer height fixed while the menu overlays the chat viewport', async () => { @@ -465,3 +479,90 @@ describe('StatusBar', () => { assert.doesNotMatch(output, /model: /); }); }); + +describe('shared selection theme', () => { + it('uses the same mist palette for session, resource, and output selection screens', async () => { + const previousColorLevel = chalk.level; + chalk.level = 3; + try { + const sessionView = renderInputBox( + {}} + onCancel={() => {}} + />, + ); + const resourceView = renderInputBox( + {}} + onCancel={() => {}} + />, + ); + const outputsView = renderInputBox( + {}} + />, + ); + await waitForInput(); + await waitForInput(); + + for (const output of [ + sessionView.output.join(''), + resourceView.output.join(''), + outputsView.output.join(''), + ]) { + assert.match(output, /\u001B\[48;2;17;23;25m/); + assert.match(output, /\u001B\[48;2;27;39;44m/); + assert.match(output, /\u001B\[38;2;121;165;169m/); + } + } finally { + chalk.level = previousColorLevel; + } + }); + + it('switches all semantic colors through one preset manager', () => { + assert.equal(themeManager.setActiveTheme('legacy'), true); + assert.equal(selectionColors.accent, '#6CA8E8'); + assert.equal(selectionColors.background, '#121820'); + + assert.equal(themeManager.setActiveTheme('mist-dark'), true); + assert.equal(selectionColors.accent, '#79A5A9'); + assert.equal(selectionColors.background, '#111719'); + assert.equal(themeManager.setActiveTheme('unknown-theme'), false); + }); +}); diff --git a/apps/tui/src/ui/components/MarkdownView.tsx b/apps/tui/src/ui/components/MarkdownView.tsx index 15df091..062b874 100644 --- a/apps/tui/src/ui/components/MarkdownView.tsx +++ b/apps/tui/src/ui/components/MarkdownView.tsx @@ -149,9 +149,9 @@ function markdownRows(content: string, width: number): MarkdownRow[] { } case 'quote': pushStyledRows(parseInlineRuns(line.text), width, lineKey, push, { - firstPrefix: { text: '| ', color: 'gray' }, - contPrefix: { text: '| ', color: 'gray' }, - blockColor: 'gray', + firstPrefix: { text: '| ', color: inkColors.muted }, + contPrefix: { text: '| ', color: inkColors.muted }, + blockColor: inkColors.muted, }); return; case 'table': diff --git a/apps/tui/src/ui/theme.ts b/apps/tui/src/ui/theme.ts index 507b840..daa96bc 100644 --- a/apps/tui/src/ui/theme.ts +++ b/apps/tui/src/ui/theme.ts @@ -1,72 +1,59 @@ +import { themeManager } from './themes/theme-manager.js'; +import type { TuiThemeTokens } from './themes/types.js'; + /** - * DataFoundry TUI 统一颜色主题 - * - * 设计原则: - * - 只保留一种主强调色(accent)用于交互和选中状态 - * - 其他颜色仅用于表达状态(success/warning/error) - * - 避免多种颜色同时争夺注意力 - * - 使用低饱和度颜色提升专业感 + * 动态语义主题访问层。组件应优先使用这里的语义分组,而不是直接写色值。 */ - -export const theme = { - // 背景和基础 - background: '#0B0F14', - surface: '#121820', - border: '#27313C', - focus: '#496783', - - // 文本 - text: '#E6EDF3', - emphasis: '#B7C0C8', - muted: '#7D8590', - subtle: '#5F6975', - - // 语义色(低饱和度) - accent: '#6CA8E8', // 主强调色:当前模式、选中项、主要交互 - success: '#88C980', // 成功状态:工具执行成功、连接正常 - warning: '#D8B76A', // 警告状态:仅用于真正需要注意的问题 - error: '#F87171', // 错误状态:失败、错误 -} as const; +export const tuiTheme: TuiThemeTokens = { + get background() { + return themeManager.getTokens().background; + }, + get text() { + return themeManager.getTokens().text; + }, + get border() { + return themeManager.getTokens().border; + }, + get structure() { + return themeManager.getTokens().structure; + }, + get interaction() { + return themeManager.getTokens().interaction; + }, + get status() { + return themeManager.getTokens().status; + }, + get selection() { + return themeManager.getTokens().selection; + }, +}; /** * 颜色使用映射 * 定义了各个UI元素应该使用的颜色 */ export const colorUsage = { - // 标题和品牌 - appTitle: theme.accent, - - // 选中和激活状态 - selectedItem: theme.accent, - activeMode: theme.accent, - activeBorder: theme.accent, - - // 文件和资源名称 - fileName: theme.accent, - resourceName: theme.text, - - // 状态指示器 - connected: theme.success, - running: theme.warning, - completed: theme.success, - failed: theme.error, - idle: theme.muted, - - // 工具调用 - toolSuccess: theme.success, - toolRunning: theme.warning, - toolFailed: theme.error, - toolName: theme.muted, - - // 系统通知(降级处理,不再使用 magenta) - systemNotice: theme.muted, - outputsNotice: theme.muted, - - // 次要信息 - timestamp: theme.muted, - metadata: theme.muted, - dimText: theme.muted, -} as const; + get appTitle() { return tuiTheme.structure.accent; }, + get selectedItem() { return tuiTheme.selection.selectedTitle; }, + get activeMode() { return tuiTheme.interaction.accent; }, + get activeBorder() { return tuiTheme.border.focused; }, + get fileName() { return tuiTheme.structure.accent; }, + get resourceName() { return tuiTheme.text.primary; }, + get connected() { return tuiTheme.status.success; }, + get running() { return tuiTheme.status.warning; }, + get completed() { return tuiTheme.status.success; }, + get failed() { return tuiTheme.status.error; }, + get idle() { return tuiTheme.text.secondary; }, + get toolSuccess() { return tuiTheme.status.success; }, + get toolRunning() { return tuiTheme.status.warning; }, + get toolFailed() { return tuiTheme.status.error; }, + get toolName() { return tuiTheme.text.secondary; }, + get systemNotice() { return tuiTheme.text.secondary; }, + get outputsNotice() { return tuiTheme.text.secondary; }, + get timestamp() { return tuiTheme.text.secondary; }, + get metadata() { return tuiTheme.text.secondary; }, + get dimText() { return tuiTheme.text.secondary; }, +}; /** * Ink 颜色映射。 @@ -75,24 +62,40 @@ export const colorUsage = { * 退回到高饱和的 cyan/yellow/green。 */ export const inkColors = { - background: theme.background, - surface: theme.surface, - border: theme.border, - focus: theme.focus, - accent: theme.accent, - success: theme.success, - warning: theme.warning, - error: theme.error, - emphasis: theme.emphasis, - muted: theme.muted, - subtle: theme.subtle, - text: theme.text, -} as const; + get background() { return tuiTheme.background.canvas; }, + get surface() { return tuiTheme.background.surface; }, + get border() { return tuiTheme.border.default; }, + get focus() { return tuiTheme.border.focused; }, + get accent() { return tuiTheme.structure.accent; }, + get success() { return tuiTheme.status.success; }, + get warning() { return tuiTheme.status.warning; }, + get error() { return tuiTheme.status.error; }, + get emphasis() { return tuiTheme.text.emphasis; }, + get muted() { return tuiTheme.text.secondary; }, + get subtle() { return tuiTheme.text.muted; }, + get text() { return tuiTheme.text.primary; }, +}; + +/** + * 所有命令补全和资源选择界面共享这一语义层。 + */ +export const selectionColors = { + get background() { return tuiTheme.selection.background; }, + get selectedBackground() { return tuiTheme.selection.selectedBackground; }, + get border() { return tuiTheme.selection.border; }, + get heading() { return tuiTheme.selection.heading; }, + get selectedTitle() { return tuiTheme.selection.selectedTitle; }, + get title() { return tuiTheme.selection.title; }, + get accent() { return tuiTheme.selection.accent; }, + get selectedDescription() { return tuiTheme.selection.selectedDescription; }, + get description() { return tuiTheme.selection.description; }, + get disabled() { return tuiTheme.selection.disabled; }, +}; /** * 获取状态对应的颜色 */ -export function getStatusColor(status: string): typeof inkColors[keyof typeof inkColors] { +export function getStatusColor(status: string): string { switch (status) { case 'success': case 'completed': @@ -130,10 +133,12 @@ export function basename(path: string): string { * 颜色使用指南 * * ✅ 推荐使用: - * - accent: 选中项、当前模式、可交互元素、文件名 + * - selectionColors: 命令补全、资源选择、会话选择和产出选择 + * - inkColors.accent: 主界面结构、链接、产出等蓝色结构语义 + * - tuiTheme.interaction.accent: 雾青色操作语义 * - success: 工具执行成功、连接正常 * - warning: 真正需要用户注意的警告(不是所有运行中状态) - * - muted (dimColor): 时间戳、快捷键、系统通知、次要信息 + * - text.secondary / text.muted: 时间戳、快捷键、系统通知、次要信息 * * ❌ 避免使用: * - magenta/purple: 已移除,用 muted 或 accent 替代 @@ -142,10 +147,12 @@ export function basename(path: string): string { * * 示例: * ```tsx - * import { inkColors, getStatusColor } from './theme.js'; + * import { inkColors, selectionColors, getStatusColor } from './theme.js'; * * // 选中项 - * Item + * + * Item + * * * // 状态指示 * diff --git a/apps/tui/src/ui/themes/presets.ts b/apps/tui/src/ui/themes/presets.ts new file mode 100644 index 0000000..a8ec078 --- /dev/null +++ b/apps/tui/src/ui/themes/presets.ts @@ -0,0 +1,108 @@ +import type { TuiThemePreset } from './types.js'; + +/** + * DataFoundry 默认主题:蓝色负责结构,雾青负责操作和选择,冷灰负责说明。 + */ +export const mistDarkTheme: TuiThemePreset = { + name: 'mist-dark', + aliases: ['default', 'mist'], + tokens: { + background: { + canvas: '#0B0F14', + surface: '#121820', + overlay: '#111719', + }, + text: { + primary: '#E6EDF3', + emphasis: '#B7C0C8', + secondary: '#7D8590', + muted: '#5F6975', + disabled: '#586368', + }, + border: { + default: '#27313C', + focused: '#496783', + overlay: '#29383E', + }, + structure: { + accent: '#6CA8E8', + focus: '#496783', + }, + interaction: { + accent: '#79A5A9', + }, + status: { + success: '#88C980', + warning: '#D8B76A', + error: '#F87171', + }, + selection: { + background: '#111719', + selectedBackground: '#1B272C', + border: '#29383E', + heading: '#D5DEDE', + selectedTitle: '#D5DEDE', + title: '#A9B8B9', + accent: '#79A5A9', + selectedDescription: '#98A6A8', + description: '#707C81', + disabled: '#586368', + }, + }, +}; + +/** + * 保留旧版蓝色交互风格,方便回退和对比,也作为新增主题的最小示例。 + */ +export const legacyDarkTheme: TuiThemePreset = { + name: 'legacy-dark', + aliases: ['legacy'], + tokens: { + background: { + canvas: '#0B0F14', + surface: '#121820', + overlay: '#121820', + }, + text: { + primary: '#E6EDF3', + emphasis: '#B7C0C8', + secondary: '#7D8590', + muted: '#5F6975', + disabled: '#5F6975', + }, + border: { + default: '#27313C', + focused: '#496783', + overlay: '#27313C', + }, + structure: { + accent: '#6CA8E8', + focus: '#496783', + }, + interaction: { + accent: '#6CA8E8', + }, + status: { + success: '#88C980', + warning: '#D8B76A', + error: '#F87171', + }, + selection: { + background: '#121820', + selectedBackground: '#18283A', + border: '#27313C', + heading: '#E6EDF3', + selectedTitle: '#E6EDF3', + title: '#B7C0C8', + accent: '#6CA8E8', + selectedDescription: '#B7C0C8', + description: '#7D8590', + disabled: '#5F6975', + }, + }, +}; + +export const builtInThemes = [ + mistDarkTheme, + legacyDarkTheme, +] as const satisfies readonly TuiThemePreset[]; diff --git a/apps/tui/src/ui/themes/theme-manager.ts b/apps/tui/src/ui/themes/theme-manager.ts new file mode 100644 index 0000000..0750448 --- /dev/null +++ b/apps/tui/src/ui/themes/theme-manager.ts @@ -0,0 +1,39 @@ +import { builtInThemes, mistDarkTheme } from './presets.js'; +import type { TuiThemePreset, TuiThemeTokens } from './types.js'; + +const normalizeThemeName = (name: string): string => name.trim().toLowerCase(); + +export class TuiThemeManager { + private activeTheme: TuiThemePreset = mistDarkTheme; + + getActiveTheme(): TuiThemePreset { + return this.activeTheme; + } + + getTokens(): TuiThemeTokens { + return this.activeTheme.tokens; + } + + getAvailableThemes(): readonly TuiThemePreset[] { + return builtInThemes; + } + + setActiveTheme(name: string | undefined): boolean { + if (!name) { + this.activeTheme = mistDarkTheme; + return true; + } + + const normalizedName = normalizeThemeName(name); + const theme = builtInThemes.find((candidate) => { + return normalizeThemeName(candidate.name) === normalizedName + || candidate.aliases?.some((alias) => normalizeThemeName(alias) === normalizedName); + }); + if (!theme) return false; + + this.activeTheme = theme; + return true; + } +} + +export const themeManager = new TuiThemeManager(); diff --git a/apps/tui/src/ui/themes/types.ts b/apps/tui/src/ui/themes/types.ts new file mode 100644 index 0000000..55d0969 --- /dev/null +++ b/apps/tui/src/ui/themes/types.ts @@ -0,0 +1,49 @@ +export interface TuiThemeTokens { + background: { + canvas: string; + surface: string; + overlay: string; + }; + text: { + primary: string; + emphasis: string; + secondary: string; + muted: string; + disabled: string; + }; + border: { + default: string; + focused: string; + overlay: string; + }; + structure: { + accent: string; + focus: string; + }; + interaction: { + accent: string; + }; + status: { + success: string; + warning: string; + error: string; + }; + selection: { + background: string; + selectedBackground: string; + border: string; + heading: string; + selectedTitle: string; + title: string; + accent: string; + selectedDescription: string; + description: string; + disabled: string; + }; +} + +export interface TuiThemePreset { + name: string; + aliases?: readonly string[] | undefined; + tokens: TuiThemeTokens; +} diff --git a/apps/tui/src/ui/transcript-lines.tsx b/apps/tui/src/ui/transcript-lines.tsx index 844808d..8aab4de 100644 --- a/apps/tui/src/ui/transcript-lines.tsx +++ b/apps/tui/src/ui/transcript-lines.tsx @@ -72,11 +72,6 @@ const MAX_REASONING_LINES = 120; const MAX_TOOL_PAYLOAD_LINES = 28; const MAX_TABLE_ROWS = 12; const MIN_TABLE_CELL_WIDTH = 3; -const TOOL_BLOCK_LABEL_COLOR = inkColors.accent; -const TOOL_BLOCK_FIELD_COLOR = inkColors.muted; -const TOOL_BLOCK_VALUE_COLOR = inkColors.text; -const TOOL_BLOCK_MUTED_COLOR = inkColors.muted; -const TOOL_BLOCK_ERROR_COLOR = inkColors.error; const TOOL_DETAIL_INDENT_WIDTH = textWidth(TOOL_DETAIL_INDENT); const STARTUP_BANNER_ART = [ ' ____ _ _____ _ ', @@ -440,7 +435,7 @@ function pushPayloadBlock( key={key} width={blockWidth} segments={[ - { text: label, color: TOOL_BLOCK_LABEL_COLOR, bold: true }, + { text: label, color: inkColors.accent, bold: true }, ]} />, ); @@ -472,7 +467,7 @@ function pushPayloadBlock( segments={[ { text: `${detailIndent}... ${hidden} more lines hidden ...`, - color: TOOL_BLOCK_MUTED_COLOR, + color: inkColors.muted, }, ]} />, @@ -488,20 +483,20 @@ function detailRowSegments(indent: string, row: string): StyledSegment[] { const separatorIndex = row.indexOf(': '); if (separatorIndex <= 0) { return [ - { text: indent, color: TOOL_BLOCK_MUTED_COLOR }, - { text: row, color: TOOL_BLOCK_VALUE_COLOR }, + { text: indent, color: inkColors.muted }, + { text: row, color: inkColors.text }, ]; } const field = row.slice(0, separatorIndex + 1); const value = row.slice(separatorIndex + 2); const fieldName = row.slice(0, separatorIndex).toLowerCase(); - const valueColor = fieldName === 'error' ? TOOL_BLOCK_ERROR_COLOR : TOOL_BLOCK_VALUE_COLOR; + const valueColor = fieldName === 'error' ? inkColors.error : inkColors.text; return [ - { text: indent, color: TOOL_BLOCK_MUTED_COLOR }, - { text: field, color: TOOL_BLOCK_FIELD_COLOR, bold: true }, - { text: ' ', color: TOOL_BLOCK_MUTED_COLOR }, + { text: indent, color: inkColors.muted }, + { text: field, color: inkColors.muted, bold: true }, + { text: ' ', color: inkColors.muted }, { text: value, color: valueColor }, ]; } diff --git a/docs/assets/readme/tui-demo.gif b/docs/assets/readme/tui-demo.gif index 6718d0c..1219088 100644 Binary files a/docs/assets/readme/tui-demo.gif and b/docs/assets/readme/tui-demo.gif differ diff --git a/docs/assets/readme/tui-demo.mp4 b/docs/assets/readme/tui-demo.mp4 index d92a620..39d2c4e 100644 Binary files a/docs/assets/readme/tui-demo.mp4 and b/docs/assets/readme/tui-demo.mp4 differ