From d8f2dcd7314fe64fcea49e71f51bf57d717681e9 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 28 May 2026 16:09:12 +0800 Subject: [PATCH 01/19] =?UTF-8?q?refactor(ui):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E4=B8=BB=E9=A2=98=E9=A2=9C=E8=89=B2=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3=E7=A1=AC=E7=BC=96=E7=A0=81=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除多个组件和视图中的硬编码颜色,改为使用主题上下文提供的颜色 - 在AppContainer中添加ThemeProvider,初始化全局主题设置 - 优化消息视图、权限提示、提问提示、进程输出、状态列表等组件的颜色适配 - 调整markdown渲染和退出摘要中颜色生成逻辑,支持主题色彩 - 解决部分组件主题切换不更新的问题,确保依赖主题的子组件正确重渲染 - 更新权限提示中的风险颜色映射,改为从主题中读取 - 在PromptInput中支持高亮粘贴标记的颜色主题配置 - 移除了一些不再使用的硬编码颜色属性,提升UI一致性和可维护性 --- README-en.md | 68 ++++ README-zh_CN.md | 68 ++++ README.md | 68 ++++ src/common/update-check.ts | 3 +- src/settings.ts | 5 + src/tests/theme.test.ts | 333 +++++++++++++++++++ src/ui/components/DropdownMenu/index.tsx | 14 +- src/ui/components/FileMentionMenu/index.tsx | 7 +- src/ui/components/MessageView/index.tsx | 35 +- src/ui/components/MessageView/markdown.ts | 26 +- src/ui/components/MessageView/utils.ts | 12 +- src/ui/components/ModelsDropdown/index.tsx | 1 - src/ui/components/RawModelDropdown/index.tsx | 1 - src/ui/components/SkillsDropdown/index.tsx | 5 +- src/ui/exit-summary.ts | 15 +- src/ui/theme/ThemeContext.tsx | 14 + src/ui/theme/chalk-theme.ts | 82 +++++ src/ui/theme/current-theme.ts | 22 ++ src/ui/theme/index.ts | 7 + src/ui/theme/presets.ts | 26 ++ src/ui/theme/resolver.ts | 51 +++ src/ui/theme/types.ts | 57 ++++ src/ui/utils/index.ts | 9 +- src/ui/views/App.tsx | 12 +- src/ui/views/AppContainer.tsx | 18 +- src/ui/views/AskUserQuestionPrompt.tsx | 18 +- src/ui/views/McpStatusList.tsx | 48 +-- src/ui/views/PermissionPrompt.tsx | 34 +- src/ui/views/ProcessStdoutView.tsx | 6 +- src/ui/views/PromptInput.tsx | 47 ++- src/ui/views/SessionList.tsx | 25 +- src/ui/views/SlashCommandMenu.tsx | 6 +- src/ui/views/ThemedGradient.tsx | 6 +- src/ui/views/UndoSelector.tsx | 14 +- src/ui/views/UpdatePrompt.tsx | 6 +- src/ui/views/WelcomeScreen.tsx | 8 +- 36 files changed, 1028 insertions(+), 149 deletions(-) create mode 100644 src/tests/theme.test.ts create mode 100644 src/ui/theme/ThemeContext.tsx create mode 100644 src/ui/theme/chalk-theme.ts create mode 100644 src/ui/theme/current-theme.ts create mode 100644 src/ui/theme/index.ts create mode 100644 src/ui/theme/presets.ts create mode 100644 src/ui/theme/resolver.ts create mode 100644 src/ui/theme/types.ts diff --git a/README-en.md b/README-en.md index c1d4acb..a09ee91 100644 --- a/README-en.md +++ b/README-en.md @@ -141,6 +141,74 @@ For detailed configuration instructions, see: [docs/notify_en.md](docs/notify_en No. Deep Code has a built-in fine-grained permission control mechanism that lets you confirm operations before the AI assistant executes shell commands, reads/writes files, accesses the network, and more. You can configure each permission scope's policy — always allow, always ask, or deny — via the `permissions` field in `settings.json`. See [docs/permission.md](docs/permission.md) for details. +### How do I customize the theme? + +Deep Code CLI includes a built-in default theme (`DEFAULT_THEME`) that works out of the box. To customize colors, set `theme.preset` to `"custom"` in `settings.json` and provide `overrides` or `tokens`. + +**Using the default theme (no config required)** + +No settings needed — the built-in theme is used automatically. + +**Option 1: Partial overrides (preset="custom" + overrides)** + +Override only the colors you want to change; the rest keep their defaults: + +```json +{ + "theme": { + "preset": "custom", + "overrides": { + "accent": "#ff6600", + "success": "greenBright" + } + } +} +``` + +**Option 2: Full customization (preset="custom" + tokens)** + +Provide a complete tokens object, merged on top of the default theme: + +```json +{ + "theme": { + "preset": "custom", + "tokens": { + "accent": "#229ac3", + "accentAlpha": "#229ac3e6", + "active": "cyanBright", + "success": "green", + "error": "red", + "warning": "yellow", + "info": "magenta", + "riskLow": "#22c55e", + "riskMedium": "#f59e0b", + "riskHigh": "#ef4444", + "text": "white", + "textDim": "gray", + "code": "cyan", + "border": "gray", + "thinking": "gray", + "gradients": ["#229ac3e6", "#229ac3e6"] + } + } +} +``` + +> Note: `overrides` and `tokens` only take effect when `preset` is set to `"custom"`. When `preset` is `"default"` or unset, the built-in default theme is always used. + +Available token descriptions: + +| Token | Used For | +|-------|----------| +| `accent`, `accentAlpha`, `active` | Logo, user messages, selected items, etc. | +| `success`, `error`, `warning`, `info` | Tool statuses, permission prompts, skill loading; `warning` also colors list bullets | +| `riskLow`, `riskMedium`, `riskHigh` | Permission confirmation panel | +| `text`, `textDim`, `code`, `border`, `thinking` | Body text, secondary text/blockquotes, code, borders, thinking status | +| `gradients` | Logo and exit panel gradient colors | + +Color values support hex (`"#ff6600"`), hex with alpha (`"#229ac3e6"`), and chalk named colors (`"cyanBright"`, `"green"`). + ## Contributing Contributions are welcome! Here's how to get started: diff --git a/README-zh_CN.md b/README-zh_CN.md index 2643756..418f645 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -141,6 +141,74 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 } ``` +### 如何自定义主题? + +Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可使用。如需自定义颜色,在 `settings.json` 中设置 `theme.preset` 为 `"custom"` 后提供 `overrides` 或 `tokens`。 + +**使用默认主题(无需配置)** + +直接使用内置主题,不做任何设置。 + +**方式一:局部覆盖(preset="custom" + overrides)** + +只覆盖需要调整的颜色,其余保持默认值: + +```json +{ + "theme": { + "preset": "custom", + "overrides": { + "accent": "#ff6600", + "success": "greenBright" + } + } +} +``` + +**方式二:完全自定义(preset="custom" + tokens)** + +提供完整的 tokens 对象,基于默认主题合并: + +```json +{ + "theme": { + "preset": "custom", + "tokens": { + "accent": "#229ac3", + "accentAlpha": "#229ac3e6", + "active": "cyanBright", + "success": "green", + "error": "red", + "warning": "yellow", + "info": "magenta", + "riskLow": "#22c55e", + "riskMedium": "#f59e0b", + "riskHigh": "#ef4444", + "text": "white", + "textDim": "gray", + "code": "cyan", + "border": "gray", + "thinking": "gray", + "gradients": ["#229ac3e6", "#229ac3e6"] + } + } +} +``` + +> 注意:`preset` 必须设为 `"custom"` 时 `overrides` 和 `tokens` 才会生效。`preset` 为 `"default"` 或不配置时始终使用系统默认主题。 + +可覆盖的 token 说明: + +| Token | 用途 | +|---------------------------------------------|----------------------------------| +| `accent`、`accentAlpha`、`active` | Logo、用户消息、选中项等 | +| `success`、`error`、`warning`、`info` | 工具状态、权限提示、技能加载,`warning` 也是列表标记色 | +| `riskLow`、`riskMedium`、`riskHigh` | 权限确认面板 | +| `text`、`textDim`、`code`、`border`、`thinking` | 正文、副文/引用块、代码、边框、思考状态 | +| `gradients` | Logo 与退出面板的渐变色数组 | + +颜色值支持 hex(`"#ff6600"`)、hex 含透明度(`"#229ac3e6"`)、chalk 命名色(`"cyanBright"`、`"green"`)。 + ## 贡献 欢迎贡献代码!以下是参与方式: diff --git a/README.md b/README.md index 2643756..418f645 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,74 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 } ``` +### 如何自定义主题? + +Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可使用。如需自定义颜色,在 `settings.json` 中设置 `theme.preset` 为 `"custom"` 后提供 `overrides` 或 `tokens`。 + +**使用默认主题(无需配置)** + +直接使用内置主题,不做任何设置。 + +**方式一:局部覆盖(preset="custom" + overrides)** + +只覆盖需要调整的颜色,其余保持默认值: + +```json +{ + "theme": { + "preset": "custom", + "overrides": { + "accent": "#ff6600", + "success": "greenBright" + } + } +} +``` + +**方式二:完全自定义(preset="custom" + tokens)** + +提供完整的 tokens 对象,基于默认主题合并: + +```json +{ + "theme": { + "preset": "custom", + "tokens": { + "accent": "#229ac3", + "accentAlpha": "#229ac3e6", + "active": "cyanBright", + "success": "green", + "error": "red", + "warning": "yellow", + "info": "magenta", + "riskLow": "#22c55e", + "riskMedium": "#f59e0b", + "riskHigh": "#ef4444", + "text": "white", + "textDim": "gray", + "code": "cyan", + "border": "gray", + "thinking": "gray", + "gradients": ["#229ac3e6", "#229ac3e6"] + } + } +} +``` + +> 注意:`preset` 必须设为 `"custom"` 时 `overrides` 和 `tokens` 才会生效。`preset` 为 `"default"` 或不配置时始终使用系统默认主题。 + +可覆盖的 token 说明: + +| Token | 用途 | +|---------------------------------------------|----------------------------------| +| `accent`、`accentAlpha`、`active` | Logo、用户消息、选中项等 | +| `success`、`error`、`warning`、`info` | 工具状态、权限提示、技能加载,`warning` 也是列表标记色 | +| `riskLow`、`riskMedium`、`riskHigh` | 权限确认面板 | +| `text`、`textDim`、`code`、`border`、`thinking` | 正文、副文/引用块、代码、边框、思考状态 | +| `gradients` | Logo 与退出面板的渐变色数组 | + +颜色值支持 hex(`"#ff6600"`)、hex 含透明度(`"#229ac3e6"`)、chalk 命名色(`"cyanBright"`、`"green"`)。 + ## 贡献 欢迎贡献代码!以下是参与方式: diff --git a/src/common/update-check.ts b/src/common/update-check.ts index 09c0273..33f8477 100644 --- a/src/common/update-check.ts +++ b/src/common/update-check.ts @@ -6,6 +6,7 @@ import * as path from "path"; import { render, type Instance } from "ink"; import chalk from "chalk"; import { UpdatePrompt, type UpdatePromptChoice } from "../ui"; +import { DEFAULT_THEME } from "../ui/theme/presets"; import { killProcessTree } from "./process-tree"; export type PackageInfo = { @@ -58,7 +59,7 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< if (ok) { writeUpdateState({ ...state, pending: null }); process.stdout.write( - `\n${chalk.red("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n` + `\n${chalk.hex(DEFAULT_THEME.error)("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n` ); } return { installed: ok }; diff --git a/src/settings.ts b/src/settings.ts index b7a7a77..f23f842 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,6 @@ import { defaultsToThinkingMode } from "./common/model-capabilities"; +import { resolveTheme } from "./ui/theme"; +import type { ThemeTokens, ThemeSettings } from "./ui/theme"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -51,6 +53,7 @@ export type DeepcodingSettings = { webSearchTool?: string; mcpServers?: Record; permissions?: PermissionSettings; + theme?: ThemeSettings; }; export type ResolvedDeepcodingSettings = { @@ -65,6 +68,7 @@ export type ResolvedDeepcodingSettings = { webSearchTool?: string; mcpServers?: Record; permissions: Required; + theme: ThemeTokens; }; export type ModelConfigSelection = { @@ -333,6 +337,7 @@ export function resolveSettingsSources( webSearchTool: webSearchTool || undefined, mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), permissions: mergePermissions(userSettings, projectSettings), + theme: resolveTheme(userSettings?.theme ?? projectSettings?.theme), }; } diff --git a/src/tests/theme.test.ts b/src/tests/theme.test.ts new file mode 100644 index 0000000..3495451 --- /dev/null +++ b/src/tests/theme.test.ts @@ -0,0 +1,333 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import chalk from "chalk"; + +import { DEFAULT_THEME, PRESETS } from "../ui/theme"; +import { resolveTheme } from "../ui/theme"; +import { createThemedChalk } from "../ui/theme"; +import { setCurrentTheme, getCurrentThemedChalk, getCurrentThemeTokens } from "../ui/theme"; +import { resolveSettingsSources } from "../settings"; +import { getScopeRiskColor } from "../ui/views/PermissionPrompt"; + +import type { ThemeTokens } from "../ui/theme"; + +// Force chalk to produce ANSI escapes even in non-TTY test environments. +chalk.level = 1; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** All token keys that every ThemeTokens must define. */ +const REQUIRED_TOKEN_KEYS: Array = [ + "accent", + "accentAlpha", + "active", + "success", + "error", + "warning", + "info", + "riskLow", + "riskMedium", + "riskHigh", + "text", + "textDim", + "code", + "border", + "thinking", + "gradients", +]; + +const DEFAULTS = { + model: "test-model", + baseURL: "https://test.example.com", +}; + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +test("DEFAULT_THEME has all required token keys", () => { + for (const key of REQUIRED_TOKEN_KEYS) { + assert.ok(key in DEFAULT_THEME, `DEFAULT_THEME is missing key: ${key}`); + } +}); + +test("DEFAULT_THEME accent matches expected brand color", () => { + assert.equal(DEFAULT_THEME.accent, "#229ac3"); + assert.equal(DEFAULT_THEME.accentAlpha, "#229ac3e6"); +}); + +test("DEFAULT_THEME semantic colors match expected values", () => { + assert.equal(DEFAULT_THEME.success, "#52c41a"); + assert.equal(DEFAULT_THEME.error, "#ff4d4f"); + assert.equal(DEFAULT_THEME.warning, "#faad14"); + assert.equal(DEFAULT_THEME.info, "#1677ff"); + assert.equal(DEFAULT_THEME.active, "#89B4FA"); + assert.equal(DEFAULT_THEME.thinking, "#CCCFD3"); +}); + +test("DEFAULT_THEME base colors match expected values", () => { + assert.equal(DEFAULT_THEME.text, "#6C7086"); + assert.equal(DEFAULT_THEME.textDim, "#6C7086"); + assert.equal(DEFAULT_THEME.code, "#787f8a"); +}); + +test("DEFAULT_THEME risk colors match expected values", () => { + assert.equal(DEFAULT_THEME.riskLow, "#22c55e"); + assert.equal(DEFAULT_THEME.riskMedium, "#f59e0b"); + assert.equal(DEFAULT_THEME.riskHigh, "#ef4444"); +}); + +test("PRESETS map contains default", () => { + assert.ok("default" in PRESETS); + assert.equal(Object.keys(PRESETS).length, 1); + assert.equal(PRESETS.default, DEFAULT_THEME); +}); + +// --------------------------------------------------------------------------- +// Resolver +// --------------------------------------------------------------------------- + +test("resolveTheme returns DEFAULT_THEME when settings is undefined", () => { + const result = resolveTheme(undefined); + assert.equal(result.accent, DEFAULT_THEME.accent); + assert.equal(result.success, DEFAULT_THEME.success); +}); + +test("resolveTheme returns DEFAULT_THEME for explicit 'default' preset", () => { + const result = resolveTheme({ preset: "default" }); + assert.equal(result.accent, DEFAULT_THEME.accent); +}); + +test("resolveTheme returns DEFAULT_THEME when preset is not 'custom'", () => { + const result = resolveTheme({ preset: "default" }); + assert.equal(result.text, DEFAULT_THEME.text); + assert.equal(result.accent, DEFAULT_THEME.accent); +}); + +test("resolveTheme applies overrides when preset is 'custom'", () => { + const result = resolveTheme({ + preset: "custom", + overrides: { accent: "#ff0000" }, + }); + assert.equal(result.accent, "#ff0000"); + assert.equal(result.success, DEFAULT_THEME.success); +}); + +test("resolveTheme applies multiple overrides with custom preset", () => { + const result = resolveTheme({ + preset: "custom", + overrides: { + accent: "#ff6600", + success: "greenBright", + warning: "yellowBright", + }, + }); + assert.equal(result.accent, "#ff6600"); + assert.equal(result.success, "greenBright"); + assert.equal(result.warning, "yellowBright"); + assert.equal(result.error, DEFAULT_THEME.error); +}); + +test("resolveTheme full custom tokens with custom preset", () => { + const customTokens: ThemeTokens = { + accent: "#aaaaaa", + accentAlpha: "#aaaaaacc", + active: "blue", + success: "blue", + error: "blue", + warning: "blue", + info: "blue", + riskLow: "#111111", + riskMedium: "#222222", + riskHigh: "#333333", + text: "blue", + textDim: "blue", + code: "blue", + border: "blue", + thinking: "blue", + gradients: ["#aaaaaa", "#bbbbbb"], + }; + const result = resolveTheme({ preset: "custom", tokens: customTokens }); + assert.equal(result.accent, "#aaaaaa"); + assert.equal(result.code, "blue"); + assert.deepEqual(result.gradients, ["#aaaaaa", "#bbbbbb"]); +}); + +test("resolveTheme handles override with undefined fields gracefully", () => { + const result = resolveTheme({ + preset: "custom", + overrides: { accent: undefined, success: undefined } as Partial, + }); + assert.equal(result.accent, DEFAULT_THEME.accent); + assert.equal(result.success, DEFAULT_THEME.success); +}); + +test("resolveTheme ignores overrides when preset is not custom", () => { + const result = resolveTheme({ + preset: "default", + overrides: { accent: "#ff0000" }, + }); + assert.equal(result.accent, DEFAULT_THEME.accent); +}); + +test("resolveTheme ignores tokens when preset is not custom", () => { + const result = resolveTheme({ + tokens: { accent: "#ff0000" } as ThemeTokens, + }); + assert.equal(result.accent, DEFAULT_THEME.accent); +}); + +test("resolveTheme returns DEFAULT_THEME for custom preset without token/overrides", () => { + const result = resolveTheme({ preset: "custom" }); + assert.equal(result.accent, DEFAULT_THEME.accent); +}); + +// --------------------------------------------------------------------------- +// createThemedChalk — markdown 方法直接复用顶层 token +// --------------------------------------------------------------------------- + +test("createThemedChalk heading1 produces styled output via accent", () => { + const tc = createThemedChalk(DEFAULT_THEME); + assert.notEqual(tc.heading1("Hello"), "Hello"); +}); + +test("createThemedChalk heading1 changes when accent changes", () => { + const custom: ThemeTokens = { ...DEFAULT_THEME, accent: "#ff0000" }; + assert.notEqual(createThemedChalk(DEFAULT_THEME).heading1("test"), createThemedChalk(custom).heading1("test")); +}); + +test("createThemedChalk inlineCode changes when code changes", () => { + const custom: ThemeTokens = { ...DEFAULT_THEME, code: "#ff0000" }; + assert.notEqual(createThemedChalk(DEFAULT_THEME).inlineCode("test"), createThemedChalk(custom).inlineCode("test")); +}); + +test("createThemedChalk listBullet changes when warning changes", () => { + const custom: ThemeTokens = { ...DEFAULT_THEME, warning: "#ff0000" }; + assert.notEqual(createThemedChalk(DEFAULT_THEME).listBullet("test"), createThemedChalk(custom).listBullet("test")); +}); + +test("createThemedChalk quote changes when textDim changes", () => { + const custom: ThemeTokens = { ...DEFAULT_THEME, textDim: "#ff0000" }; + assert.notEqual(createThemedChalk(DEFAULT_THEME).quote("test"), createThemedChalk(custom).quote("test")); +}); + +test("createThemedChalk bold / italic / dim produce styled output", () => { + const tc = createThemedChalk(DEFAULT_THEME); + assert.notEqual(tc.bold("bold"), "bold"); + assert.notEqual(tc.italic("italic"), "italic"); + assert.notEqual(tc.dim("dim"), "dim"); +}); + +test("createThemedChalk produces different output for different accent values", () => { + const custom1: ThemeTokens = { ...DEFAULT_THEME, accent: "#ff0000" }; + const custom2: ThemeTokens = { ...DEFAULT_THEME, accent: "#00ff00" }; + assert.notEqual(createThemedChalk(custom1).accent("test"), createThemedChalk(custom2).accent("test")); +}); + +test("createThemedChalk handles hex colors correctly", () => { + const hexTheme: ThemeTokens = { + ...DEFAULT_THEME, + accent: "#ff6600", + warning: "#ffcc00", + code: "#00ccff", + }; + const tc = createThemedChalk(hexTheme); + assert.notEqual(tc.heading1("test"), "test"); + assert.notEqual(tc.inlineCode("test"), "test"); +}); + +// --------------------------------------------------------------------------- +// current-theme (module-level state) +// --------------------------------------------------------------------------- + +test("getCurrentThemedChalk returns DEFAULT_THEME chalk by default", () => { + setCurrentTheme(DEFAULT_THEME); + assert.notEqual(getCurrentThemedChalk().accent("test"), "test"); +}); + +test("setCurrentTheme changes getCurrentThemedChalk output", () => { + setCurrentTheme(DEFAULT_THEME); + const first = getCurrentThemedChalk().accent("test"); + + const custom: ThemeTokens = { ...DEFAULT_THEME, accent: "#ff0000" }; + setCurrentTheme(custom); + const second = getCurrentThemedChalk().accent("test"); + + assert.notEqual(first, second); + + setCurrentTheme(DEFAULT_THEME); +}); + +test("setCurrentTheme changes getCurrentThemeTokens output", () => { + setCurrentTheme(DEFAULT_THEME); + assert.equal(getCurrentThemeTokens().accent, DEFAULT_THEME.accent); + + const custom: ThemeTokens = { ...DEFAULT_THEME, accent: "#ff0000" }; + setCurrentTheme(custom); + assert.equal(getCurrentThemeTokens().accent, "#ff0000"); + + setCurrentTheme(DEFAULT_THEME); +}); + +// --------------------------------------------------------------------------- +// Settings integration +// --------------------------------------------------------------------------- + +test("resolveSettingsSources includes theme field in resolved settings", () => { + const result = resolveSettingsSources(null, null, DEFAULTS, {}); + assert.ok("theme" in result); + assert.equal(result.theme.accent, DEFAULT_THEME.accent); +}); + +test("resolveSettingsSources resolves custom theme from user settings", () => { + const result = resolveSettingsSources( + { theme: { preset: "custom", overrides: { accent: "#abcdef" } } }, + null, + DEFAULTS, + {} + ); + assert.equal(result.theme.accent, "#abcdef"); +}); + +test("resolveSettingsSources resolves custom theme from project settings", () => { + const result = resolveSettingsSources( + null, + { theme: { preset: "custom", overrides: { accent: "#123456" } } }, + DEFAULTS, + {} + ); + assert.equal(result.theme.accent, "#123456"); +}); + +test("resolveSettingsSources uses default theme when preset is not custom", () => { + const result = resolveSettingsSources( + { theme: { preset: "default", overrides: { accent: "#abcdef" } } }, + null, + DEFAULTS, + {} + ); + assert.equal(result.theme.accent, DEFAULT_THEME.accent); +}); + +// --------------------------------------------------------------------------- +// getScopeRiskColor with theme parameter +// --------------------------------------------------------------------------- + +test("getScopeRiskColor returns dark theme defaults when no theme is passed", () => { + assert.equal(getScopeRiskColor("read-in-cwd"), "#22c55e"); + assert.equal(getScopeRiskColor("write-in-cwd"), "#f59e0b"); + assert.equal(getScopeRiskColor("write-out-cwd"), "#ef4444"); +}); + +test("getScopeRiskColor uses theme risk colors when theme is provided", () => { + const custom: Partial = { + riskLow: "#aaaaaa", + riskMedium: "#bbbbbb", + riskHigh: "#cccccc", + }; + assert.equal(getScopeRiskColor("read-in-cwd", custom as ThemeTokens), "#aaaaaa"); + assert.equal(getScopeRiskColor("mcp", custom as ThemeTokens), "#bbbbbb"); + assert.equal(getScopeRiskColor("delete-out-cwd", custom as ThemeTokens), "#cccccc"); +}); diff --git a/src/ui/components/DropdownMenu/index.tsx b/src/ui/components/DropdownMenu/index.tsx index cf32314..f34bc12 100644 --- a/src/ui/components/DropdownMenu/index.tsx +++ b/src/ui/components/DropdownMenu/index.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from "react"; import { Box, Text } from "ink"; +import { useTheme } from "../../theme"; /** * Generic dropdown menu item structure @@ -64,12 +65,15 @@ const DropdownMenu = React.memo(function DropdownMenu({ maxVisible = 8, width, title, - titleColor = "#229ac3", - activeColor = "cyanBright", + titleColor, + activeColor, helpText, emptyText = "No items found", renderItem, }: DropdownMenuProps): React.ReactElement | null { + const theme = useTheme(); + const effectiveTitleColor = titleColor ?? theme.accent; + const effectiveActiveColor = activeColor ?? theme.active; // Calculate visible window const visibleStart = calculateVisibleStart(activeIndex, items?.length, maxVisible); const visibleItems = items?.slice(visibleStart, visibleStart + maxVisible); @@ -102,7 +106,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ return ( {title ? ( - + {title} ) : null} @@ -125,7 +129,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ borderLeft={false} paddingX={1} > - + {title} @@ -153,7 +157,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ return ( - + {isActive ? "> " : " "} {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} {item.statusIndicator ? ( diff --git a/src/ui/components/FileMentionMenu/index.tsx b/src/ui/components/FileMentionMenu/index.tsx index f00b367..96b3a14 100644 --- a/src/ui/components/FileMentionMenu/index.tsx +++ b/src/ui/components/FileMentionMenu/index.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import { Box, Text } from "ink"; import { useInput } from "ink"; import DropdownMenu from "../DropdownMenu"; +import { useTheme } from "../../theme"; import type { FileMentionItem, FileMentionToken } from "../../core/file-mentions"; type Props = { @@ -14,6 +15,7 @@ type Props = { }; const FileMentionMenu: React.FC = ({ open, width, token, items, onClose, onSelect }) => { + const theme = useTheme(); const [activeIndex, setActiveIndex] = useState(0); // Reset index when opened @@ -93,13 +95,12 @@ const FileMentionMenu: React.FC = ({ open, width, token, items, onClose, description: item.type === "directory" ? "directory" : "file", }))} activeIndex={activeIndex} - activeColor="#229ac3" maxVisible={8} renderItem={(item, isActive) => ( - {isActive ? "> " : " "} + {isActive ? "> " : " "} - + {item.label} diff --git a/src/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx index 9c31551..a52d7c5 100644 --- a/src/ui/components/MessageView/index.tsx +++ b/src/ui/components/MessageView/index.tsx @@ -11,9 +11,11 @@ import { } from "./utils"; import type { DiffPreviewLine, MessageViewProps } from "./types"; import { RawMode, useRawModeContext } from "../../contexts"; +import { useTheme } from "../../theme"; export function MessageView({ message, collapsed, width = 80 }: MessageViewProps): React.ReactElement | null { const { mode } = useRawModeContext(); + const theme = useTheme(); if (!message.visible) { return null; } @@ -23,12 +25,12 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return ( - {`>`} + {`>`} - {text} + {text} {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( - {` 📎 ${message.contentParams.length} image attachment(s)`} + {` 📎 ${message.contentParams.length} image attachment(s)`} ) : null} @@ -44,13 +46,13 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps if (collapsed !== false) { return ( - + ); } return ( - + {content ? {renderMarkdown(content)} : null} @@ -64,7 +66,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return ( - + {content @@ -96,7 +98,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps @@ -112,10 +114,10 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return ( - {`>`} + {`>`} - {message.content} + {message.content} ); @@ -124,7 +126,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps if (message.meta?.skill) { return ( - ⚡ Loaded skill: {message.meta.skill.name} + ⚡ Loaded skill: {message.meta.skill.name} ); } @@ -149,14 +151,16 @@ function StatusLine({ params, width, }: { - bulletColor: "gray" | "green" | "red"; + bulletColor: string; name: string; params: string; width: number; }): React.ReactElement { const { mode } = useRawModeContext(); + const theme = useTheme(); const containerWidth = Math.max(1, width - 2); const contentWidth = Math.max(1, width - 4); + return ( @@ -166,11 +170,11 @@ function StatusLine({ - + {name} {params ? ( - + {` ${params}`} ) : null} @@ -181,16 +185,17 @@ function StatusLine({ } function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElement { + const theme = useTheme(); return ( └ Changes {lines.map((line, index) => ( - + {line.marker} - + {line.content} diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index 3ebb58b..a3e8092 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -1,4 +1,5 @@ import chalk from "chalk"; +import { getCurrentThemedChalk } from "../../theme"; /** * A rendered piece of markdown. Consumers should use `wrap="truncate-end"` for @@ -35,8 +36,9 @@ export function renderMarkdownSegments(text: string, maxWidth?: number): Markdow for (const seg of fenceSegments) { if (seg.kind === "code") { - const langTag = seg.lang ? chalk.dim(`[${seg.lang}]`) + "\n" : ""; - segments.push({ kind: "code", body: langTag + chalk.cyan(seg.body), lang: seg.lang }); + const tc = getCurrentThemedChalk(); + const langTag = seg.lang ? tc.dim(`[${seg.lang}]`) + "\n" : ""; + segments.push({ kind: "code", body: langTag + tc.code(seg.body), lang: seg.lang }); continue; } const blocks = splitTableBlocks(seg.body); @@ -366,29 +368,30 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { // --------------------------------------------------------------------------- function renderInlineLine(line: string): string { + const tc = getCurrentThemedChalk(); const headingMatch = /^(\s*)(#{1,6})\s+(.*)$/.exec(line); if (headingMatch) { const [, lead, hashes, content] = headingMatch; - const styled = hashes.length <= 2 ? chalk.bold.cyanBright(content) : chalk.bold.cyan(content); - return `${lead}${chalk.dim(hashes)} ${styled}`; + const styled = hashes.length <= 2 ? tc.heading1(content) : tc.heading3(content); + return `${lead}${tc.dim(hashes)} ${styled}`; } const listMatch = /^(\s*)([-*+])\s+(.*)$/.exec(line); if (listMatch) { const [, lead, bullet, content] = listMatch; - return `${lead}${chalk.yellow(bullet)} ${renderInlineSpans(content)}`; + return `${lead}${tc.listBullet(bullet)} ${renderInlineSpans(content)}`; } const numListMatch = /^(\s*)(\d+\.)\s+(.*)$/.exec(line); if (numListMatch) { const [, lead, marker, content] = numListMatch; - return `${lead}${chalk.yellow(marker)} ${renderInlineSpans(content)}`; + return `${lead}${tc.listBullet(marker)} ${renderInlineSpans(content)}`; } const quoteMatch = /^(\s*)>\s?(.*)$/.exec(line); if (quoteMatch) { const [, lead, content] = quoteMatch; - return `${lead}${chalk.dim("│ ")}${chalk.italic(renderInlineSpans(content))}`; + return `${lead}${tc.quote("│ ")}${chalk.italic(renderInlineSpans(content))}`; } return renderInlineSpans(line); @@ -396,10 +399,11 @@ function renderInlineLine(line: string): string { function renderInlineSpans(text: string): string { if (!text) return text; + const tc = getCurrentThemedChalk(); let result = text; - result = result.replace(/`([^`]+)`/g, (_, inner) => chalk.cyan(inner)); - result = result.replace(/\*\*([^*]+)\*\*/g, (_, inner) => chalk.bold(inner)); - result = result.replace(/(? chalk.italic(inner)); - result = result.replace(/_([^_\n]+)_/g, (_, inner) => chalk.italic(inner)); + result = result.replace(/`([^`]+)`/g, (_, inner) => tc.inlineCode(inner)); + result = result.replace(/\*\*([^*]+)\*\*/g, (_, inner) => tc.bold(inner)); + result = result.replace(/(? tc.italic(inner)); + result = result.replace(/_([^_\n]+)_/g, (_, inner) => tc.italic(inner)); return result; } diff --git a/src/ui/components/MessageView/utils.ts b/src/ui/components/MessageView/utils.ts index af5391d..fb9f3d8 100644 --- a/src/ui/components/MessageView/utils.ts +++ b/src/ui/components/MessageView/utils.ts @@ -2,6 +2,7 @@ import type { DiffPreviewLine, ToolSummary } from "./types"; import type { SessionMessage } from "../../../session"; import { RawMode } from "../../contexts"; import chalk from "chalk"; +import { getCurrentThemedChalk } from "../../theme"; /** Type guard that checks whether a value is a plain object (not null, not an array). */ export function isPlainRecord(value: unknown): value is Record { @@ -204,13 +205,14 @@ export function parseDiffPreview(diffPreview: string): DiffPreviewLine[] { } export function renderMessageToStdout(message: SessionMessage, mode: RawMode): string { + const tc = getCurrentThemedChalk(); if (!message.visible) { return ""; } if (message.role === "user") { const text = message.content || "(no content)"; - return chalk(`> ${text}`); + return tc.accent(`> ${text}`); } if (message.role === "assistant") { @@ -237,7 +239,7 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s const statusLine = `${chalk("✧")} ${chalk(formatStatusName(name))}${params ? ` ${chalk(params)}` : ""}`; const metaResultMd = typeof message.meta?.resultMd === "string" ? message.meta.resultMd.trim() : ""; - const result = metaResultMd ? `\n${chalk.dim(" └ Result")}\n${metaResultMd}` : ""; + const result = metaResultMd ? `\n${tc.dim(" └ Result")}\n${metaResultMd}` : ""; const summary: ToolSummary = { name, @@ -248,7 +250,7 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s const planLines = getUpdatePlanPreviewLines(summary); if (planLines.length > 0) { const planText = planLines.map((line) => ` ${line}`).join("\n"); - return `${statusLine}\n${chalk.dim(" └ Plan")}\n${planText}${result}`; + return `${statusLine}\n${tc.dim(" └ Plan")}\n${planText}${result}`; } return `${statusLine}${result}`; @@ -256,14 +258,14 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s if (message.role === "system") { if (message.meta?.isModelChange) { - return chalk(`> ${message.content}`); + return tc.accent(`> ${message.content}`); } if (message.meta?.skill && typeof message.meta.skill === "object") { const skillName = (message.meta.skill as { name?: unknown }).name; return chalk(`⚡ Loaded skill: ${typeof skillName === "string" ? skillName : ""}`); } if (message.meta?.isSummary) { - return chalk.dim.italic("(conversation summary inserted)"); + return tc.dim(tc.italic("(conversation summary inserted)")); } return ""; } diff --git a/src/ui/components/ModelsDropdown/index.tsx b/src/ui/components/ModelsDropdown/index.tsx index 6e80756..7ce2008 100644 --- a/src/ui/components/ModelsDropdown/index.tsx +++ b/src/ui/components/ModelsDropdown/index.tsx @@ -155,7 +155,6 @@ const ModelsDropdown: React.FC = ({ helpText={step === "model" ? "Space/Enter select model · Esc to cancel" : "Space/Enter apply · Esc to cancel"} items={items} activeIndex={activeIndex} - activeColor="#229ac3" maxVisible={6} /> ); diff --git a/src/ui/components/RawModelDropdown/index.tsx b/src/ui/components/RawModelDropdown/index.tsx index 67f053c..541e10b 100644 --- a/src/ui/components/RawModelDropdown/index.tsx +++ b/src/ui/components/RawModelDropdown/index.tsx @@ -44,7 +44,6 @@ const RawModelDropdown: React.FC<{ items={RAW_COMMAND_MODELS.map((model) => ({ ...model, selected: model.key === mode }))} helpText="Space/Enter select mode · Esc to close" // onSelect={onSelect} - activeColor="#229ac3" maxVisible={6} activeIndex={index} width={screenWidth} diff --git a/src/ui/components/SkillsDropdown/index.tsx b/src/ui/components/SkillsDropdown/index.tsx index 4ec5339..8da231a 100644 --- a/src/ui/components/SkillsDropdown/index.tsx +++ b/src/ui/components/SkillsDropdown/index.tsx @@ -2,6 +2,7 @@ import DropdownMenu from "../DropdownMenu"; import React, { useEffect, useState } from "react"; import type { SkillInfo } from "../../../session"; import { useInput } from "ink"; +import { useTheme } from "../../theme"; import { isSkillSelected } from "../../views/SlashCommandMenu"; const SkillsDropdown: React.FC<{ @@ -12,6 +13,7 @@ const SkillsDropdown: React.FC<{ selectedSkills: SkillInfo[]; onSelect?: (skill: SkillInfo) => void; }> = ({ open, width, skills, selectedSkills, onSelect, onClose }) => { + const theme = useTheme(); const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0); useInput( (input, key) => { @@ -62,10 +64,9 @@ const SkillsDropdown: React.FC<{ label: skill.name, description: skill.path, selected: isSkillSelected(selectedSkills, skill), - statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined, + statusIndicator: skill.isLoaded ? { symbol: "✓", color: theme.success } : undefined, }))} activeIndex={skillsDropdownIndex} - activeColor="#229ac3" maxVisible={6} /> ); diff --git a/src/ui/exit-summary.ts b/src/ui/exit-summary.ts index c55d9ce..931d0c3 100644 --- a/src/ui/exit-summary.ts +++ b/src/ui/exit-summary.ts @@ -1,6 +1,7 @@ import chalk from "chalk"; import gradientString from "gradient-string"; import type { ModelUsage, SessionEntry } from "../session"; +import { getCurrentThemedChalk, getCurrentThemeTokens } from "./theme"; type ExitSummaryInput = { session: SessionEntry | null; @@ -72,8 +73,10 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const innerWidth = 98; const contentWidth = innerWidth - 4; // "│ " prefix + " │" suffix → 4 chars padding - const borderColor = chalk.hex("#229ac3e6"); - const titleColor = gradientString("#229ac3e6", "rgb(125 51 247 / 0.7)"); + const theme = getCurrentThemeTokens(); + const tc = getCurrentThemedChalk(); + const borderColor = chalk.hex(theme.accentAlpha); + const titleColor = gradientString(...theme.gradients); const line = (text: string) => `${borderColor("│")} ${padRight(text, contentWidth)} ${borderColor("│")}`; const header = chalk.bold(titleColor("Goodbye!")); @@ -113,7 +116,7 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { padLeft("Output Tokens", colOutput) + padLeft("Cached Tokens", colCached); rows.push(chalk.bold(headerRow)); - rows.push(divider); + rows.push(tc.textDim(divider)); for (const { modelName, usage } of usageRows) { const reqsStr = formatNumber(usage.totalReqs).padStart(colReqs); @@ -123,9 +126,9 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const dataRow = padRight(modelName, colModel) + padRight(reqsStr, colReqs) + - padRight(chalk.yellow(inputStr), colInput) + - padRight(chalk.yellow(outputStr), colOutput) + - padRight(chalk.yellow(cachedStr), colCached); + padRight(tc.warning(inputStr), colInput) + + padRight(tc.warning(outputStr), colOutput) + + padRight(tc.warning(cachedStr), colCached); rows.push(dataRow); } diff --git a/src/ui/theme/ThemeContext.tsx b/src/ui/theme/ThemeContext.tsx new file mode 100644 index 0000000..bc76b46 --- /dev/null +++ b/src/ui/theme/ThemeContext.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext } from "react"; +import type { ThemeTokens } from "./types"; +import { DEFAULT_THEME } from "./presets"; + +/** 主题 React Context */ +const ThemeContext = createContext(DEFAULT_THEME); + +/** 主题 Provider */ +export const ThemeProvider = ThemeContext.Provider; + +/** 获取当前主题 token */ +export function useTheme(): ThemeTokens { + return useContext(ThemeContext); +} diff --git a/src/ui/theme/chalk-theme.ts b/src/ui/theme/chalk-theme.ts new file mode 100644 index 0000000..6475e8b --- /dev/null +++ b/src/ui/theme/chalk-theme.ts @@ -0,0 +1,82 @@ +import chalk, { type ChalkInstance } from "chalk"; +import type { ThemeTokens } from "./types"; + +/** + * 将 ThemeTokens 中的颜色 token 转换为实际的 chalk 颜色实例。 + * 对于命名颜色(如 "cyanBright"),通过 chalk 的索引访问获取对应颜色函数。 + * 对于 hex 颜色,直接用 chalk.hex()。 + */ +function chalkColor(color: string): ChalkInstance { + // 尝试 hex 格式 + if (color.startsWith("#")) { + return chalk.hex(color); + } + // 尝试 chalk 命名颜色(如 "cyanBright" → chalk.cyanBright) + const chalkWithIndex = chalk as unknown as Record; + const instance = chalkWithIndex[color]; + if (instance) { + return instance; + } + return chalk; +} + +/** + * 根据主题创建 chalk 样式函数集合。 + * 用于 markdown 渲染、raw mode 输出等非 Ink 组件的终端输出。 + */ +export interface ThemedChalk { + heading1: (text: string) => string; + heading2: (text: string) => string; + heading3: (text: string) => string; + listBullet: (text: string) => string; + quote: (text: string) => string; + inlineCode: (text: string) => string; + code: (text: string) => string; + bold: (text: string) => string; + italic: (text: string) => string; + dim: (text: string) => string; + accent: (text: string) => string; + accentAlpha: (text: string) => string; + text: (text: string) => string; + textDim: (text: string) => string; + success: (text: string) => string; + error: (text: string) => string; + warning: (text: string) => string; + info: (text: string) => string; +} + +export function createThemedChalk(theme: ThemeTokens): ThemedChalk { + const ac = chalkColor(theme.accent); + const aa = chalkColor(theme.accentAlpha); + const tx = chalkColor(theme.text); + const td = chalkColor(theme.textDim); + const cd = chalkColor(theme.code); + const sc = chalkColor(theme.success); + const er = chalkColor(theme.error); + const wr = chalkColor(theme.warning); + const inf = chalkColor(theme.info); + + return { + // Markdown 渲染 — 直接复用顶层 token + heading1: (text: string) => chalk.bold(ac(text)), + heading2: (text: string) => chalk.bold(ac(text)), + heading3: (text: string) => chalk.bold(ac(text)), + listBullet: (text: string) => wr(text), + quote: (text: string) => chalk.italic(td(text)), + inlineCode: (text: string) => cd(text), + code: (text: string) => cd(text), + // 基础样式 + bold: (text: string) => chalk.bold(text), + italic: (text: string) => chalk.italic(text), + dim: (text: string) => chalk.dim(text), + // 语义色 + accent: (text: string) => ac(text), + accentAlpha: (text: string) => aa(text), + text: (text: string) => tx(text), + textDim: (text: string) => td(text), + success: (text: string) => sc(text), + error: (text: string) => er(text), + warning: (text: string) => wr(text), + info: (text: string) => inf(text), + }; +} diff --git a/src/ui/theme/current-theme.ts b/src/ui/theme/current-theme.ts new file mode 100644 index 0000000..60957d6 --- /dev/null +++ b/src/ui/theme/current-theme.ts @@ -0,0 +1,22 @@ +import { DEFAULT_THEME } from "./presets"; +import { createThemedChalk, type ThemedChalk } from "./chalk-theme"; +import type { ThemeTokens } from "./types"; + +let currentThemedChalk: ThemedChalk = createThemedChalk(DEFAULT_THEME); +let currentThemeTokens: ThemeTokens = DEFAULT_THEME; + +/** 设置当前主题(在 AppContainer 中调用一次) */ +export function setCurrentTheme(theme: ThemeTokens): void { + currentThemeTokens = theme; + currentThemedChalk = createThemedChalk(theme); +} + +/** 获取当前主题的 chalk 样式工具 */ +export function getCurrentThemedChalk(): ThemedChalk { + return currentThemedChalk; +} + +/** 获取当前 ThemeTokens */ +export function getCurrentThemeTokens(): ThemeTokens { + return currentThemeTokens; +} diff --git a/src/ui/theme/index.ts b/src/ui/theme/index.ts new file mode 100644 index 0000000..5988468 --- /dev/null +++ b/src/ui/theme/index.ts @@ -0,0 +1,7 @@ +export type { ThemeTokens, ThemePreset, ThemeSettings } from "./types"; +export { DEFAULT_THEME, PRESETS } from "./presets"; +export { resolveTheme } from "./resolver"; +export { ThemeProvider, useTheme } from "./ThemeContext"; +export { createThemedChalk } from "./chalk-theme"; +export type { ThemedChalk } from "./chalk-theme"; +export { setCurrentTheme, getCurrentThemedChalk, getCurrentThemeTokens } from "./current-theme"; diff --git a/src/ui/theme/presets.ts b/src/ui/theme/presets.ts new file mode 100644 index 0000000..118b3bc --- /dev/null +++ b/src/ui/theme/presets.ts @@ -0,0 +1,26 @@ +import type { ThemeTokens } from "./types"; + +/** 系统默认主题(唯一内置主题) */ +export const DEFAULT_THEME: ThemeTokens = { + accent: "#229ac3", + accentAlpha: "#229ac3e6", + active: "#89B4FA", + success: "#52c41a", + error: "#ff4d4f", + warning: "#faad14", + info: "#1677ff", + riskLow: "#22c55e", + riskMedium: "#f59e0b", + riskHigh: "#ef4444", + text: "#6C7086", + textDim: "#6C7086", + code: "#787f8a", + border: "#4C566A", + thinking: "#CCCFD3", + gradients: ["#229ac3", "#7c3aed"], +}; + +/** 预设主题映射表 */ +export const PRESETS: Record = { + default: DEFAULT_THEME, +}; diff --git a/src/ui/theme/resolver.ts b/src/ui/theme/resolver.ts new file mode 100644 index 0000000..f635217 --- /dev/null +++ b/src/ui/theme/resolver.ts @@ -0,0 +1,51 @@ +import { type ThemeTokens, type ThemeSettings } from "./types"; +import { DEFAULT_THEME } from "./presets"; + +/** + * 深度合并两个对象。right 的值覆盖 left。 + * 仅支持最多两层嵌套(ThemeTokens)。 + */ +function deepMerge(left: T, right: object): T { + const result = { ...left }; + for (const key of Object.keys(right) as string[]) { + const rv = (right as Record)[key]; + if (rv === undefined) { + continue; + } + const lv = (result as Record)[key]; + if (lv && typeof lv === "object" && !Array.isArray(lv) && rv && typeof rv === "object" && !Array.isArray(rv)) { + (result as Record)[key] = deepMerge(lv as object, rv); + } else { + (result as Record)[key] = rv; + } + } + return result; +} + +/** + * 解析主题配置,返回最终的 ThemeTokens。 + * + * - 未配置 / preset="default":使用系统默认 DEFAULT_THEME + * - preset="custom":使用用户自定义 tokens 或 overrides 合并到 DEFAULT_THEME + */ +export function resolveTheme(themeSettings: ThemeSettings | undefined): ThemeTokens { + if (!themeSettings) { + return DEFAULT_THEME; + } + + // preset 不为 "custom" 时使用默认主题 + if (themeSettings.preset !== "custom") { + return DEFAULT_THEME; + } + + // preset="custom":应用用户自定义 + if (themeSettings.tokens) { + return deepMerge(DEFAULT_THEME, themeSettings.tokens); + } + if (themeSettings.overrides) { + return deepMerge(DEFAULT_THEME, themeSettings.overrides); + } + + // preset="custom" 但没有提供自定义内容,回退默认 + return DEFAULT_THEME; +} diff --git a/src/ui/theme/types.ts b/src/ui/theme/types.ts new file mode 100644 index 0000000..803d4e6 --- /dev/null +++ b/src/ui/theme/types.ts @@ -0,0 +1,57 @@ +/** 主题颜色 Token 定义 */ +export interface ThemeTokens { + // ——— 品牌色 ——— + /** 主品牌色:Logo、用户消息、选中项,及 Markdown H1-H6 标题 */ + accent: string; + /** 品牌色(含透明度):边框、渐变 */ + accentAlpha: string; + /** 强调/活跃态颜色:下拉菜单的当前项 */ + active: string; + + // ——— 语义颜色 ——— + /** 成功:工具执行成功、MCP ready */ + success: string; + /** 失败/错误:工具执行失败、错误信息 */ + error: string; + /** 警告/进行中:忙时 spinner、权限提示,及 Markdown 列表标记 */ + warning: string; + /** 特殊指示:技能、图片附件 */ + info: string; + + // ——— 风险等级色 ——— + /** 低风险操作 */ + riskLow: string; + /** 中风险操作 */ + riskMedium: string; + /** 高风险操作 */ + riskHigh: string; + + // ——— 基础色 ——— + /** 主文字颜色 */ + text: string; + /** 次要文字:暗化提示,及 Markdown 引用块 */ + textDim: string; + /** 代码块/内联代码 */ + code: string; + /** 边框 */ + border: string; + /** 思考状态 bullet 颜色 */ + thinking: string; + + // ——— 渐变 ——— + /** Logo 渐变色数组 */ + gradients: string[]; +} + +/** 预设主题名称 */ +export type ThemePreset = "default" | "custom"; + +/** 主题配置(用户可配置部分) */ +export type ThemeSettings = { + /** 选择预设主题:"default" 使用系统默认,"custom" 使用用户自定义 */ + preset?: ThemePreset; + /** 覆盖部分 token(仅 preset="custom" 时生效) */ + overrides?: Partial; + /** 完全自定义(仅 preset="custom" 时生效,优先级高于 overrides) */ + tokens?: ThemeTokens; +}; diff --git a/src/ui/utils/index.ts b/src/ui/utils/index.ts index b9b61ec..ad19934 100644 --- a/src/ui/utils/index.ts +++ b/src/ui/utils/index.ts @@ -1,28 +1,29 @@ -import chalk from "chalk"; import { renderMessageToStdout } from "../components/MessageView/utils"; import type { RawMode } from "../contexts"; import type { PromptDraft } from "../views/PromptInput"; import type { ModelConfigSelection } from "../../settings"; import type { SessionEntry, SessionMessage } from "../../session"; import type { SessionManager } from "../../session"; +import { getCurrentThemedChalk } from "../theme"; /** * Render all messages directly to stdout for Raw mode display. * Writes each message followed by the "Press ESC to exit raw mode" footer. */ export function renderRawModeMessages(allMessages: SessionMessage[], mode: string | RawMode): void { + const tc = getCurrentThemedChalk(); for (const msg of allMessages) { process.stdout.write("\n"); process.stdout.write(renderMessageToStdout(msg, mode as RawMode) + "\n\n"); } if (allMessages.length > 0) { process.stdout.write("\n\n"); - process.stdout.write(chalk.dim("Press ESC to exit raw mode")); + process.stdout.write(tc.dim("Press ESC to exit raw mode")); } else { process.stdout.write("\n"); - process.stdout.write(chalk.dim("(No messages in this session yet. Start chatting to see them here.)")); + process.stdout.write(tc.dim("(No messages in this session yet. Start chatting to see them here.)")); process.stdout.write("\n\n"); - process.stdout.write(chalk.dim("Press ESC to exit raw mode")); + process.stdout.write(tc.dim("Press ESC to exit raw mode")); } } diff --git a/src/ui/views/App.tsx b/src/ui/views/App.tsx index bef803e..ee16601 100644 --- a/src/ui/views/App.tsx +++ b/src/ui/views/App.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink"; -import chalk from "chalk"; +import { getCurrentThemedChalk, useTheme } from "../theme"; import { createOpenAIClient } from "../../common/openai-client"; import type { PermissionScope } from "../../settings"; import { type ModelConfigSelection } from "../../settings"; @@ -59,6 +59,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl const { stdout, write } = useStdout(); const { columns, rows } = useWindowSize(); const { mode, setMode } = useRawModeContext(); + const theme = useTheme(); const initialPromptSubmittedRef = useRef(false); const processStdoutRef = useRef>(new Map()); const rawModeRef = useRef(mode); @@ -254,8 +255,9 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl const activeSessionId = sessionManager.getActiveSessionId(); const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; const summary = buildExitSummaryText({ session }); + const tc = getCurrentThemedChalk(); process.stdout.write("\n"); - process.stdout.write(chalk.rgb(34, 154, 195)("> /exit ")); + process.stdout.write(tc.accent("> /exit ")); process.stdout.write("\n\n"); process.stdout.write(summary); process.stdout.write("\n\n"); @@ -634,7 +636,9 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl return [welcomeItem, ...messages]; } return messages; - }, [mode, showWelcome, view, messages, welcomeItem]); + // theme 作为依赖确保主题切换时 Static 子组件重渲染 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, showWelcome, view, messages, welcomeItem, theme]); const handleQuestionAnswers = useCallback( (answers: AskUserQuestionAnswers) => { @@ -725,7 +729,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl ) : null} {errorLine ? ( - Error: {errorLine} + Error: {errorLine} ) : null} {showProcessStdout ? ( diff --git a/src/ui/views/AppContainer.tsx b/src/ui/views/AppContainer.tsx index d5f6363..2491b16 100644 --- a/src/ui/views/AppContainer.tsx +++ b/src/ui/views/AppContainer.tsx @@ -1,7 +1,9 @@ -import React from "react"; +import React, { useState } from "react"; import { AppContext } from "../contexts"; import App from "./App"; import { RawModeProvider } from "../contexts"; +import { ThemeProvider, setCurrentTheme } from "../theme"; +import { resolveCurrentSettings } from "../../settings"; const AppContainer: React.FC<{ projectRoot: string; @@ -9,11 +11,19 @@ const AppContainer: React.FC<{ initialPrompt: string | undefined; onRestart: () => void; }> = ({ version, projectRoot, initialPrompt, onRestart }) => { + const settings = resolveCurrentSettings(projectRoot); + const [theme] = useState(settings.theme); + + // 初始设置全局 chalk 主题 + setCurrentTheme(theme); + return ( - - - + + + + + ); }; diff --git a/src/ui/views/AskUserQuestionPrompt.tsx b/src/ui/views/AskUserQuestionPrompt.tsx index a2f91ad..257b6db 100644 --- a/src/ui/views/AskUserQuestionPrompt.tsx +++ b/src/ui/views/AskUserQuestionPrompt.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, Text } from "ink"; import type { AskUserQuestionAnswers, AskUserQuestionItem } from "../core/ask-user-question"; import { useTerminalInput } from "../hooks"; +import { useTheme } from "../theme"; type Props = { questions: AskUserQuestionItem[]; @@ -19,6 +20,7 @@ type OptionEntry = { }; export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): React.ReactElement | null { + const theme = useTheme(); const [questionIndex, setQuestionIndex] = useState(0); const [cursorIndex, setCursorIndex] = useState(0); const [answers, setAnswers] = useState({}); @@ -163,9 +165,9 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): } return ( - + - + Answer questions @@ -173,7 +175,9 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): {questionIndex + 1}/{questions.length} - {question.question} + + {question.question} + {options.map((option, index) => { const isCursor = index === cursorIndex; @@ -183,7 +187,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): const marker = question.multiSelect ? (isSelected ? "[x]" : "[ ]") : isSelected ? "●" : "○"; return ( - + {isCursor ? "> " : " "} {marker} {option.label} @@ -192,14 +196,14 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): marginLeft={4} marginTop={0} borderStyle="single" - borderColor={isCursor ? "cyanBright" : "gray"} + borderColor={isCursor ? theme.active : theme.textDim} paddingX={1} width={64} > {otherText ? ( - + {otherText} - {isCursor ? : null} + {isCursor ? : null} ) : ( {isCursor ? "type your answer here" : "type a custom answer"} diff --git a/src/ui/views/McpStatusList.tsx b/src/ui/views/McpStatusList.tsx index 40d2f3f..453fba4 100644 --- a/src/ui/views/McpStatusList.tsx +++ b/src/ui/views/McpStatusList.tsx @@ -1,6 +1,7 @@ import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import type { McpServerStatus } from "../../mcp/mcp-manager"; +import { useTheme } from "../theme"; type Props = { statuses: McpServerStatus[]; @@ -10,6 +11,7 @@ type Props = { export function McpStatusList({ statuses, onCancel, onReconnect }: Props): React.ReactElement { const { columns, rows } = useWindowSize(); + const theme = useTheme(); // 视图模式:server-list(服务器列表) 或 server-detail(服务器详情) const [viewMode, setViewMode] = useState<"server-list" | "server-detail">("server-list"); @@ -40,7 +42,7 @@ export function McpStatusList({ statuses, onCancel, onReconnect }: Props): React return ( - + Manage MCP servers 0 servers @@ -100,6 +102,7 @@ function ServerListView({ }): React.ReactElement { const [scrollOffset, setScrollOffset] = useState(0); const serverCount = statuses.length; + const theme = useTheme(); const maxVisible = useMemo(() => { const reservedLines = 8; // header + footer + borders @@ -190,15 +193,15 @@ function ServerListView({ {/* Header row */} - + Manage MCP servers ( - {readyCount} ready, - {startingCount} starting, - {reconnectingCount > 0 && {reconnectingCount} reconnecting,} - {failedCount} failed + {readyCount} ready, + {startingCount} starting, + {reconnectingCount > 0 && {reconnectingCount} reconnecting,} + {failedCount} failed ) @@ -255,16 +258,17 @@ function ServerRow({ selected: boolean; labelColumnWidth: number; }): React.ReactElement { + const theme = useTheme(); const icon = status.status === "ready" ? "✓" : status.status === "failed" ? "✗" : status.status === "reconnecting" ? "↻" : "●"; const color = status.status === "ready" - ? "green" + ? theme.success : status.status === "failed" - ? "red" + ? theme.error : status.status === "reconnecting" - ? "#ff9900" - : "yellow"; + ? theme.warning + : theme.warning; // 加载动画:循环显示 (空) → . → .. → ... → (空) → ... const [dots, setDots] = React.useState(0); @@ -290,7 +294,7 @@ function ServerRow({ {/* Server row */} - + {selected ? "> " : " "} {icon} {status.name} @@ -328,6 +332,7 @@ function ServerDetailView({ const [activeIndex, setActiveIndex] = React.useState(0); const hasReconnect = server.status === "failed"; const canScroll = server.status === "ready"; + const theme = useTheme(); // 合并所有 items(tools, prompts, resources)+ Reconnect 选项 const allItems = useMemo(() => { @@ -415,12 +420,12 @@ function ServerDetailView({ server.status === "ready" ? "✓" : server.status === "failed" ? "✗" : server.status === "reconnecting" ? "↻" : "●"; const statusColor = server.status === "ready" - ? "green" + ? theme.success : server.status === "failed" - ? "red" + ? theme.error : server.status === "reconnecting" - ? "#ff9900" - : "yellow"; + ? theme.warning + : theme.warning; return ( {statusIcon} - + {server.name} — {server.status === "ready" ? "Details" : "Status"} @@ -515,11 +520,12 @@ function ServerDetailView({ function ItemRow({ item, selected }: { item: { type: string; name: string }; selected: boolean }): React.ReactElement { const isAction = item.type === "action"; const icon = isAction ? "↻" : item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; - const color = isAction && selected ? "#ff9900" : selected ? "#229ac3" : undefined; + const theme = useTheme(); + const color = isAction && selected ? theme.warning : selected ? theme.accent : undefined; return ( - {selected ? "> " : " "} + {selected ? "> " : " "} {icon} {isAction ? `[${item.name}]` : item.name} @@ -529,8 +535,8 @@ function ItemRow({ item, selected }: { item: { type: string; name: string }; sel } function ErrorRow({ error }: { error: string }): React.ReactElement { - // 将错误消息按行分割,每行单独显示 const lines = error.split("\n").filter((line) => line.trim().length > 0); + const theme = useTheme(); return ( {lines.map((line, index) => ( - + {line} diff --git a/src/ui/views/PermissionPrompt.tsx b/src/ui/views/PermissionPrompt.tsx index 320dd7a..f89ad2f 100644 --- a/src/ui/views/PermissionPrompt.tsx +++ b/src/ui/views/PermissionPrompt.tsx @@ -3,6 +3,8 @@ import { Box, Text } from "ink"; import { useTerminalInput } from "../hooks"; import type { AskPermissionRequest, AskPermissionScope, UserToolPermission } from "../../common/permissions"; import type { PermissionScope } from "../../settings"; +import { useTheme } from "../theme/ThemeContext"; +import type { ThemeTokens } from "../theme/types"; export type PermissionPromptResult = { permissions: UserToolPermission[]; @@ -42,6 +44,7 @@ const ALWAYS_ALLOWED_SCOPES = new Set([ ]); export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React.ReactElement | null { + const theme = useTheme(); const prompts = useMemo(() => buildScopePrompts(requests), [requests]); const [index, setIndex] = useState(0); const [cursor, setCursor] = useState(0); @@ -50,7 +53,7 @@ export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React const effectiveIndex = findNextPromptIndex(prompts, index, alwaysAllows); const prompt = prompts[effectiveIndex] ?? null; - const options = prompt ? buildOptions(prompt.scope) : []; + const options = prompt ? buildOptions(prompt.scope, theme) : []; useEffect(() => { setIndex(0); @@ -126,9 +129,9 @@ export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React } return ( - + - + Permission required @@ -136,15 +139,17 @@ export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React {Math.min(effectiveIndex + 1, prompts.length)}/{prompts.length} - {prompt.request.name} - {prompt.request.command} + + {prompt.request.name} + + {prompt.request.command} {prompt.request.description ? {prompt.request.description} : null} - Do you want to proceed? + Do you want to proceed? {options.map((option, optionIndex) => ( - + {optionIndex === cursor ? "> " : " "} {optionIndex + 1}. {renderOptionLabel(option)} @@ -179,14 +184,14 @@ function buildScopePrompts(requests: AskPermissionRequest[]): ScopePrompt[] { return prompts; } -function buildOptions(scope: AskPermissionScope): PromptOption[] { +function buildOptions(scope: AskPermissionScope, theme: ThemeTokens): PromptOption[] { const options: PromptOption[] = [{ kind: "allow", label: "Yes" }]; if (isAlwaysAllowedScope(scope)) { options.push({ kind: "always", label: "Yes, and always allow ", scopeDescription: describeScope(scope), - scopeColor: getScopeRiskColor(scope), + scopeColor: getScopeRiskColor(scope, theme), }); } options.push({ kind: "deny", label: "No" }); @@ -226,24 +231,25 @@ function isAlwaysAllowedScope(scope: AskPermissionScope): scope is PermissionSco return ALWAYS_ALLOWED_SCOPES.has(scope); } -export function getScopeRiskColor(scope: AskPermissionScope): string { +export function getScopeRiskColor(scope: AskPermissionScope, theme?: ThemeTokens): string { + const t = theme ?? { riskLow: "#22c55e", riskMedium: "#f59e0b", riskHigh: "#ef4444" }; switch (scope) { case "read-in-cwd": case "query-git-log": - return "#22c55e"; + return t.riskLow; case "read-out-cwd": case "write-in-cwd": case "network": case "mcp": - return "#f59e0b"; + return t.riskMedium; case "write-out-cwd": case "delete-in-cwd": case "delete-out-cwd": case "mutate-git-log": case "unknown": - return "#ef4444"; + return t.riskHigh; default: - return "#ef4444"; + return t.riskHigh; } } diff --git a/src/ui/views/ProcessStdoutView.tsx b/src/ui/views/ProcessStdoutView.tsx index bd5e636..5b68253 100644 --- a/src/ui/views/ProcessStdoutView.tsx +++ b/src/ui/views/ProcessStdoutView.tsx @@ -3,6 +3,7 @@ import { Box, Text } from "ink"; import { BASH_TIMEOUT_DECREMENT_MS, BASH_TIMEOUT_INCREMENT_MS } from "../../common/bash-timeout"; import type { BashTimeoutAdjustment, SessionEntry, SessionProcessEntry } from "../../session"; import { useTerminalInput } from "../hooks"; +import { useTheme } from "../theme"; type RunningProcesses = SessionEntry["processes"]; @@ -27,6 +28,7 @@ export const ProcessStdoutView = React.memo(function ProcessStdoutView({ screenWidth, screenHeight, }: ProcessStdoutViewProps): React.ReactElement { + const theme = useTheme(); const [stdoutText, setStdoutText] = useState(""); const [scrollOffset, setScrollOffset] = useState(0); const [statusMessage, setStatusMessage] = useState(""); @@ -133,7 +135,9 @@ export const ProcessStdoutView = React.memo(function ProcessStdoutView({ return ( - 📟 Process Output + + 📟 Process Output + {` (${formatTimeoutHint( timeoutProcess?.entry )} · +/- adjust · Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll)`} diff --git a/src/ui/views/PromptInput.tsx b/src/ui/views/PromptInput.tsx index b812a73..b6a0cd0 100644 --- a/src/ui/views/PromptInput.tsx +++ b/src/ui/views/PromptInput.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; +import { useTheme } from "../theme"; import { ARGS_SEPARATOR } from "../constants"; import { EMPTY_BUFFER, @@ -95,6 +96,7 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { const [spinnerIndex, setSpinnerIndex] = useState(0); + const theme = useTheme(); useEffect(() => { if (!busy) { @@ -108,7 +110,7 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }, [busy]); const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; - return {prefix}; + return {prefix}; }); export const PromptInput = React.memo(function PromptInput({ @@ -131,6 +133,7 @@ export const PromptInput = React.memo(function PromptInput({ }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); + const theme = useTheme(); const [buffer, setBuffer] = useState(EMPTY_BUFFER); const [imageUrls, setImageUrls] = useState([]); const [selectedSkills, setSelectedSkills] = useState([]); @@ -726,13 +729,13 @@ export const PromptInput = React.memo(function PromptInput({ {imageUrls.length > 0 ? ( - {formatImageAttachmentStatus(imageUrls.length)} + {formatImageAttachmentStatus(imageUrls.length)} {` (${IMAGE_ATTACHMENT_CLEAR_HINT})`} ) : null} {selectedSkills.length > 0 ? ( - + {formatSelectedSkillsStatus(selectedSkills)} (use /skills to edit) @@ -748,7 +751,9 @@ export const PromptInput = React.memo(function PromptInput({ borderDimColor > - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)} + + {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current, theme.warning)} + {inlineHint ? {inlineHint} : null} + validPastes?: Map, + highlightColor?: string ): string { const text = state.text || ""; const cursor = Math.max(0, Math.min(state.cursor, text.length)); const validIds = validPastes ?? new Map(); + const h = highlightColor ?? "#faad14"; if (text.length === 0 && placeholder) { if (!isFocused) { @@ -884,13 +891,13 @@ export function renderBufferWithCursor( } if (!isFocused) { - return highlightPasteMarkersInText(text, validIds); + return highlightPasteMarkersInText(text, validIds, h); } - return renderFocusedText(text, cursor, validIds); + return renderFocusedText(text, cursor, validIds, h); } -function highlightPasteMarkersInText(s: string, validIds: Map): string { +function highlightPasteMarkersInText(s: string, validIds: Map, highlightColor: string): string { if (!s.includes("[paste #")) return s; PASTE_MARKER_REGEX.lastIndex = 0; let result = ""; @@ -899,7 +906,7 @@ function highlightPasteMarkersInText(s: string, validIds: Map): while ((match = PASTE_MARKER_REGEX.exec(s)) !== null) { result += s.slice(pos, match.index); const id = Number.parseInt(match[1]!, 10); - result += validIds.has(id) ? chalk.yellow(match[0]) : match[0]; + result += validIds.has(id) ? chalk.hex(highlightColor)(match[0]) : match[0]; pos = match.index + match[0].length; } result += s.slice(pos); @@ -912,7 +919,12 @@ function highlightPasteMarkersInText(s: string, validIds: Map): * anywhere (including inside or at the boundary of a paste marker) and the * marker will still be highlighted correctly. */ -function renderFocusedText(text: string, cursor: number, validIds: Map): string { +function renderFocusedText( + text: string, + cursor: number, + validIds: Map, + highlightColor: string +): string { let result = ""; let pos = 0; PASTE_MARKER_REGEX.lastIndex = 0; @@ -925,16 +937,16 @@ function renderFocusedText(text: string, cursor: number, validIds: Map= end) return ""; @@ -957,12 +970,12 @@ function renderTextSegmentWithCursor( // Cursor not in this segment – just return the text. if (cursorRel < 0 || cursorRel > segText.length) { - return highlighted ? chalk.yellow(segText) : segText; + return highlighted ? chalk.hex(highlightColor)(segText) : segText; } // Cursor is exactly at `end` (which equals `segText.length`). if (cursorRel === segText.length) { - return highlighted ? chalk.yellow(segText) + renderCursorCell(" ") : segText + renderCursorCell(" "); + return highlighted ? chalk.hex(highlightColor)(segText) + renderCursorCell(" ") : segText + renderCursorCell(" "); } // Cursor is somewhere inside the segment. @@ -978,7 +991,7 @@ function renderTextSegmentWithCursor( const before = segText.slice(0, cursorRel); const after = segText.slice(cursorRel + 1); if (highlighted) { - return chalk.yellow(before) + renderCursorCell(at) + chalk.yellow(after); + return chalk.hex(highlightColor)(before) + renderCursorCell(at) + chalk.hex(highlightColor)(after); } return before + renderCursorCell(at) + after; } diff --git a/src/ui/views/SessionList.tsx b/src/ui/views/SessionList.tsx index ac53f21..5448553 100644 --- a/src/ui/views/SessionList.tsx +++ b/src/ui/views/SessionList.tsx @@ -2,6 +2,7 @@ import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import type { SessionEntry, SessionStatus } from "../../session"; import { truncate } from "../components/MessageView/utils"; +import { useTheme } from "../theme"; type Props = { sessions: SessionEntry[]; @@ -43,6 +44,7 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): const [searchQuery, setSearchQuery] = useState(""); const [confirmDeleteSessionId, setConfirmDeleteSessionId] = useState(null); const { columns, rows } = useWindowSize(); + const theme = useTheme(); // Filter sessions by search query const filteredSessions = useMemo(() => filterSessions(sessions, searchQuery), [sessions, searchQuery]); @@ -180,7 +182,7 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): if (sessions.length === 0) { return ( - No previous sessions found. + No previous sessions found. Press Esc to go back. ); @@ -198,12 +200,11 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): {/* Header row */} - - + + Resume a session - - {" "} + ({sessions.length} total {hasActiveSearch ? `, ${filteredSessions.length} matched` : ""}) @@ -230,7 +231,7 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): > {filteredSessions.length === 0 ? ( - No sessions match "{searchQuery}". + No sessions match "{searchQuery}". ) : ( visibleSessions.map((session, i) => { @@ -240,15 +241,15 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): return ( - {isSelected ? "> " : " "} + {isSelected ? "> " : " "} - + {formatSessionTitle(session.summary || "Untitled")} {isConfirming ? ( - [Delete? Enter=yes, Esc=no] + [Delete? Enter=yes, Esc=no] ) : ( ({formatSessionStatus(session.status)}) )} @@ -274,12 +275,12 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): {confirmDeleteSessionId ? ( - Delete this session? - + Delete this session? + Enter to confirm · - + Esc to cancel diff --git a/src/ui/views/SlashCommandMenu.tsx b/src/ui/views/SlashCommandMenu.tsx index d93446d..44ca468 100644 --- a/src/ui/views/SlashCommandMenu.tsx +++ b/src/ui/views/SlashCommandMenu.tsx @@ -4,6 +4,7 @@ import { ARGS_SEPARATOR } from "../constants"; import React from "react"; import { Box, Text } from "ink"; import type { SkillInfo } from "../../session"; +import { useTheme } from "../theme"; type SlashCommandMenuProps = { items: SlashCommandItem[]; @@ -20,6 +21,7 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ maxVisible = 6, width, }: SlashCommandMenuProps): React.ReactElement | null { + const theme = useTheme(); // 计算标签列最佳宽度:包含前缀"> "或" "(2字符),不超过容器一半(扣除gap) const labelColumnWidth = React.useMemo(() => { if (items.length === 0) { @@ -56,14 +58,14 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ return ( - + {actualIndex === activeIndex ? "> " : " "} {formatSlashCommandLabel(item)} {item.args ? {item.args.join(ARGS_SEPARATOR)} : null} - + {formatSlashCommandDescription(item.description)} diff --git a/src/ui/views/ThemedGradient.tsx b/src/ui/views/ThemedGradient.tsx index f2c2369..31f47fc 100644 --- a/src/ui/views/ThemedGradient.tsx +++ b/src/ui/views/ThemedGradient.tsx @@ -1,9 +1,11 @@ import type React from "react"; import { Text, type TextProps } from "ink"; import Gradient from "ink-gradient"; +import { useTheme } from "../theme"; export const ThemedGradient: React.FC = ({ children, ...props }) => { - const gradient = ["#229ac3e6", "#229ac3e6"]; // Use solid color for now + const theme = useTheme(); + const gradient = theme.gradients; if (gradient && gradient.length >= 2) { return ( @@ -23,7 +25,7 @@ export const ThemedGradient: React.FC = ({ children, ...props }) => { // Fallback to accent color if no gradient return ( - + {children} ); diff --git a/src/ui/views/UndoSelector.tsx b/src/ui/views/UndoSelector.tsx index 977bca2..8b9c443 100644 --- a/src/ui/views/UndoSelector.tsx +++ b/src/ui/views/UndoSelector.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import type { UndoTarget } from "../../session"; +import { useTheme } from "../theme"; export type UndoRestoreMode = "code-and-conversation" | "conversation"; @@ -19,6 +20,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac const [targetIndex, setTargetIndex] = useState(Math.max(0, targets.length - 1)); const [modeIndex, setModeIndex] = useState(0); const { columns, rows } = useWindowSize(); + const theme = useTheme(); const safeTargetIndex = useMemo(() => { if (targets.length === 0) { @@ -82,7 +84,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac if (targets.length === 0) { return ( - Nothing to undo yet. + Nothing to undo yet. Press Esc to go back. ); @@ -99,7 +101,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac > - + Undo restore to the point before a prompt @@ -122,9 +124,9 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac const isActive = actualIndex === safeTargetIndex; return ( - {isActive ? "> " : " "} + {isActive ? "> " : " "} - + {formatUndoMessage(target.message.content)} @@ -152,7 +154,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac Selected prompt: {formatUndoMessage(selectedTarget?.message.content ?? "")} - + {modeIndex === 0 ? "> " : " "}Restore code and conversation @@ -161,7 +163,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac ? "Restore files from the recorded Git checkpoint, then fork the conversation." : "No code checkpoint is recorded for this prompt."} - + {modeIndex === 1 ? "> " : " "}Restore conversation {" "}Fork the conversation without changing files. diff --git a/src/ui/views/UpdatePrompt.tsx b/src/ui/views/UpdatePrompt.tsx index f2b9e21..123fd94 100644 --- a/src/ui/views/UpdatePrompt.tsx +++ b/src/ui/views/UpdatePrompt.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { Box, Text, useApp, useInput } from "ink"; +import { useTheme } from "../theme"; export type UpdatePromptChoice = "install" | "ignore-once" | "ignore-version"; @@ -17,6 +18,7 @@ type Props = { export function UpdatePrompt({ currentVersion, latestVersion, installCommand, onSelect }: Props): React.ReactElement { const { exit } = useApp(); + const theme = useTheme(); const [selectedIndex, setSelectedIndex] = useState(0); const options: UpdatePromptOption[] = [ { @@ -60,14 +62,14 @@ export function UpdatePrompt({ currentVersion, latestVersion, installCommand, on return ( - + Deep Code latest version has been released: {currentVersion} -> {latestVersion} {options.map((option, index) => { const selected = index === selectedIndex; return ( - + {selected ? "> " : " "} {index + 1}. {option.label} diff --git a/src/ui/views/WelcomeScreen.tsx b/src/ui/views/WelcomeScreen.tsx index 96aef71..2d55282 100644 --- a/src/ui/views/WelcomeScreen.tsx +++ b/src/ui/views/WelcomeScreen.tsx @@ -8,6 +8,7 @@ import { buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescripti import { ThemedGradient } from "./ThemedGradient"; import { AsciiLogo } from "../ascii-art"; import { useAppContext } from "../contexts"; +import { useTheme } from "../theme"; type WelcomeScreenProps = { projectRoot: string; @@ -30,6 +31,7 @@ const SHORTCUT_TIPS = [ export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeScreenProps): React.ReactElement { const { version } = useAppContext(); + const theme = useTheme(); const tips = useMemo(() => buildWelcomeTips(skills), [skills]); const [tipIndex] = useState(() => randomTipIndex(tips.length)); const compact = width < TITLE_PANEL_WIDTH + 42; @@ -49,7 +51,7 @@ export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeS - {">"}_ Deep Code - (v{version || "unknown"}) + {">"}_ Deep Code + (v{version || "unknown"}) {!compact ? : null} From fdd9909f7f7cd68118becd9fbbbb3a68fad5dd2e Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 28 May 2026 16:48:47 +0800 Subject: [PATCH 02/19] =?UTF-8?q?refactor(theme):=20=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E8=89=B2=E4=BB=8E=20accent=20=E5=88=B0=20pri?= =?UTF-8?q?mary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将全局主题中所有 accent/active 样式替换为 primary 样式 - 替换主题 token 名称及测试中的相关字段与断言 - 调整多个组件及视图中的颜色应用,使用 primary 色调 - 更新 Chalk 主题工程实现,使用 primary 和 secondary 代替 accent 和 accentAlpha - 替换边框颜色及渐变色为 secondary - 使用 useEffect 初始化主题,确保灵活响应主题变更 - 简化部分 UI 组件边框样式与文本加粗设置,统一区域主色调用 --- src/tests/theme.test.ts | 103 ++++++++++---------- src/ui/components/DropdownMenu/index.tsx | 38 +++----- src/ui/components/FileMentionMenu/index.tsx | 4 +- src/ui/components/MessageView/index.tsx | 10 +- src/ui/components/MessageView/utils.ts | 4 +- src/ui/exit-summary.ts | 2 +- src/ui/theme/chalk-theme.ts | 18 ++-- src/ui/theme/presets.ts | 19 ++-- src/ui/theme/types.ts | 8 +- src/ui/views/App.tsx | 2 +- src/ui/views/AppContainer.tsx | 8 +- src/ui/views/AskUserQuestionPrompt.tsx | 6 +- src/ui/views/McpStatusList.tsx | 23 +++-- src/ui/views/PermissionPrompt.tsx | 2 +- src/ui/views/PromptInput.tsx | 4 +- src/ui/views/SessionList.tsx | 12 +-- src/ui/views/SlashCommandMenu.tsx | 4 +- src/ui/views/ThemedGradient.tsx | 4 +- src/ui/views/UndoSelector.tsx | 16 +-- src/ui/views/UpdatePrompt.tsx | 2 +- src/ui/views/WelcomeScreen.tsx | 4 +- 21 files changed, 141 insertions(+), 152 deletions(-) diff --git a/src/tests/theme.test.ts b/src/tests/theme.test.ts index 3495451..54e69ca 100644 --- a/src/tests/theme.test.ts +++ b/src/tests/theme.test.ts @@ -20,9 +20,8 @@ chalk.level = 1; /** All token keys that every ThemeTokens must define. */ const REQUIRED_TOKEN_KEYS: Array = [ - "accent", - "accentAlpha", - "active", + "primary", + "secondary", "success", "error", "warning", @@ -53,23 +52,22 @@ test("DEFAULT_THEME has all required token keys", () => { } }); -test("DEFAULT_THEME accent matches expected brand color", () => { - assert.equal(DEFAULT_THEME.accent, "#229ac3"); - assert.equal(DEFAULT_THEME.accentAlpha, "#229ac3e6"); +test("DEFAULT_THEME primary matches expected brand color", () => { + assert.equal(DEFAULT_THEME.primary, "#229ac3"); + assert.equal(DEFAULT_THEME.secondary, "#229ac3e6"); }); test("DEFAULT_THEME semantic colors match expected values", () => { assert.equal(DEFAULT_THEME.success, "#52c41a"); - assert.equal(DEFAULT_THEME.error, "#ff4d4f"); - assert.equal(DEFAULT_THEME.warning, "#faad14"); - assert.equal(DEFAULT_THEME.info, "#1677ff"); - assert.equal(DEFAULT_THEME.active, "#89B4FA"); - assert.equal(DEFAULT_THEME.thinking, "#CCCFD3"); + assert.equal(DEFAULT_THEME.error, "#f5222d"); + assert.equal(DEFAULT_THEME.warning, "#fa8c16"); + assert.equal(DEFAULT_THEME.info, "#2f54eb"); + assert.equal(DEFAULT_THEME.thinking, "#ff4400"); }); test("DEFAULT_THEME base colors match expected values", () => { - assert.equal(DEFAULT_THEME.text, "#6C7086"); - assert.equal(DEFAULT_THEME.textDim, "#6C7086"); + assert.equal(DEFAULT_THEME.text, "#3D4149"); + assert.equal(DEFAULT_THEME.textDim, "#646A71"); assert.equal(DEFAULT_THEME.code, "#787f8a"); }); @@ -91,27 +89,27 @@ test("PRESETS map contains default", () => { test("resolveTheme returns DEFAULT_THEME when settings is undefined", () => { const result = resolveTheme(undefined); - assert.equal(result.accent, DEFAULT_THEME.accent); + assert.equal(result.primary, DEFAULT_THEME.primary); assert.equal(result.success, DEFAULT_THEME.success); }); test("resolveTheme returns DEFAULT_THEME for explicit 'default' preset", () => { const result = resolveTheme({ preset: "default" }); - assert.equal(result.accent, DEFAULT_THEME.accent); + assert.equal(result.primary, DEFAULT_THEME.primary); }); test("resolveTheme returns DEFAULT_THEME when preset is not 'custom'", () => { const result = resolveTheme({ preset: "default" }); assert.equal(result.text, DEFAULT_THEME.text); - assert.equal(result.accent, DEFAULT_THEME.accent); + assert.equal(result.primary, DEFAULT_THEME.primary); }); test("resolveTheme applies overrides when preset is 'custom'", () => { const result = resolveTheme({ preset: "custom", - overrides: { accent: "#ff0000" }, + overrides: { primary: "#ff0000" }, }); - assert.equal(result.accent, "#ff0000"); + assert.equal(result.primary, "#ff0000"); assert.equal(result.success, DEFAULT_THEME.success); }); @@ -119,12 +117,12 @@ test("resolveTheme applies multiple overrides with custom preset", () => { const result = resolveTheme({ preset: "custom", overrides: { - accent: "#ff6600", + primary: "#ff6600", success: "greenBright", warning: "yellowBright", }, }); - assert.equal(result.accent, "#ff6600"); + assert.equal(result.primary, "#ff6600"); assert.equal(result.success, "greenBright"); assert.equal(result.warning, "yellowBright"); assert.equal(result.error, DEFAULT_THEME.error); @@ -132,9 +130,8 @@ test("resolveTheme applies multiple overrides with custom preset", () => { test("resolveTheme full custom tokens with custom preset", () => { const customTokens: ThemeTokens = { - accent: "#aaaaaa", - accentAlpha: "#aaaaaacc", - active: "blue", + primary: "#aaaaaa", + secondary: "#aaaaaacc", success: "blue", error: "blue", warning: "blue", @@ -150,7 +147,7 @@ test("resolveTheme full custom tokens with custom preset", () => { gradients: ["#aaaaaa", "#bbbbbb"], }; const result = resolveTheme({ preset: "custom", tokens: customTokens }); - assert.equal(result.accent, "#aaaaaa"); + assert.equal(result.primary, "#aaaaaa"); assert.equal(result.code, "blue"); assert.deepEqual(result.gradients, ["#aaaaaa", "#bbbbbb"]); }); @@ -158,43 +155,43 @@ test("resolveTheme full custom tokens with custom preset", () => { test("resolveTheme handles override with undefined fields gracefully", () => { const result = resolveTheme({ preset: "custom", - overrides: { accent: undefined, success: undefined } as Partial, + overrides: { primary: undefined, success: undefined } as Partial, }); - assert.equal(result.accent, DEFAULT_THEME.accent); + assert.equal(result.primary, DEFAULT_THEME.primary); assert.equal(result.success, DEFAULT_THEME.success); }); test("resolveTheme ignores overrides when preset is not custom", () => { const result = resolveTheme({ preset: "default", - overrides: { accent: "#ff0000" }, + overrides: { primary: "#ff0000" }, }); - assert.equal(result.accent, DEFAULT_THEME.accent); + assert.equal(result.primary, DEFAULT_THEME.primary); }); test("resolveTheme ignores tokens when preset is not custom", () => { const result = resolveTheme({ - tokens: { accent: "#ff0000" } as ThemeTokens, + tokens: { primary: "#ff0000" } as ThemeTokens, }); - assert.equal(result.accent, DEFAULT_THEME.accent); + assert.equal(result.primary, DEFAULT_THEME.primary); }); test("resolveTheme returns DEFAULT_THEME for custom preset without token/overrides", () => { const result = resolveTheme({ preset: "custom" }); - assert.equal(result.accent, DEFAULT_THEME.accent); + assert.equal(result.primary, DEFAULT_THEME.primary); }); // --------------------------------------------------------------------------- // createThemedChalk — markdown 方法直接复用顶层 token // --------------------------------------------------------------------------- -test("createThemedChalk heading1 produces styled output via accent", () => { +test("createThemedChalk heading1 produces styled output via primary", () => { const tc = createThemedChalk(DEFAULT_THEME); assert.notEqual(tc.heading1("Hello"), "Hello"); }); -test("createThemedChalk heading1 changes when accent changes", () => { - const custom: ThemeTokens = { ...DEFAULT_THEME, accent: "#ff0000" }; +test("createThemedChalk heading1 changes when primary changes", () => { + const custom: ThemeTokens = { ...DEFAULT_THEME, primary: "#ff0000" }; assert.notEqual(createThemedChalk(DEFAULT_THEME).heading1("test"), createThemedChalk(custom).heading1("test")); }); @@ -220,16 +217,16 @@ test("createThemedChalk bold / italic / dim produce styled output", () => { assert.notEqual(tc.dim("dim"), "dim"); }); -test("createThemedChalk produces different output for different accent values", () => { - const custom1: ThemeTokens = { ...DEFAULT_THEME, accent: "#ff0000" }; - const custom2: ThemeTokens = { ...DEFAULT_THEME, accent: "#00ff00" }; - assert.notEqual(createThemedChalk(custom1).accent("test"), createThemedChalk(custom2).accent("test")); +test("createThemedChalk produces different output for different primary values", () => { + const custom1: ThemeTokens = { ...DEFAULT_THEME, primary: "#ff0000" }; + const custom2: ThemeTokens = { ...DEFAULT_THEME, primary: "#00ff00" }; + assert.notEqual(createThemedChalk(custom1).primary("test"), createThemedChalk(custom2).primary("test")); }); test("createThemedChalk handles hex colors correctly", () => { const hexTheme: ThemeTokens = { ...DEFAULT_THEME, - accent: "#ff6600", + primary: "#ff6600", warning: "#ffcc00", code: "#00ccff", }; @@ -244,16 +241,16 @@ test("createThemedChalk handles hex colors correctly", () => { test("getCurrentThemedChalk returns DEFAULT_THEME chalk by default", () => { setCurrentTheme(DEFAULT_THEME); - assert.notEqual(getCurrentThemedChalk().accent("test"), "test"); + assert.notEqual(getCurrentThemedChalk().primary("test"), "test"); }); test("setCurrentTheme changes getCurrentThemedChalk output", () => { setCurrentTheme(DEFAULT_THEME); - const first = getCurrentThemedChalk().accent("test"); + const first = getCurrentThemedChalk().primary("test"); - const custom: ThemeTokens = { ...DEFAULT_THEME, accent: "#ff0000" }; + const custom: ThemeTokens = { ...DEFAULT_THEME, primary: "#ff0000" }; setCurrentTheme(custom); - const second = getCurrentThemedChalk().accent("test"); + const second = getCurrentThemedChalk().primary("test"); assert.notEqual(first, second); @@ -262,11 +259,11 @@ test("setCurrentTheme changes getCurrentThemedChalk output", () => { test("setCurrentTheme changes getCurrentThemeTokens output", () => { setCurrentTheme(DEFAULT_THEME); - assert.equal(getCurrentThemeTokens().accent, DEFAULT_THEME.accent); + assert.equal(getCurrentThemeTokens().primary, DEFAULT_THEME.primary); - const custom: ThemeTokens = { ...DEFAULT_THEME, accent: "#ff0000" }; + const custom: ThemeTokens = { ...DEFAULT_THEME, primary: "#ff0000" }; setCurrentTheme(custom); - assert.equal(getCurrentThemeTokens().accent, "#ff0000"); + assert.equal(getCurrentThemeTokens().primary, "#ff0000"); setCurrentTheme(DEFAULT_THEME); }); @@ -278,37 +275,37 @@ test("setCurrentTheme changes getCurrentThemeTokens output", () => { test("resolveSettingsSources includes theme field in resolved settings", () => { const result = resolveSettingsSources(null, null, DEFAULTS, {}); assert.ok("theme" in result); - assert.equal(result.theme.accent, DEFAULT_THEME.accent); + assert.equal(result.theme.primary, DEFAULT_THEME.primary); }); test("resolveSettingsSources resolves custom theme from user settings", () => { const result = resolveSettingsSources( - { theme: { preset: "custom", overrides: { accent: "#abcdef" } } }, + { theme: { preset: "custom", overrides: { primary: "#abcdef" } } }, null, DEFAULTS, {} ); - assert.equal(result.theme.accent, "#abcdef"); + assert.equal(result.theme.primary, "#abcdef"); }); test("resolveSettingsSources resolves custom theme from project settings", () => { const result = resolveSettingsSources( null, - { theme: { preset: "custom", overrides: { accent: "#123456" } } }, + { theme: { preset: "custom", overrides: { primary: "#123456" } } }, DEFAULTS, {} ); - assert.equal(result.theme.accent, "#123456"); + assert.equal(result.theme.primary, "#123456"); }); test("resolveSettingsSources uses default theme when preset is not custom", () => { const result = resolveSettingsSources( - { theme: { preset: "default", overrides: { accent: "#abcdef" } } }, + { theme: { preset: "default", overrides: { primary: "#abcdef" } } }, null, DEFAULTS, {} ); - assert.equal(result.theme.accent, DEFAULT_THEME.accent); + assert.equal(result.theme.primary, DEFAULT_THEME.primary); }); // --------------------------------------------------------------------------- diff --git a/src/ui/components/DropdownMenu/index.tsx b/src/ui/components/DropdownMenu/index.tsx index f34bc12..f6439ff 100644 --- a/src/ui/components/DropdownMenu/index.tsx +++ b/src/ui/components/DropdownMenu/index.tsx @@ -72,8 +72,8 @@ const DropdownMenu = React.memo(function DropdownMenu({ renderItem, }: DropdownMenuProps): React.ReactElement | null { const theme = useTheme(); - const effectiveTitleColor = titleColor ?? theme.accent; - const effectiveActiveColor = activeColor ?? theme.active; + const effectiveTitleColor = titleColor ?? theme.primary; + const effectiveActiveColor = activeColor ?? theme.primary; // Calculate visible window const visibleStart = calculateVisibleStart(activeIndex, items?.length, maxVisible); const visibleItems = items?.slice(visibleStart, visibleStart + maxVisible); @@ -117,18 +117,20 @@ const DropdownMenu = React.memo(function DropdownMenu({ } return ( - + {/* Title */} {title ? ( - + {title} @@ -159,7 +161,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ {isActive ? "> " : " "} - {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} + {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} {item.statusIndicator ? ( {item.statusIndicator.symbol} ) : null} @@ -180,15 +182,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ {/* Help text */} {helpText ? ( - + {helpText} ) : null} diff --git a/src/ui/components/FileMentionMenu/index.tsx b/src/ui/components/FileMentionMenu/index.tsx index 96b3a14..96da8db 100644 --- a/src/ui/components/FileMentionMenu/index.tsx +++ b/src/ui/components/FileMentionMenu/index.tsx @@ -98,9 +98,9 @@ const FileMentionMenu: React.FC = ({ open, width, token, items, onClose, maxVisible={8} renderItem={(item, isActive) => ( - {isActive ? "> " : " "} + {isActive ? "> " : " "} - + {item.label} diff --git a/src/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx index a52d7c5..ef5b13f 100644 --- a/src/ui/components/MessageView/index.tsx +++ b/src/ui/components/MessageView/index.tsx @@ -25,10 +25,10 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return ( - {`>`} + {`>`} - {text} + {text} {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( {` 📎 ${message.contentParams.length} image attachment(s)`} ) : null} @@ -66,7 +66,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return ( - + {content @@ -114,10 +114,10 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return ( - {`>`} + {`>`} - {message.content} + {message.content} ); diff --git a/src/ui/components/MessageView/utils.ts b/src/ui/components/MessageView/utils.ts index fb9f3d8..bc1d730 100644 --- a/src/ui/components/MessageView/utils.ts +++ b/src/ui/components/MessageView/utils.ts @@ -212,7 +212,7 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s if (message.role === "user") { const text = message.content || "(no content)"; - return tc.accent(`> ${text}`); + return tc.primary(`> ${text}`); } if (message.role === "assistant") { @@ -258,7 +258,7 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s if (message.role === "system") { if (message.meta?.isModelChange) { - return tc.accent(`> ${message.content}`); + return tc.primary(`> ${message.content}`); } if (message.meta?.skill && typeof message.meta.skill === "object") { const skillName = (message.meta.skill as { name?: unknown }).name; diff --git a/src/ui/exit-summary.ts b/src/ui/exit-summary.ts index 931d0c3..0400845 100644 --- a/src/ui/exit-summary.ts +++ b/src/ui/exit-summary.ts @@ -75,7 +75,7 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const theme = getCurrentThemeTokens(); const tc = getCurrentThemedChalk(); - const borderColor = chalk.hex(theme.accentAlpha); + const borderColor = chalk.hex(theme.secondary); const titleColor = gradientString(...theme.gradients); const line = (text: string) => `${borderColor("│")} ${padRight(text, contentWidth)} ${borderColor("│")}`; diff --git a/src/ui/theme/chalk-theme.ts b/src/ui/theme/chalk-theme.ts index 6475e8b..e451c2b 100644 --- a/src/ui/theme/chalk-theme.ts +++ b/src/ui/theme/chalk-theme.ts @@ -35,8 +35,8 @@ export interface ThemedChalk { bold: (text: string) => string; italic: (text: string) => string; dim: (text: string) => string; - accent: (text: string) => string; - accentAlpha: (text: string) => string; + primary: (text: string) => string; + secondary: (text: string) => string; text: (text: string) => string; textDim: (text: string) => string; success: (text: string) => string; @@ -46,8 +46,8 @@ export interface ThemedChalk { } export function createThemedChalk(theme: ThemeTokens): ThemedChalk { - const ac = chalkColor(theme.accent); - const aa = chalkColor(theme.accentAlpha); + const pr = chalkColor(theme.primary); + const se = chalkColor(theme.secondary); const tx = chalkColor(theme.text); const td = chalkColor(theme.textDim); const cd = chalkColor(theme.code); @@ -58,9 +58,9 @@ export function createThemedChalk(theme: ThemeTokens): ThemedChalk { return { // Markdown 渲染 — 直接复用顶层 token - heading1: (text: string) => chalk.bold(ac(text)), - heading2: (text: string) => chalk.bold(ac(text)), - heading3: (text: string) => chalk.bold(ac(text)), + heading1: (text: string) => chalk.bold(pr(text)), + heading2: (text: string) => chalk.bold(pr(text)), + heading3: (text: string) => chalk.bold(pr(text)), listBullet: (text: string) => wr(text), quote: (text: string) => chalk.italic(td(text)), inlineCode: (text: string) => cd(text), @@ -70,8 +70,8 @@ export function createThemedChalk(theme: ThemeTokens): ThemedChalk { italic: (text: string) => chalk.italic(text), dim: (text: string) => chalk.dim(text), // 语义色 - accent: (text: string) => ac(text), - accentAlpha: (text: string) => aa(text), + primary: (text: string) => pr(text), + secondary: (text: string) => se(text), text: (text: string) => tx(text), textDim: (text: string) => td(text), success: (text: string) => sc(text), diff --git a/src/ui/theme/presets.ts b/src/ui/theme/presets.ts index 118b3bc..4ce7476 100644 --- a/src/ui/theme/presets.ts +++ b/src/ui/theme/presets.ts @@ -2,21 +2,20 @@ import type { ThemeTokens } from "./types"; /** 系统默认主题(唯一内置主题) */ export const DEFAULT_THEME: ThemeTokens = { - accent: "#229ac3", - accentAlpha: "#229ac3e6", - active: "#89B4FA", + primary: "#229ac3", + secondary: "#229ac3e6", success: "#52c41a", - error: "#ff4d4f", - warning: "#faad14", - info: "#1677ff", + error: "#f5222d", + warning: "#fa8c16", + info: "#2f54eb", riskLow: "#22c55e", riskMedium: "#f59e0b", riskHigh: "#ef4444", - text: "#6C7086", - textDim: "#6C7086", + text: "#3D4149", + textDim: "#646A71", code: "#787f8a", - border: "#4C566A", - thinking: "#CCCFD3", + border: "#ABADB1", + thinking: "#ff4400", gradients: ["#229ac3", "#7c3aed"], }; diff --git a/src/ui/theme/types.ts b/src/ui/theme/types.ts index 803d4e6..89aee47 100644 --- a/src/ui/theme/types.ts +++ b/src/ui/theme/types.ts @@ -2,11 +2,9 @@ export interface ThemeTokens { // ——— 品牌色 ——— /** 主品牌色:Logo、用户消息、选中项,及 Markdown H1-H6 标题 */ - accent: string; - /** 品牌色(含透明度):边框、渐变 */ - accentAlpha: string; - /** 强调/活跃态颜色:下拉菜单的当前项 */ - active: string; + primary: string; + /** 辅助品牌色:边框、渐变 */ + secondary: string; // ——— 语义颜色 ——— /** 成功:工具执行成功、MCP ready */ diff --git a/src/ui/views/App.tsx b/src/ui/views/App.tsx index ee16601..a56a84d 100644 --- a/src/ui/views/App.tsx +++ b/src/ui/views/App.tsx @@ -257,7 +257,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl const summary = buildExitSummaryText({ session }); const tc = getCurrentThemedChalk(); process.stdout.write("\n"); - process.stdout.write(tc.accent("> /exit ")); + process.stdout.write(tc.primary("> /exit ")); process.stdout.write("\n\n"); process.stdout.write(summary); process.stdout.write("\n\n"); diff --git a/src/ui/views/AppContainer.tsx b/src/ui/views/AppContainer.tsx index 2491b16..159a77b 100644 --- a/src/ui/views/AppContainer.tsx +++ b/src/ui/views/AppContainer.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { AppContext } from "../contexts"; import App from "./App"; import { RawModeProvider } from "../contexts"; @@ -14,8 +14,10 @@ const AppContainer: React.FC<{ const settings = resolveCurrentSettings(projectRoot); const [theme] = useState(settings.theme); - // 初始设置全局 chalk 主题 - setCurrentTheme(theme); + useEffect(() => { + // 初始设置全局 chalk 主题 + setCurrentTheme(theme); + }, [theme]); return ( diff --git a/src/ui/views/AskUserQuestionPrompt.tsx b/src/ui/views/AskUserQuestionPrompt.tsx index 257b6db..1eeb31d 100644 --- a/src/ui/views/AskUserQuestionPrompt.tsx +++ b/src/ui/views/AskUserQuestionPrompt.tsx @@ -187,7 +187,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): const marker = question.multiSelect ? (isSelected ? "[x]" : "[ ]") : isSelected ? "●" : "○"; return ( - + {isCursor ? "> " : " "} {marker} {option.label} @@ -196,14 +196,14 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): marginLeft={4} marginTop={0} borderStyle="single" - borderColor={isCursor ? theme.active : theme.textDim} + borderColor={isCursor ? theme.primary : theme.textDim} paddingX={1} width={64} > {otherText ? ( {otherText} - {isCursor ? : null} + {isCursor ? : null} ) : ( {isCursor ? "type your answer here" : "type a custom answer"} diff --git a/src/ui/views/McpStatusList.tsx b/src/ui/views/McpStatusList.tsx index 453fba4..3834454 100644 --- a/src/ui/views/McpStatusList.tsx +++ b/src/ui/views/McpStatusList.tsx @@ -40,9 +40,9 @@ export function McpStatusList({ statuses, onCancel, onReconnect }: Props): React if (statuses.length === 0) { return ( - + - + Manage MCP servers 0 servers @@ -190,10 +190,10 @@ function ServerListView({ paddingX={1} marginTop={1} > - + {/* Header row */} - + Manage MCP servers @@ -212,7 +212,7 @@ function ServerListView({ borderLeft={false} borderRight={false} borderStyle="round" - borderDimColor + borderColor={theme.border} flexDirection="column" flexGrow={1} paddingX={1} @@ -294,7 +294,7 @@ function ServerRow({ {/* Server row */} - + {selected ? "> " : " "} {icon} {status.name} @@ -436,11 +436,11 @@ function ServerDetailView({ paddingX={1} marginTop={1} > - + {/* Header row */} {statusIcon} - + {server.name} — {server.status === "ready" ? "Details" : "Status"} @@ -466,7 +466,7 @@ function ServerDetailView({ borderLeft={false} borderRight={false} borderStyle="round" - borderDimColor + borderColor={theme.border} flexDirection="column" flexGrow={1} paddingX={1} @@ -521,11 +521,11 @@ function ItemRow({ item, selected }: { item: { type: string; name: string }; sel const isAction = item.type === "action"; const icon = isAction ? "↻" : item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; const theme = useTheme(); - const color = isAction && selected ? theme.warning : selected ? theme.accent : undefined; + const color = isAction && selected ? theme.warning : selected ? theme.primary : undefined; return ( - {selected ? "> " : " "} + {selected ? "> " : " "} {icon} {isAction ? `[${item.name}]` : item.name} @@ -546,7 +546,6 @@ function ErrorRow({ error }: { error: string }): React.ReactElement { marginBottom={0} borderStyle="round" borderColor={theme.error} - borderDimColor > {lines.map((line, index) => ( diff --git a/src/ui/views/PermissionPrompt.tsx b/src/ui/views/PermissionPrompt.tsx index f89ad2f..08ab42f 100644 --- a/src/ui/views/PermissionPrompt.tsx +++ b/src/ui/views/PermissionPrompt.tsx @@ -149,7 +149,7 @@ export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React {options.map((option, optionIndex) => ( - + {optionIndex === cursor ? "> " : " "} {optionIndex + 1}. {renderOptionLabel(option)} diff --git a/src/ui/views/PromptInput.tsx b/src/ui/views/PromptInput.tsx index b6a0cd0..ce3d8d1 100644 --- a/src/ui/views/PromptInput.tsx +++ b/src/ui/views/PromptInput.tsx @@ -110,7 +110,7 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }, [busy]); const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; - return {prefix}; + return {prefix}; }); export const PromptInput = React.memo(function PromptInput({ @@ -748,7 +748,7 @@ export const PromptInput = React.memo(function PromptInput({ borderBottom={true} borderLeft={false} borderRight={false} - borderDimColor + borderColor={theme.border} > diff --git a/src/ui/views/SessionList.tsx b/src/ui/views/SessionList.tsx index 5448553..f836741 100644 --- a/src/ui/views/SessionList.tsx +++ b/src/ui/views/SessionList.tsx @@ -197,14 +197,14 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): paddingX={1} marginTop={1} > - + {/* Header row */} - + Resume a session - + ({sessions.length} total {hasActiveSearch ? `, ${filteredSessions.length} matched` : ""}) @@ -223,7 +223,7 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): borderLeft={false} borderRight={false} borderStyle="round" - borderDimColor + borderColor={theme.border} flexDirection="column" flexGrow={1} paddingX={1} @@ -241,11 +241,11 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): return ( - {isSelected ? "> " : " "} + {isSelected ? "> " : " "} - + {formatSessionTitle(session.summary || "Untitled")} {isConfirming ? ( diff --git a/src/ui/views/SlashCommandMenu.tsx b/src/ui/views/SlashCommandMenu.tsx index 44ca468..73c3251 100644 --- a/src/ui/views/SlashCommandMenu.tsx +++ b/src/ui/views/SlashCommandMenu.tsx @@ -58,14 +58,14 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ return ( - + {actualIndex === activeIndex ? "> " : " "} {formatSlashCommandLabel(item)} {item.args ? {item.args.join(ARGS_SEPARATOR)} : null} - + {formatSlashCommandDescription(item.description)} diff --git a/src/ui/views/ThemedGradient.tsx b/src/ui/views/ThemedGradient.tsx index 31f47fc..c470d53 100644 --- a/src/ui/views/ThemedGradient.tsx +++ b/src/ui/views/ThemedGradient.tsx @@ -23,9 +23,9 @@ export const ThemedGradient: React.FC = ({ children, ...props }) => { ); } - // Fallback to accent color if no gradient + // Fallback to primary color if no gradient return ( - + {children} ); diff --git a/src/ui/views/UndoSelector.tsx b/src/ui/views/UndoSelector.tsx index 8b9c443..a1385fc 100644 --- a/src/ui/views/UndoSelector.tsx +++ b/src/ui/views/UndoSelector.tsx @@ -99,9 +99,9 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac paddingX={1} marginTop={1} > - + - + Undo restore to the point before a prompt @@ -113,7 +113,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac borderLeft={false} borderRight={false} borderStyle="round" - borderDimColor + borderColor={theme.border} flexDirection="column" flexGrow={1} paddingX={1} @@ -124,9 +124,9 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac const isActive = actualIndex === safeTargetIndex; return ( - {isActive ? "> " : " "} + {isActive ? "> " : " "} - + {formatUndoMessage(target.message.content)} @@ -145,7 +145,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac borderLeft={false} borderRight={false} borderStyle="round" - borderDimColor + borderColor={theme.border} flexDirection="column" flexGrow={1} paddingX={1} @@ -154,7 +154,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac Selected prompt: {formatUndoMessage(selectedTarget?.message.content ?? "")} - + {modeIndex === 0 ? "> " : " "}Restore code and conversation @@ -163,7 +163,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac ? "Restore files from the recorded Git checkpoint, then fork the conversation." : "No code checkpoint is recorded for this prompt."} - + {modeIndex === 1 ? "> " : " "}Restore conversation {" "}Fork the conversation without changing files. diff --git a/src/ui/views/UpdatePrompt.tsx b/src/ui/views/UpdatePrompt.tsx index 123fd94..4159569 100644 --- a/src/ui/views/UpdatePrompt.tsx +++ b/src/ui/views/UpdatePrompt.tsx @@ -69,7 +69,7 @@ export function UpdatePrompt({ currentVersion, latestVersion, installCommand, on {options.map((option, index) => { const selected = index === selectedIndex; return ( - + {selected ? "> " : " "} {index + 1}. {option.label} diff --git a/src/ui/views/WelcomeScreen.tsx b/src/ui/views/WelcomeScreen.tsx index 2d55282..f9e437f 100644 --- a/src/ui/views/WelcomeScreen.tsx +++ b/src/ui/views/WelcomeScreen.tsx @@ -51,7 +51,7 @@ export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeS - {">"}_ Deep Code + {">"}_ Deep Code (v{version || "unknown"}) {!compact ? : null} From 5c497b8d3b2fb06a2070c974891de938a83082c8 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 28 May 2026 17:13:03 +0800 Subject: [PATCH 03/19] =?UTF-8?q?refactor(theme):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=9D=83=E9=99=90=E9=A3=8E=E9=99=A9=E8=89=B2=E4=B8=BA=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 PermissionPrompt 组件中将风险色映射由 riskLow/riskMedium/riskHigh 改为 success/warning/error - 修改 theme presets,移除旧的风险等级颜色配置 - 更新测试中风险颜色的断言,改为使用新的语义色断言 - 统一 README 文档中主题色 token,替换 accent/risk 相关为 primary/secondary 和语义色 - 修改 DropdownMenu 组件中选中项标签加粗表现 - 调整 MessageView 组件中 Thinking 状态颜色为 theme.text - 精简 theme 类型定义,去除旧风险色属性,改用语义色属性 - 清理测试文件中与旧风险色相关的代码和注释内容 --- README-en.md | 18 +++++--------- README-zh_CN.md | 18 +++++--------- README.md | 18 +++++--------- src/tests/permission-prompt.test.ts | 22 ++++++++--------- src/tests/theme.test.ts | 31 ++++++------------------ src/ui/components/DropdownMenu/index.tsx | 2 +- src/ui/components/MessageView/index.tsx | 4 +-- src/ui/theme/presets.ts | 4 --- src/ui/theme/types.ts | 16 +++--------- src/ui/views/PermissionPrompt.tsx | 14 +++++------ 10 files changed, 50 insertions(+), 97 deletions(-) diff --git a/README-en.md b/README-en.md index a09ee91..b0eb8a2 100644 --- a/README-en.md +++ b/README-en.md @@ -158,7 +158,7 @@ Override only the colors you want to change; the rest keep their defaults: "theme": { "preset": "custom", "overrides": { - "accent": "#ff6600", + "primary": "#ff6600", "success": "greenBright" } } @@ -174,21 +174,16 @@ Provide a complete tokens object, merged on top of the default theme: "theme": { "preset": "custom", "tokens": { - "accent": "#229ac3", - "accentAlpha": "#229ac3e6", - "active": "cyanBright", + "primary": "#229ac3", + "secondary": "#229ac3e6", "success": "green", "error": "red", "warning": "yellow", "info": "magenta", - "riskLow": "#22c55e", - "riskMedium": "#f59e0b", - "riskHigh": "#ef4444", "text": "white", "textDim": "gray", "code": "cyan", "border": "gray", - "thinking": "gray", "gradients": ["#229ac3e6", "#229ac3e6"] } } @@ -201,10 +196,9 @@ Available token descriptions: | Token | Used For | |-------|----------| -| `accent`, `accentAlpha`, `active` | Logo, user messages, selected items, etc. | -| `success`, `error`, `warning`, `info` | Tool statuses, permission prompts, skill loading; `warning` also colors list bullets | -| `riskLow`, `riskMedium`, `riskHigh` | Permission confirmation panel | -| `text`, `textDim`, `code`, `border`, `thinking` | Body text, secondary text/blockquotes, code, borders, thinking status | +| `primary`, `secondary` | Logo, user messages, selected items, etc. | +| `success`, `error`, `warning`, `info` | Tool statuses, permission prompts (risk levels), skill loading; `warning` also colors list bullets | +| `text`, `textDim`, `code`, `border` | Body text, secondary text/blockquotes, code, borders | | `gradients` | Logo and exit panel gradient colors | Color values support hex (`"#ff6600"`), hex with alpha (`"#229ac3e6"`), and chalk named colors (`"cyanBright"`, `"green"`). diff --git a/README-zh_CN.md b/README-zh_CN.md index 418f645..040b989 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -158,7 +158,7 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 "theme": { "preset": "custom", "overrides": { - "accent": "#ff6600", + "primary": "#ff6600", "success": "greenBright" } } @@ -174,21 +174,16 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 "theme": { "preset": "custom", "tokens": { - "accent": "#229ac3", - "accentAlpha": "#229ac3e6", - "active": "cyanBright", + "primary": "#229ac3", + "secondary": "#229ac3e6", "success": "green", "error": "red", "warning": "yellow", "info": "magenta", - "riskLow": "#22c55e", - "riskMedium": "#f59e0b", - "riskHigh": "#ef4444", "text": "white", "textDim": "gray", "code": "cyan", "border": "gray", - "thinking": "gray", "gradients": ["#229ac3e6", "#229ac3e6"] } } @@ -201,10 +196,9 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 | Token | 用途 | |---------------------------------------------|----------------------------------| -| `accent`、`accentAlpha`、`active` | Logo、用户消息、选中项等 | -| `success`、`error`、`warning`、`info` | 工具状态、权限提示、技能加载,`warning` 也是列表标记色 | -| `riskLow`、`riskMedium`、`riskHigh` | 权限确认面板 | -| `text`、`textDim`、`code`、`border`、`thinking` | 正文、副文/引用块、代码、边框、思考状态 | +| `primary`、`secondary` | Logo、用户消息、选中项等 | +| `success`、`error`、`warning`、`info` | 工具状态、权限提示(风险等级)、技能加载,`warning` 也是列表标记色 | +| `text`、`textDim`、`code`、`border` | 正文、副文/引用块、代码、边框 | | `gradients` | Logo 与退出面板的渐变色数组 | 颜色值支持 hex(`"#ff6600"`)、hex 含透明度(`"#229ac3e6"`)、chalk 命名色(`"cyanBright"`、`"green"`)。 diff --git a/README.md b/README.md index 418f645..040b989 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 "theme": { "preset": "custom", "overrides": { - "accent": "#ff6600", + "primary": "#ff6600", "success": "greenBright" } } @@ -174,21 +174,16 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 "theme": { "preset": "custom", "tokens": { - "accent": "#229ac3", - "accentAlpha": "#229ac3e6", - "active": "cyanBright", + "primary": "#229ac3", + "secondary": "#229ac3e6", "success": "green", "error": "red", "warning": "yellow", "info": "magenta", - "riskLow": "#22c55e", - "riskMedium": "#f59e0b", - "riskHigh": "#ef4444", "text": "white", "textDim": "gray", "code": "cyan", "border": "gray", - "thinking": "gray", "gradients": ["#229ac3e6", "#229ac3e6"] } } @@ -201,10 +196,9 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 | Token | 用途 | |---------------------------------------------|----------------------------------| -| `accent`、`accentAlpha`、`active` | Logo、用户消息、选中项等 | -| `success`、`error`、`warning`、`info` | 工具状态、权限提示、技能加载,`warning` 也是列表标记色 | -| `riskLow`、`riskMedium`、`riskHigh` | 权限确认面板 | -| `text`、`textDim`、`code`、`border`、`thinking` | 正文、副文/引用块、代码、边框、思考状态 | +| `primary`、`secondary` | Logo、用户消息、选中项等 | +| `success`、`error`、`warning`、`info` | 工具状态、权限提示(风险等级)、技能加载,`warning` 也是列表标记色 | +| `text`、`textDim`、`code`、`border` | 正文、副文/引用块、代码、边框 | | `gradients` | Logo 与退出面板的渐变色数组 | 颜色值支持 hex(`"#ff6600"`)、hex 含透明度(`"#229ac3e6"`)、chalk 命名色(`"cyanBright"`、`"green"`)。 diff --git a/src/tests/permission-prompt.test.ts b/src/tests/permission-prompt.test.ts index 4f1d87e..c49519c 100644 --- a/src/tests/permission-prompt.test.ts +++ b/src/tests/permission-prompt.test.ts @@ -3,17 +3,17 @@ import assert from "node:assert/strict"; import { getScopeRiskColor } from "../ui/views/PermissionPrompt"; test("getScopeRiskColor maps permission scopes by risk", () => { - assert.equal(getScopeRiskColor("read-in-cwd"), "#22c55e"); - assert.equal(getScopeRiskColor("query-git-log"), "#22c55e"); + assert.equal(getScopeRiskColor("read-in-cwd"), "#52c41a"); + assert.equal(getScopeRiskColor("query-git-log"), "#52c41a"); - assert.equal(getScopeRiskColor("read-out-cwd"), "#f59e0b"); - assert.equal(getScopeRiskColor("write-in-cwd"), "#f59e0b"); - assert.equal(getScopeRiskColor("network"), "#f59e0b"); - assert.equal(getScopeRiskColor("mcp"), "#f59e0b"); + assert.equal(getScopeRiskColor("read-out-cwd"), "#faad14"); + assert.equal(getScopeRiskColor("write-in-cwd"), "#faad14"); + assert.equal(getScopeRiskColor("network"), "#faad14"); + assert.equal(getScopeRiskColor("mcp"), "#faad14"); - assert.equal(getScopeRiskColor("write-out-cwd"), "#ef4444"); - assert.equal(getScopeRiskColor("delete-in-cwd"), "#ef4444"); - assert.equal(getScopeRiskColor("delete-out-cwd"), "#ef4444"); - assert.equal(getScopeRiskColor("mutate-git-log"), "#ef4444"); - assert.equal(getScopeRiskColor("unknown"), "#ef4444"); + assert.equal(getScopeRiskColor("write-out-cwd"), "#ff4d4f"); + assert.equal(getScopeRiskColor("delete-in-cwd"), "#ff4d4f"); + assert.equal(getScopeRiskColor("delete-out-cwd"), "#ff4d4f"); + assert.equal(getScopeRiskColor("mutate-git-log"), "#ff4d4f"); + assert.equal(getScopeRiskColor("unknown"), "#ff4d4f"); }); diff --git a/src/tests/theme.test.ts b/src/tests/theme.test.ts index 54e69ca..2effec8 100644 --- a/src/tests/theme.test.ts +++ b/src/tests/theme.test.ts @@ -26,14 +26,10 @@ const REQUIRED_TOKEN_KEYS: Array = [ "error", "warning", "info", - "riskLow", - "riskMedium", - "riskHigh", "text", "textDim", "code", "border", - "thinking", "gradients", ]; @@ -62,7 +58,6 @@ test("DEFAULT_THEME semantic colors match expected values", () => { assert.equal(DEFAULT_THEME.error, "#f5222d"); assert.equal(DEFAULT_THEME.warning, "#fa8c16"); assert.equal(DEFAULT_THEME.info, "#2f54eb"); - assert.equal(DEFAULT_THEME.thinking, "#ff4400"); }); test("DEFAULT_THEME base colors match expected values", () => { @@ -71,12 +66,6 @@ test("DEFAULT_THEME base colors match expected values", () => { assert.equal(DEFAULT_THEME.code, "#787f8a"); }); -test("DEFAULT_THEME risk colors match expected values", () => { - assert.equal(DEFAULT_THEME.riskLow, "#22c55e"); - assert.equal(DEFAULT_THEME.riskMedium, "#f59e0b"); - assert.equal(DEFAULT_THEME.riskHigh, "#ef4444"); -}); - test("PRESETS map contains default", () => { assert.ok("default" in PRESETS); assert.equal(Object.keys(PRESETS).length, 1); @@ -136,14 +125,10 @@ test("resolveTheme full custom tokens with custom preset", () => { error: "blue", warning: "blue", info: "blue", - riskLow: "#111111", - riskMedium: "#222222", - riskHigh: "#333333", text: "blue", textDim: "blue", code: "blue", border: "blue", - thinking: "blue", gradients: ["#aaaaaa", "#bbbbbb"], }; const result = resolveTheme({ preset: "custom", tokens: customTokens }); @@ -312,17 +297,17 @@ test("resolveSettingsSources uses default theme when preset is not custom", () = // getScopeRiskColor with theme parameter // --------------------------------------------------------------------------- -test("getScopeRiskColor returns dark theme defaults when no theme is passed", () => { - assert.equal(getScopeRiskColor("read-in-cwd"), "#22c55e"); - assert.equal(getScopeRiskColor("write-in-cwd"), "#f59e0b"); - assert.equal(getScopeRiskColor("write-out-cwd"), "#ef4444"); +test("getScopeRiskColor returns default theme colors when no theme is passed", () => { + assert.equal(getScopeRiskColor("read-in-cwd"), "#52c41a"); + assert.equal(getScopeRiskColor("write-in-cwd"), "#faad14"); + assert.equal(getScopeRiskColor("write-out-cwd"), "#ff4d4f"); }); -test("getScopeRiskColor uses theme risk colors when theme is provided", () => { +test("getScopeRiskColor uses theme semantic colors when theme is provided", () => { const custom: Partial = { - riskLow: "#aaaaaa", - riskMedium: "#bbbbbb", - riskHigh: "#cccccc", + success: "#aaaaaa", + warning: "#bbbbbb", + error: "#cccccc", }; assert.equal(getScopeRiskColor("read-in-cwd", custom as ThemeTokens), "#aaaaaa"); assert.equal(getScopeRiskColor("mcp", custom as ThemeTokens), "#bbbbbb"); diff --git a/src/ui/components/DropdownMenu/index.tsx b/src/ui/components/DropdownMenu/index.tsx index f6439ff..e0acb8b 100644 --- a/src/ui/components/DropdownMenu/index.tsx +++ b/src/ui/components/DropdownMenu/index.tsx @@ -161,7 +161,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ {isActive ? "> " : " "} - {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} + {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} {item.statusIndicator ? ( {item.statusIndicator.symbol} ) : null} diff --git a/src/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx index ef5b13f..8b5cf3f 100644 --- a/src/ui/components/MessageView/index.tsx +++ b/src/ui/components/MessageView/index.tsx @@ -46,13 +46,13 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps if (collapsed !== false) { return ( - + ); } return ( - + {content ? {renderMarkdown(content)} : null} diff --git a/src/ui/theme/presets.ts b/src/ui/theme/presets.ts index 4ce7476..c8ecba8 100644 --- a/src/ui/theme/presets.ts +++ b/src/ui/theme/presets.ts @@ -8,14 +8,10 @@ export const DEFAULT_THEME: ThemeTokens = { error: "#f5222d", warning: "#fa8c16", info: "#2f54eb", - riskLow: "#22c55e", - riskMedium: "#f59e0b", - riskHigh: "#ef4444", text: "#3D4149", textDim: "#646A71", code: "#787f8a", border: "#ABADB1", - thinking: "#ff4400", gradients: ["#229ac3", "#7c3aed"], }; diff --git a/src/ui/theme/types.ts b/src/ui/theme/types.ts index 89aee47..22a7d65 100644 --- a/src/ui/theme/types.ts +++ b/src/ui/theme/types.ts @@ -7,23 +7,15 @@ export interface ThemeTokens { secondary: string; // ——— 语义颜色 ——— - /** 成功:工具执行成功、MCP ready */ + /** 成功:工具执行成功、MCP ready,低风险操作 */ success: string; - /** 失败/错误:工具执行失败、错误信息 */ + /** 失败/错误:工具执行失败、错误信息,高风险操作 */ error: string; - /** 警告/进行中:忙时 spinner、权限提示,及 Markdown 列表标记 */ + /** 警告/进行中:忙时 spinner、权限提示、中风险操作,及 Markdown 列表标记 */ warning: string; /** 特殊指示:技能、图片附件 */ info: string; - // ——— 风险等级色 ——— - /** 低风险操作 */ - riskLow: string; - /** 中风险操作 */ - riskMedium: string; - /** 高风险操作 */ - riskHigh: string; - // ——— 基础色 ——— /** 主文字颜色 */ text: string; @@ -33,8 +25,6 @@ export interface ThemeTokens { code: string; /** 边框 */ border: string; - /** 思考状态 bullet 颜色 */ - thinking: string; // ——— 渐变 ——— /** Logo 渐变色数组 */ diff --git a/src/ui/views/PermissionPrompt.tsx b/src/ui/views/PermissionPrompt.tsx index 08ab42f..a4dfa09 100644 --- a/src/ui/views/PermissionPrompt.tsx +++ b/src/ui/views/PermissionPrompt.tsx @@ -3,8 +3,8 @@ import { Box, Text } from "ink"; import { useTerminalInput } from "../hooks"; import type { AskPermissionRequest, AskPermissionScope, UserToolPermission } from "../../common/permissions"; import type { PermissionScope } from "../../settings"; -import { useTheme } from "../theme/ThemeContext"; -import type { ThemeTokens } from "../theme/types"; +import { useTheme } from "../theme"; +import type { ThemeTokens } from "../theme"; export type PermissionPromptResult = { permissions: UserToolPermission[]; @@ -232,24 +232,24 @@ function isAlwaysAllowedScope(scope: AskPermissionScope): scope is PermissionSco } export function getScopeRiskColor(scope: AskPermissionScope, theme?: ThemeTokens): string { - const t = theme ?? { riskLow: "#22c55e", riskMedium: "#f59e0b", riskHigh: "#ef4444" }; + const t = theme ?? ({ success: "#52c41a", warning: "#faad14", error: "#ff4d4f" } as ThemeTokens); switch (scope) { case "read-in-cwd": case "query-git-log": - return t.riskLow; + return t.success; case "read-out-cwd": case "write-in-cwd": case "network": case "mcp": - return t.riskMedium; + return t.warning; case "write-out-cwd": case "delete-in-cwd": case "delete-out-cwd": case "mutate-git-log": case "unknown": - return t.riskHigh; + return t.error; default: - return t.riskHigh; + return t.error; } } From 5196b02e1390eccc6fe958d8bee837414ad23061 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 28 May 2026 17:19:23 +0800 Subject: [PATCH 04/19] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E6=94=B9MessageView?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E4=B8=AD=E7=9A=84=E6=96=87=E6=9C=AC=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除name文本的颜色属性,改为默认颜色显示 - 保持文本加粗样式不变 - 调整相关代码间距,提升阅读性 --- src/ui/components/MessageView/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx index 8b5cf3f..2f0b13e 100644 --- a/src/ui/components/MessageView/index.tsx +++ b/src/ui/components/MessageView/index.tsx @@ -170,7 +170,7 @@ function StatusLine({ - + {name} {params ? ( From a80d89b150c74822502983aeb3460cbeb03d0fe7 Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 29 May 2026 10:35:55 +0800 Subject: [PATCH 05/19] =?UTF-8?q?style(ui):=20=E4=BC=98=E5=8C=96=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E6=A0=B7=E5=BC=8F=E5=92=8C=E4=B8=BB=E9=A2=98=E9=85=8D?= =?UTF-8?q?=E8=89=B2=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 App 组件中无用的 theme 依赖以减少重渲染 - 为状态提示文字添加左边距增强视觉效果 - 调整 DropdownMenu 组件中选中项标签字体加粗改为普通 - 修改 MessageView 中状态行的颜色由 theme.text 改为 theme.primary - 更换 MessageView 中文本符号以提升美观度 - 更新主题色板中的 success、error、info 颜色值以及边框和渐变色 - PromptInput 组件中多处添加 marginLeft 进行缩进优化 - 根据焦点状态动态调整输入框底部边框颜色 - 在 SessionList 中根据搜索状态调整提示文字颜色为主色或次色 --- src/ui/components/DropdownMenu/index.tsx | 2 +- src/ui/components/MessageView/index.tsx | 6 +++--- src/ui/theme/presets.ts | 10 +++++----- src/ui/views/App.tsx | 8 +++----- src/ui/views/PromptInput.tsx | 14 +++++++------- src/ui/views/SessionList.tsx | 4 +++- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/ui/components/DropdownMenu/index.tsx b/src/ui/components/DropdownMenu/index.tsx index e0acb8b..f6439ff 100644 --- a/src/ui/components/DropdownMenu/index.tsx +++ b/src/ui/components/DropdownMenu/index.tsx @@ -161,7 +161,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ {isActive ? "> " : " "} - {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} + {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} {item.statusIndicator ? ( {item.statusIndicator.symbol} ) : null} diff --git a/src/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx index 2f0b13e..40970f1 100644 --- a/src/ui/components/MessageView/index.tsx +++ b/src/ui/components/MessageView/index.tsx @@ -46,13 +46,13 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps if (collapsed !== false) { return ( - + ); } return ( - + {content ? {renderMarkdown(content)} : null} @@ -165,7 +165,7 @@ function StatusLine({ - ✧ + ✦ diff --git a/src/ui/theme/presets.ts b/src/ui/theme/presets.ts index c8ecba8..6cd67d4 100644 --- a/src/ui/theme/presets.ts +++ b/src/ui/theme/presets.ts @@ -4,15 +4,15 @@ import type { ThemeTokens } from "./types"; export const DEFAULT_THEME: ThemeTokens = { primary: "#229ac3", secondary: "#229ac3e6", - success: "#52c41a", - error: "#f5222d", + success: "#1a7f37", + error: "#d1242f", warning: "#fa8c16", - info: "#2f54eb", + info: "#0969da", text: "#3D4149", textDim: "#646A71", code: "#787f8a", - border: "#ABADB1", - gradients: ["#229ac3", "#7c3aed"], + border: "#999", + gradients: ["#229ac3", "#8250df"], }; /** 预设主题映射表 */ diff --git a/src/ui/views/App.tsx b/src/ui/views/App.tsx index a56a84d..916c5cb 100644 --- a/src/ui/views/App.tsx +++ b/src/ui/views/App.tsx @@ -636,9 +636,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl return [welcomeItem, ...messages]; } return messages; - // theme 作为依赖确保主题切换时 Static 子组件重渲染 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, showWelcome, view, messages, welcomeItem, theme]); + }, [mode, showWelcome, view, messages, welcomeItem]); const handleQuestionAnswers = useCallback( (answers: AskUserQuestionAnswers) => { @@ -723,12 +721,12 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl }} {statusLine ? ( - + {statusLine} ) : null} {errorLine ? ( - + Error: {errorLine} ) : null} diff --git a/src/ui/views/PromptInput.tsx b/src/ui/views/PromptInput.tsx index ce3d8d1..3259c92 100644 --- a/src/ui/views/PromptInput.tsx +++ b/src/ui/views/PromptInput.tsx @@ -722,19 +722,21 @@ export const PromptInput = React.memo(function PromptInput({ [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu] ); + const isFocused = useMemo(() => !disabled && hasTerminalFocus, [disabled, hasTerminalFocus]); + const matchedCommand = slashToken ? findExactSlashCommand(slashItems, slashToken) : null; const inlineHint = matchedCommand?.args ? ` ${matchedCommand.args.join(ARGS_SEPARATOR)}` : ""; return ( {imageUrls.length > 0 ? ( - + {formatImageAttachmentStatus(imageUrls.length)} {` (${IMAGE_ATTACHMENT_CLEAR_HINT})`} ) : null} {selectedSkills.length > 0 ? ( - + {formatSelectedSkillsStatus(selectedSkills)} @@ -748,12 +750,10 @@ export const PromptInput = React.memo(function PromptInput({ borderBottom={true} borderLeft={false} borderRight={false} - borderColor={theme.border} + borderColor={isFocused ? theme.primary : theme.border} > - - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current, theme.warning)} - + {renderBufferWithCursor(buffer, isFocused, placeholder, pastesRef.current, theme.warning)} {inlineHint ? {inlineHint} : null} {!showFooterText && ( - + {footerText} )} diff --git a/src/ui/views/SessionList.tsx b/src/ui/views/SessionList.tsx index f836741..6c83a56 100644 --- a/src/ui/views/SessionList.tsx +++ b/src/ui/views/SessionList.tsx @@ -211,7 +211,9 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): {/* Search bar */} - {searchQuery ? `Search: ${searchQuery}` : "Type to search\u2026"} + + {searchQuery ? `Search: ${searchQuery}` : "Type to search\u2026"} + {searchQuery ? | : null} From f4f69abb8fe1d67ee5b1797e09e5b0839e54cdbe Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 29 May 2026 10:44:48 +0800 Subject: [PATCH 06/19] =?UTF-8?q?test(theme):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E4=B8=BB=E9=A2=98=E7=9A=84=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E9=A2=9C=E8=89=B2=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 success 颜色从 #52c41a 修改为 #1a7f37 - 将 error 颜色从 #f5222d 修改为 #d1242f - 将 info 颜色从 #2f54eb 修改为 #0969da - 保持 warning 颜色不变,依然为 #fa8c16 --- src/tests/theme.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tests/theme.test.ts b/src/tests/theme.test.ts index 2effec8..5b80998 100644 --- a/src/tests/theme.test.ts +++ b/src/tests/theme.test.ts @@ -54,10 +54,10 @@ test("DEFAULT_THEME primary matches expected brand color", () => { }); test("DEFAULT_THEME semantic colors match expected values", () => { - assert.equal(DEFAULT_THEME.success, "#52c41a"); - assert.equal(DEFAULT_THEME.error, "#f5222d"); + assert.equal(DEFAULT_THEME.success, "#1a7f37"); + assert.equal(DEFAULT_THEME.error, "#d1242f"); assert.equal(DEFAULT_THEME.warning, "#fa8c16"); - assert.equal(DEFAULT_THEME.info, "#2f54eb"); + assert.equal(DEFAULT_THEME.info, "#0969da"); }); test("DEFAULT_THEME base colors match expected values", () => { From 0e052f5169912271c5ec7d8c08996fcbb5cd9bcd Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 29 May 2026 11:16:24 +0800 Subject: [PATCH 07/19] =?UTF-8?q?docs(readme):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E4=B8=BB=E9=A2=98=E8=89=B2=E5=80=BC=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E5=8F=8A=E4=BC=98=E5=85=88=E7=BA=A7=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将原来简略的 token 说明替换为详细的默认主题色值表格 - 新增每个 token 的默认颜色值,及其具体用途说明 - 说明颜色值支持 hex、带透明度的 hex 及 chalk 命名色 - 增加 tokens 优先于 overrides 的优先级说明 - 说明主题配置文件可放置的位置 - 文档中中英双语 README 都同步了相同修改内容 --- README-en.md | 25 +++++++++++++++++-------- README-zh_CN.md | 25 +++++++++++++++++-------- README.md | 25 +++++++++++++++++-------- 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/README-en.md b/README-en.md index b0eb8a2..2367fc3 100644 --- a/README-en.md +++ b/README-en.md @@ -192,17 +192,26 @@ Provide a complete tokens object, merged on top of the default theme: > Note: `overrides` and `tokens` only take effect when `preset` is set to `"custom"`. When `preset` is `"default"` or unset, the built-in default theme is always used. -Available token descriptions: - -| Token | Used For | -|-------|----------| -| `primary`, `secondary` | Logo, user messages, selected items, etc. | -| `success`, `error`, `warning`, `info` | Tool statuses, permission prompts (risk levels), skill loading; `warning` also colors list bullets | -| `text`, `textDim`, `code`, `border` | Body text, secondary text/blockquotes, code, borders | -| `gradients` | Logo and exit panel gradient colors | +Default theme color values (`DEFAULT_THEME`): + +| Token | Default | Used For | +|-------|---------|----------| +| `primary` | `#229ac3` | Primary brand: user messages, selected items, status line bullets, Markdown headings | +| `secondary` | `#229ac3e6` | Secondary brand: welcome screen logo text/border, exit panel border | +| `success` | `#1a7f37` | Success: tool execution success, MCP ready, diff additions, low-risk permissions | +| `error` | `#d1242f` | Error: tool execution failure, error lines, diff deletions, high-risk permissions | +| `warning` | `#fa8c16` | Warning/in-progress: busy spinner, permission prompt border, list bullets, MCP starting | +| `info` | `#0969da` | Info: skill loading tips, image attachment status | +| `text` | `#3D4149` | Body text: permission prompt text, question text, ProcessStdout title | +| `textDim` | `#646A71` | Secondary text: status line params, search placeholder, diff context, Markdown blockquotes | +| `code` | `#787f8a` | Code blocks and inline code | +| `border` | `#999` | All component borders | +| `gradients` | `["#229ac3", "#8250df"]` | Logo and exit panel gradient colors | Color values support hex (`"#ff6600"`), hex with alpha (`"#229ac3e6"`), and chalk named colors (`"cyanBright"`, `"green"`). +> Note: `tokens` takes priority over `overrides` — if both are specified, only `tokens` is used. Theme settings can be placed in the global `~/.deepcode/settings.json` or the project-root `.deepcode/settings.json`. + ## Contributing Contributions are welcome! Here's how to get started: diff --git a/README-zh_CN.md b/README-zh_CN.md index 040b989..1203113 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -192,17 +192,26 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 > 注意:`preset` 必须设为 `"custom"` 时 `overrides` 和 `tokens` 才会生效。`preset` 为 `"default"` 或不配置时始终使用系统默认主题。 -可覆盖的 token 说明: - -| Token | 用途 | -|---------------------------------------------|----------------------------------| -| `primary`、`secondary` | Logo、用户消息、选中项等 | -| `success`、`error`、`warning`、`info` | 工具状态、权限提示(风险等级)、技能加载,`warning` 也是列表标记色 | -| `text`、`textDim`、`code`、`border` | 正文、副文/引用块、代码、边框 | -| `gradients` | Logo 与退出面板的渐变色数组 | +默认主题色值(`DEFAULT_THEME`): + +| Token | 默认值 | 用途 | +|-------|--------|------| +| `primary` | `#229ac3` | 主品牌色:用户消息、选中项、状态行 bullet、Markdown 标题 | +| `secondary` | `#229ac3e6` | 辅助品牌色:欢迎屏 Logo 文字与边框、退出面板边框 | +| `success` | `#1a7f37` | 成功:工具执行成功、MCP ready、diff 新增行、低风险权限色 | +| `error` | `#d1242f` | 失败/错误:工具执行失败、Error 行、diff 删除行、高风险权限色 | +| `warning` | `#fa8c16` | 警告/进行中:忙时 spinner、权限提示边框、列表标记色、MCP 启动中 | +| `info` | `#0969da` | 特殊指示:技能加载提示、图片附件状态 | +| `text` | `#3D4149` | 主文字色:权限提示正文、问题文字、ProcessStdout 标题 | +| `textDim` | `#646A71` | 次要文字:状态行参数、搜索占位符、diff 上下文行、Markdown 引用块 | +| `code` | `#787f8a` | 代码块/内联代码 | +| `border` | `#999` | 所有组件的边框色 | +| `gradients` | `["#229ac3", "#8250df"]` | Logo 与退出面板的渐变色数组 | 颜色值支持 hex(`"#ff6600"`)、hex 含透明度(`"#229ac3e6"`)、chalk 命名色(`"cyanBright"`、`"green"`)。 +> 注意:`tokens` 优先级高于 `overrides`——如果同时指定两者,仅 `tokens` 生效。主题配置可放在全局 `~/.deepcode/settings.json` 或项目根 `.deepcode/settings.json` 中。 + ## 贡献 欢迎贡献代码!以下是参与方式: diff --git a/README.md b/README.md index 040b989..1203113 100644 --- a/README.md +++ b/README.md @@ -192,17 +192,26 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 > 注意:`preset` 必须设为 `"custom"` 时 `overrides` 和 `tokens` 才会生效。`preset` 为 `"default"` 或不配置时始终使用系统默认主题。 -可覆盖的 token 说明: - -| Token | 用途 | -|---------------------------------------------|----------------------------------| -| `primary`、`secondary` | Logo、用户消息、选中项等 | -| `success`、`error`、`warning`、`info` | 工具状态、权限提示(风险等级)、技能加载,`warning` 也是列表标记色 | -| `text`、`textDim`、`code`、`border` | 正文、副文/引用块、代码、边框 | -| `gradients` | Logo 与退出面板的渐变色数组 | +默认主题色值(`DEFAULT_THEME`): + +| Token | 默认值 | 用途 | +|-------|--------|------| +| `primary` | `#229ac3` | 主品牌色:用户消息、选中项、状态行 bullet、Markdown 标题 | +| `secondary` | `#229ac3e6` | 辅助品牌色:欢迎屏 Logo 文字与边框、退出面板边框 | +| `success` | `#1a7f37` | 成功:工具执行成功、MCP ready、diff 新增行、低风险权限色 | +| `error` | `#d1242f` | 失败/错误:工具执行失败、Error 行、diff 删除行、高风险权限色 | +| `warning` | `#fa8c16` | 警告/进行中:忙时 spinner、权限提示边框、列表标记色、MCP 启动中 | +| `info` | `#0969da` | 特殊指示:技能加载提示、图片附件状态 | +| `text` | `#3D4149` | 主文字色:权限提示正文、问题文字、ProcessStdout 标题 | +| `textDim` | `#646A71` | 次要文字:状态行参数、搜索占位符、diff 上下文行、Markdown 引用块 | +| `code` | `#787f8a` | 代码块/内联代码 | +| `border` | `#999` | 所有组件的边框色 | +| `gradients` | `["#229ac3", "#8250df"]` | Logo 与退出面板的渐变色数组 | 颜色值支持 hex(`"#ff6600"`)、hex 含透明度(`"#229ac3e6"`)、chalk 命名色(`"cyanBright"`、`"green"`)。 +> 注意:`tokens` 优先级高于 `overrides`——如果同时指定两者,仅 `tokens` 生效。主题配置可放在全局 `~/.deepcode/settings.json` 或项目根 `.deepcode/settings.json` 中。 + ## 贡献 欢迎贡献代码!以下是参与方式: From 3358ca88f84795828dfab75b263b14071e3cb16c Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 29 May 2026 15:54:17 +0800 Subject: [PATCH 08/19] =?UTF-8?q?feat(theme):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BA=AE=E8=89=B2=E6=96=87=E5=AD=97(textBright)=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在主题预设中新增 textBright 颜色属性 - 在类型定义中添加 textBright 属性及对应注释 - 用于强调提示的文字显示优化 --- src/ui/theme/presets.ts | 1 + src/ui/theme/types.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/ui/theme/presets.ts b/src/ui/theme/presets.ts index 6cd67d4..39a8204 100644 --- a/src/ui/theme/presets.ts +++ b/src/ui/theme/presets.ts @@ -10,6 +10,7 @@ export const DEFAULT_THEME: ThemeTokens = { info: "#0969da", text: "#3D4149", textDim: "#646A71", + textBright: "#646A71", code: "#787f8a", border: "#999", gradients: ["#229ac3", "#8250df"], diff --git a/src/ui/theme/types.ts b/src/ui/theme/types.ts index 22a7d65..5a099e2 100644 --- a/src/ui/theme/types.ts +++ b/src/ui/theme/types.ts @@ -21,6 +21,8 @@ export interface ThemeTokens { text: string; /** 次要文字:暗化提示,及 Markdown 引用块 */ textDim: string; + /** 亮色文字:强调提示 */ + textBright: string; /** 代码块/内联代码 */ code: string; /** 边框 */ From ff7ccec8c6d8170110c192103fac4f6459a7aad8 Mon Sep 17 00:00:00 2001 From: hcyang Date: Sat, 30 May 2026 13:18:20 +0800 Subject: [PATCH 09/19] =?UTF-8?q?feat(theme):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=A4=9A=E4=B8=BB=E9=A2=98=E6=94=AF=E6=8C=81=E4=B8=8E=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=88=87=E6=8D=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加多种预设主题(暗色、Monokai、Dracula、GitHub Light/Dark、GitLab Light/Dark) - 支持自定义主题颜色并保存至 settings.json,实现个性化调色 - 在 AppContext 中提供主题版本、当前预设和主题切换相关接口 - 实现主题预览、切换和回退功能,动态更新终端界面主题 - 修改应用入口 AppContainer,集成主题状态管理与持久化逻辑 - 在 PromptInput 添加主题切换相关回调和状态支持,集成主题选择组件 - 替换原有 Static 为支持主题的 ThemeableStatic,提升界面一致性 - 优化退出逻辑,新增结构化退出总结视图呈现 - 增加颜色令牌 textBright,丰富主题色彩层次 - 更新文档说明,详细介绍主题配置、预设主题及运行时主题切换操作 - 优化权限提示颜色映射,统一使用主题色配置 - DropdownMenu 支持禁用项及自定义标签颜色,增强菜单表达能力 --- docs/configuration.md | 79 +++++++++ docs/configuration_en.md | 79 +++++++++ src/common/update-check.ts | 4 +- src/tests/permission-prompt.test.ts | 23 +-- src/tests/slash-commands.test.ts | 8 + src/tests/theme.test.ts | 167 +++++++++++-------- src/ui/components/DropdownMenu/index.tsx | 22 ++- src/ui/components/ThemeDropdown/index.tsx | 175 ++++++++++++++++++++ src/ui/components/ThemeableStatic/index.tsx | 35 ++++ src/ui/components/index.ts | 2 + src/ui/contexts/AppContext.tsx | 9 +- src/ui/core/slash-commands.ts | 9 +- src/ui/exit-summary.ts | 37 +++++ src/ui/theme/ThemeContext.tsx | 4 +- src/ui/theme/chalk-theme.ts | 3 + src/ui/theme/current-theme.ts | 6 +- src/ui/theme/index.ts | 12 +- src/ui/theme/presets.ts | 127 +++++++++++++- src/ui/theme/resolver.ts | 33 ++-- src/ui/theme/types.ts | 11 +- src/ui/views/App.tsx | 98 +++++------ src/ui/views/AppContainer.tsx | 80 ++++++++- src/ui/views/ExitSummaryView.tsx | 96 +++++++++++ src/ui/views/PermissionPrompt.tsx | 4 +- src/ui/views/PromptInput.tsx | 46 ++++- 25 files changed, 999 insertions(+), 170 deletions(-) create mode 100644 src/ui/components/ThemeDropdown/index.tsx create mode 100644 src/ui/components/ThemeableStatic/index.tsx create mode 100644 src/ui/views/ExitSummaryView.tsx diff --git a/docs/configuration.md b/docs/configuration.md index 922f39e..4811a21 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -128,6 +128,85 @@ MCP(Model Context Protocol)服务器配置。值是键值对,键为服务 详细 MCP 使用说明请参考 [mcp.md](mcp.md)。 +#### `theme` — 主题配置 + +Deep Code 支持自定义主题颜色,让你的终端界面更符合个人喜好。 + +**使用预设主题** + +```json +{ + "theme": { + "preset": "dark" + } +} +``` + +可用的预设主题: + +| 预设名称 | 说明 | +| --------------- | ------------------------------ | +| `light` | 浅色主题(默认,浅色背景优化) | +| `dark` | 暗色主题(深色背景优化) | +| `github-light` | GitHub Light 风格主题 | +| `github-dark` | GitHub Dark 风格主题 | +| `gitlab-light` | GitLab Light 风格主题 | +| `gitlab-dark` | GitLab Dark 风格主题 | +| `monokai` | Monokai 风格主题 | +| `dracula` | Dracula 风格主题 | + +**自定义主题颜色** + +使用 `preset: "custom"` 并通过 `overrides` 覆盖部分颜色: + +```json +{ + "theme": { + "preset": "custom", + "overrides": { + "primary": "#ff6600", + "success": "greenBright" + } + } +} +``` + +**可用的颜色 Token** + +| Token | 说明 | 默认值 | +| ------------ | -------------------------------------------- | ---------- | +| `primary` | 品牌色:Logo、用户消息、选中项、标题 | `#229ac3` | +| `secondary` | 辅助品牌色:边框、渐变 | `#229ac3e6`| +| `success` | 成功:工具执行成功、低风险操作 | `#1a7f37` | +| `error` | 错误:工具执行失败、高风险操作 | `#d1242f` | +| `warning` | 警告:进行中状态、中风险操作 | `#fa8c16` | +| `info` | 信息:技能、图片附件 | `#0969da` | +| `text` | 主文字颜色 | `#3D4149` | +| `textDim` | 次要文字:暗化提示、引用块 | `#646A71` | +| `textBright` | 亮色文字:强调提示 | `#1F2329` | +| `code` | 代码块/内联代码 | `#787f8a` | +| `border` | 边框 | `#999` | +| `gradients` | Logo 渐变色数组 | `["#229ac3", "#8250df"]` | + +颜色值支持以下格式: +- Hex 格式:`"#ff6600"`、`"#ff6600cc"`(带透明度) +- Chalk 命名颜色:`"greenBright"`、`"cyanBright"`、`"red"` 等 + +**运行时切换主题** + +在 CLI 中使用 `/theme` 命令可以快速切换预设主题: + +``` +/theme # 显示主题选择器 +/theme dark # 切换到暗色主题 +/theme light # 切换回浅色主题 +/theme github-dark # 切换到 GitHub Dark 主题 +/theme gitlab-light # 切换到 GitLab Light 主题 +/theme monokai # 切换到 Monokai 主题 +``` + +切换后会自动保存到 `settings.json`,下次启动时生效。 + #### `debugLogEnabled` — 调试日志 设为 `true` 可让程序输出详细的调试日志(默认 `false`),用于排查 API 调用和工具执行的问题。 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index f53fb11..da6d11a 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -127,6 +127,85 @@ Configuration for MCP (Model Context Protocol) servers. The value is a key-value For detailed MCP usage instructions, refer to [mcp.md](mcp.md). +#### `theme` — Theme Configuration + +Deep Code supports customizing theme colors to make your terminal interface match your personal preferences. + +**Using Preset Themes** + +```json +{ + "theme": { + "preset": "dark" + } +} +``` + +Available preset themes: + +| Preset Name | Description | +| --------------- | ---------------------------------------- | +| `light` | Light theme (default, optimized for light backgrounds) | +| `dark` | Dark theme (optimized for dark backgrounds) | +| `github-light` | GitHub Light style theme | +| `github-dark` | GitHub Dark style theme | +| `gitlab-light` | GitLab Light style theme | +| `gitlab-dark` | GitLab Dark style theme | +| `monokai` | Monokai-style theme | +| `dracula` | Dracula-style theme | + +**Custom Theme Colors** + +Use `preset: "custom"` with `overrides` to customize specific colors: + +```json +{ + "theme": { + "preset": "custom", + "overrides": { + "primary": "#ff6600", + "success": "greenBright" + } + } +} +``` + +**Available Color Tokens** + +| Token | Description | Default Value | +| ------------ | ------------------------------------------------ | ------------- | +| `primary` | Brand color: logo, user messages, selected items, headings | `#229ac3` | +| `secondary` | Auxiliary brand color: borders, gradients | `#229ac3e6` | +| `success` | Success: tool execution success, low-risk ops | `#1a7f37` | +| `error` | Error: tool execution failure, high-risk ops | `#d1242f` | +| `warning` | Warning: in-progress state, mid-risk ops | `#fa8c16` | +| `info` | Info: skills, image attachments | `#0969da` | +| `text` | Main text color | `#3D4149` | +| `textDim` | Secondary text: dimmed hints, quote blocks | `#646A71` | +| `textBright` | Bright text: emphasized hints | `#1F2329` | +| `code` | Code blocks / inline code | `#787f8a` | +| `border` | Borders | `#999` | +| `gradients` | Logo gradient color array | `["#229ac3", "#8250df"]` | + +Color values support the following formats: +- Hex format: `"#ff6600"`, `"#ff6600cc"` (with alpha) +- Chalk named colors: `"greenBright"`, `"cyanBright"`, `"red"`, etc. + +**Runtime Theme Switching** + +Use the `/theme` command in the CLI to quickly switch preset themes: + +``` +/theme # Show theme picker +/theme dark # Switch to dark theme +/theme light # Switch back to light theme +/theme github-dark # Switch to GitHub Dark theme +/theme gitlab-light # Switch to GitLab Light theme +/theme monokai # Switch to Monokai theme +``` + +The switch is automatically saved to `settings.json` and will take effect on the next launch. + #### `debugLogEnabled` — Debug Log Set to `true` to enable detailed debug logging (default `false`), useful for troubleshooting API calls and tool execution. diff --git a/src/common/update-check.ts b/src/common/update-check.ts index 33f8477..5ab3efc 100644 --- a/src/common/update-check.ts +++ b/src/common/update-check.ts @@ -6,7 +6,7 @@ import * as path from "path"; import { render, type Instance } from "ink"; import chalk from "chalk"; import { UpdatePrompt, type UpdatePromptChoice } from "../ui"; -import { DEFAULT_THEME } from "../ui/theme/presets"; +import { LIGHT_THEME } from "../ui/theme/presets"; import { killProcessTree } from "./process-tree"; export type PackageInfo = { @@ -59,7 +59,7 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< if (ok) { writeUpdateState({ ...state, pending: null }); process.stdout.write( - `\n${chalk.hex(DEFAULT_THEME.error)("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n` + `\n${chalk.hex(LIGHT_THEME.error)("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n` ); } return { installed: ok }; diff --git a/src/tests/permission-prompt.test.ts b/src/tests/permission-prompt.test.ts index c49519c..c41b651 100644 --- a/src/tests/permission-prompt.test.ts +++ b/src/tests/permission-prompt.test.ts @@ -1,19 +1,20 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { getScopeRiskColor } from "../ui/views/PermissionPrompt"; +import { LIGHT_THEME } from "../ui/theme"; test("getScopeRiskColor maps permission scopes by risk", () => { - assert.equal(getScopeRiskColor("read-in-cwd"), "#52c41a"); - assert.equal(getScopeRiskColor("query-git-log"), "#52c41a"); + assert.equal(getScopeRiskColor("read-in-cwd"), LIGHT_THEME.success); + assert.equal(getScopeRiskColor("query-git-log"), LIGHT_THEME.success); - assert.equal(getScopeRiskColor("read-out-cwd"), "#faad14"); - assert.equal(getScopeRiskColor("write-in-cwd"), "#faad14"); - assert.equal(getScopeRiskColor("network"), "#faad14"); - assert.equal(getScopeRiskColor("mcp"), "#faad14"); + assert.equal(getScopeRiskColor("read-out-cwd"), LIGHT_THEME.warning); + assert.equal(getScopeRiskColor("write-in-cwd"), LIGHT_THEME.warning); + assert.equal(getScopeRiskColor("network"), LIGHT_THEME.warning); + assert.equal(getScopeRiskColor("mcp"), LIGHT_THEME.warning); - assert.equal(getScopeRiskColor("write-out-cwd"), "#ff4d4f"); - assert.equal(getScopeRiskColor("delete-in-cwd"), "#ff4d4f"); - assert.equal(getScopeRiskColor("delete-out-cwd"), "#ff4d4f"); - assert.equal(getScopeRiskColor("mutate-git-log"), "#ff4d4f"); - assert.equal(getScopeRiskColor("unknown"), "#ff4d4f"); + assert.equal(getScopeRiskColor("write-out-cwd"), LIGHT_THEME.error); + assert.equal(getScopeRiskColor("delete-in-cwd"), LIGHT_THEME.error); + assert.equal(getScopeRiskColor("delete-out-cwd"), LIGHT_THEME.error); + assert.equal(getScopeRiskColor("mutate-git-log"), LIGHT_THEME.error); + assert.equal(getScopeRiskColor("unknown"), LIGHT_THEME.error); }); diff --git a/src/tests/slash-commands.test.ts b/src/tests/slash-commands.test.ts index 30d77ee..a7d470e 100644 --- a/src/tests/slash-commands.test.ts +++ b/src/tests/slash-commands.test.ts @@ -22,6 +22,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => { assert.deepEqual(builtinNames, [ "skills", "model", + "theme", "new", "init", "resume", @@ -105,6 +106,13 @@ test("findExactSlashCommand returns built-in /raw", () => { assert.equal(item?.kind, "raw"); }); +test("findExactSlashCommand returns built-in /theme", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/theme"); + assert.ok(item); + assert.equal(item?.kind, "theme"); +}); + test("findExactSlashCommand returns the matching skill", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/code-review"); diff --git a/src/tests/theme.test.ts b/src/tests/theme.test.ts index 5b80998..eeccd0c 100644 --- a/src/tests/theme.test.ts +++ b/src/tests/theme.test.ts @@ -2,7 +2,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import chalk from "chalk"; -import { DEFAULT_THEME, PRESETS } from "../ui/theme"; +import { + LIGHT_THEME, + DARK_THEME, + MONOKAI_THEME, + DRACULA_THEME, + GITHUB_LIGHT_THEME, + GITHUB_DARK_THEME, + GITLAB_LIGHT_THEME, + GITLAB_DARK_THEME, + PRESETS, +} from "../ui/theme"; import { resolveTheme } from "../ui/theme"; import { createThemedChalk } from "../ui/theme"; import { setCurrentTheme, getCurrentThemedChalk, getCurrentThemeTokens } from "../ui/theme"; @@ -28,6 +38,7 @@ const REQUIRED_TOKEN_KEYS: Array = [ "info", "text", "textDim", + "textBright", "code", "border", "gradients", @@ -42,55 +53,78 @@ const DEFAULTS = { // Presets // --------------------------------------------------------------------------- -test("DEFAULT_THEME has all required token keys", () => { +test("LIGHT_THEME has all required token keys", () => { for (const key of REQUIRED_TOKEN_KEYS) { - assert.ok(key in DEFAULT_THEME, `DEFAULT_THEME is missing key: ${key}`); + assert.ok(key in LIGHT_THEME, `LIGHT_THEME is missing key: ${key}`); } }); -test("DEFAULT_THEME primary matches expected brand color", () => { - assert.equal(DEFAULT_THEME.primary, "#229ac3"); - assert.equal(DEFAULT_THEME.secondary, "#229ac3e6"); +test("LIGHT_THEME primary matches expected brand color", () => { + assert.equal(LIGHT_THEME.primary, "#229ac3"); + assert.equal(LIGHT_THEME.secondary, "#229ac3e6"); }); -test("DEFAULT_THEME semantic colors match expected values", () => { - assert.equal(DEFAULT_THEME.success, "#1a7f37"); - assert.equal(DEFAULT_THEME.error, "#d1242f"); - assert.equal(DEFAULT_THEME.warning, "#fa8c16"); - assert.equal(DEFAULT_THEME.info, "#0969da"); +test("LIGHT_THEME semantic colors match expected values", () => { + assert.equal(LIGHT_THEME.success, "#1a7f37"); + assert.equal(LIGHT_THEME.error, "#d1242f"); + assert.equal(LIGHT_THEME.warning, "#fa8c16"); + assert.equal(LIGHT_THEME.info, "#0969da"); }); -test("DEFAULT_THEME base colors match expected values", () => { - assert.equal(DEFAULT_THEME.text, "#3D4149"); - assert.equal(DEFAULT_THEME.textDim, "#646A71"); - assert.equal(DEFAULT_THEME.code, "#787f8a"); +test("LIGHT_THEME base colors match expected values", () => { + assert.equal(LIGHT_THEME.text, "#3D4149"); + assert.equal(LIGHT_THEME.textDim, "#646A71"); + assert.equal(LIGHT_THEME.textBright, "#1F2329"); + assert.equal(LIGHT_THEME.code, "#787f8a"); }); -test("PRESETS map contains default", () => { - assert.ok("default" in PRESETS); - assert.equal(Object.keys(PRESETS).length, 1); - assert.equal(PRESETS.default, DEFAULT_THEME); +test("PRESETS map contains all presets", () => { + assert.ok("light" in PRESETS); + assert.ok("dark" in PRESETS); + assert.ok("monokai" in PRESETS); + assert.ok("dracula" in PRESETS); + assert.ok("github-light" in PRESETS); + assert.ok("github-dark" in PRESETS); + assert.ok("gitlab-light" in PRESETS); + assert.ok("gitlab-dark" in PRESETS); + assert.equal(Object.keys(PRESETS).length, 8); + assert.equal(PRESETS.light, LIGHT_THEME); + assert.equal(PRESETS.dark, DARK_THEME); + assert.equal(PRESETS.monokai, MONOKAI_THEME); + assert.equal(PRESETS.dracula, DRACULA_THEME); }); // --------------------------------------------------------------------------- // Resolver // --------------------------------------------------------------------------- -test("resolveTheme returns DEFAULT_THEME when settings is undefined", () => { +test("resolveTheme returns LIGHT_THEME when settings is undefined", () => { const result = resolveTheme(undefined); - assert.equal(result.primary, DEFAULT_THEME.primary); - assert.equal(result.success, DEFAULT_THEME.success); + assert.equal(result.primary, LIGHT_THEME.primary); + assert.equal(result.success, LIGHT_THEME.success); }); -test("resolveTheme returns DEFAULT_THEME for explicit 'default' preset", () => { - const result = resolveTheme({ preset: "default" }); - assert.equal(result.primary, DEFAULT_THEME.primary); +test("resolveTheme returns LIGHT_THEME for explicit 'light' preset", () => { + const result = resolveTheme({ preset: "light" }); + assert.equal(result.primary, LIGHT_THEME.primary); }); -test("resolveTheme returns DEFAULT_THEME when preset is not 'custom'", () => { - const result = resolveTheme({ preset: "default" }); - assert.equal(result.text, DEFAULT_THEME.text); - assert.equal(result.primary, DEFAULT_THEME.primary); +test("resolveTheme returns DARK_THEME for 'dark' preset", () => { + const result = resolveTheme({ preset: "dark" }); + assert.equal(result.primary, DARK_THEME.primary); + assert.equal(result.text, DARK_THEME.text); +}); + +test("resolveTheme returns MONOKAI_THEME for 'monokai' preset", () => { + const result = resolveTheme({ preset: "monokai" }); + assert.equal(result.primary, MONOKAI_THEME.primary); + assert.equal(result.text, MONOKAI_THEME.text); +}); + +test("resolveTheme returns DRACULA_THEME for 'dracula' preset", () => { + const result = resolveTheme({ preset: "dracula" }); + assert.equal(result.primary, DRACULA_THEME.primary); + assert.equal(result.text, DRACULA_THEME.text); }); test("resolveTheme applies overrides when preset is 'custom'", () => { @@ -99,7 +133,7 @@ test("resolveTheme applies overrides when preset is 'custom'", () => { overrides: { primary: "#ff0000" }, }); assert.equal(result.primary, "#ff0000"); - assert.equal(result.success, DEFAULT_THEME.success); + assert.equal(result.success, LIGHT_THEME.success); }); test("resolveTheme applies multiple overrides with custom preset", () => { @@ -114,7 +148,7 @@ test("resolveTheme applies multiple overrides with custom preset", () => { assert.equal(result.primary, "#ff6600"); assert.equal(result.success, "greenBright"); assert.equal(result.warning, "yellowBright"); - assert.equal(result.error, DEFAULT_THEME.error); + assert.equal(result.error, LIGHT_THEME.error); }); test("resolveTheme full custom tokens with custom preset", () => { @@ -127,6 +161,7 @@ test("resolveTheme full custom tokens with custom preset", () => { info: "blue", text: "blue", textDim: "blue", + textBright: "blue", code: "blue", border: "blue", gradients: ["#aaaaaa", "#bbbbbb"], @@ -142,28 +177,28 @@ test("resolveTheme handles override with undefined fields gracefully", () => { preset: "custom", overrides: { primary: undefined, success: undefined } as Partial, }); - assert.equal(result.primary, DEFAULT_THEME.primary); - assert.equal(result.success, DEFAULT_THEME.success); + assert.equal(result.primary, LIGHT_THEME.primary); + assert.equal(result.success, LIGHT_THEME.success); }); test("resolveTheme ignores overrides when preset is not custom", () => { const result = resolveTheme({ - preset: "default", + preset: "light", overrides: { primary: "#ff0000" }, }); - assert.equal(result.primary, DEFAULT_THEME.primary); + assert.equal(result.primary, LIGHT_THEME.primary); }); test("resolveTheme ignores tokens when preset is not custom", () => { const result = resolveTheme({ tokens: { primary: "#ff0000" } as ThemeTokens, }); - assert.equal(result.primary, DEFAULT_THEME.primary); + assert.equal(result.primary, LIGHT_THEME.primary); }); -test("resolveTheme returns DEFAULT_THEME for custom preset without token/overrides", () => { +test("resolveTheme returns LIGHT_THEME for custom preset without token/overrides", () => { const result = resolveTheme({ preset: "custom" }); - assert.equal(result.primary, DEFAULT_THEME.primary); + assert.equal(result.primary, LIGHT_THEME.primary); }); // --------------------------------------------------------------------------- @@ -171,46 +206,46 @@ test("resolveTheme returns DEFAULT_THEME for custom preset without token/overrid // --------------------------------------------------------------------------- test("createThemedChalk heading1 produces styled output via primary", () => { - const tc = createThemedChalk(DEFAULT_THEME); + const tc = createThemedChalk(LIGHT_THEME); assert.notEqual(tc.heading1("Hello"), "Hello"); }); test("createThemedChalk heading1 changes when primary changes", () => { - const custom: ThemeTokens = { ...DEFAULT_THEME, primary: "#ff0000" }; - assert.notEqual(createThemedChalk(DEFAULT_THEME).heading1("test"), createThemedChalk(custom).heading1("test")); + const custom: ThemeTokens = { ...LIGHT_THEME, primary: "#ff0000" }; + assert.notEqual(createThemedChalk(LIGHT_THEME).heading1("test"), createThemedChalk(custom).heading1("test")); }); test("createThemedChalk inlineCode changes when code changes", () => { - const custom: ThemeTokens = { ...DEFAULT_THEME, code: "#ff0000" }; - assert.notEqual(createThemedChalk(DEFAULT_THEME).inlineCode("test"), createThemedChalk(custom).inlineCode("test")); + const custom: ThemeTokens = { ...LIGHT_THEME, code: "#ff0000" }; + assert.notEqual(createThemedChalk(LIGHT_THEME).inlineCode("test"), createThemedChalk(custom).inlineCode("test")); }); test("createThemedChalk listBullet changes when warning changes", () => { - const custom: ThemeTokens = { ...DEFAULT_THEME, warning: "#ff0000" }; - assert.notEqual(createThemedChalk(DEFAULT_THEME).listBullet("test"), createThemedChalk(custom).listBullet("test")); + const custom: ThemeTokens = { ...LIGHT_THEME, warning: "#ff0000" }; + assert.notEqual(createThemedChalk(LIGHT_THEME).listBullet("test"), createThemedChalk(custom).listBullet("test")); }); test("createThemedChalk quote changes when textDim changes", () => { - const custom: ThemeTokens = { ...DEFAULT_THEME, textDim: "#ff0000" }; - assert.notEqual(createThemedChalk(DEFAULT_THEME).quote("test"), createThemedChalk(custom).quote("test")); + const custom: ThemeTokens = { ...LIGHT_THEME, textDim: "#ff0000" }; + assert.notEqual(createThemedChalk(LIGHT_THEME).quote("test"), createThemedChalk(custom).quote("test")); }); test("createThemedChalk bold / italic / dim produce styled output", () => { - const tc = createThemedChalk(DEFAULT_THEME); + const tc = createThemedChalk(LIGHT_THEME); assert.notEqual(tc.bold("bold"), "bold"); assert.notEqual(tc.italic("italic"), "italic"); assert.notEqual(tc.dim("dim"), "dim"); }); test("createThemedChalk produces different output for different primary values", () => { - const custom1: ThemeTokens = { ...DEFAULT_THEME, primary: "#ff0000" }; - const custom2: ThemeTokens = { ...DEFAULT_THEME, primary: "#00ff00" }; + const custom1: ThemeTokens = { ...LIGHT_THEME, primary: "#ff0000" }; + const custom2: ThemeTokens = { ...LIGHT_THEME, primary: "#00ff00" }; assert.notEqual(createThemedChalk(custom1).primary("test"), createThemedChalk(custom2).primary("test")); }); test("createThemedChalk handles hex colors correctly", () => { const hexTheme: ThemeTokens = { - ...DEFAULT_THEME, + ...LIGHT_THEME, primary: "#ff6600", warning: "#ffcc00", code: "#00ccff", @@ -224,33 +259,33 @@ test("createThemedChalk handles hex colors correctly", () => { // current-theme (module-level state) // --------------------------------------------------------------------------- -test("getCurrentThemedChalk returns DEFAULT_THEME chalk by default", () => { - setCurrentTheme(DEFAULT_THEME); +test("getCurrentThemedChalk returns LIGHT_THEME chalk by default", () => { + setCurrentTheme(LIGHT_THEME); assert.notEqual(getCurrentThemedChalk().primary("test"), "test"); }); test("setCurrentTheme changes getCurrentThemedChalk output", () => { - setCurrentTheme(DEFAULT_THEME); + setCurrentTheme(LIGHT_THEME); const first = getCurrentThemedChalk().primary("test"); - const custom: ThemeTokens = { ...DEFAULT_THEME, primary: "#ff0000" }; + const custom: ThemeTokens = { ...LIGHT_THEME, primary: "#ff0000" }; setCurrentTheme(custom); const second = getCurrentThemedChalk().primary("test"); assert.notEqual(first, second); - setCurrentTheme(DEFAULT_THEME); + setCurrentTheme(LIGHT_THEME); }); test("setCurrentTheme changes getCurrentThemeTokens output", () => { - setCurrentTheme(DEFAULT_THEME); - assert.equal(getCurrentThemeTokens().primary, DEFAULT_THEME.primary); + setCurrentTheme(LIGHT_THEME); + assert.equal(getCurrentThemeTokens().primary, LIGHT_THEME.primary); - const custom: ThemeTokens = { ...DEFAULT_THEME, primary: "#ff0000" }; + const custom: ThemeTokens = { ...LIGHT_THEME, primary: "#ff0000" }; setCurrentTheme(custom); assert.equal(getCurrentThemeTokens().primary, "#ff0000"); - setCurrentTheme(DEFAULT_THEME); + setCurrentTheme(LIGHT_THEME); }); // --------------------------------------------------------------------------- @@ -260,7 +295,7 @@ test("setCurrentTheme changes getCurrentThemeTokens output", () => { test("resolveSettingsSources includes theme field in resolved settings", () => { const result = resolveSettingsSources(null, null, DEFAULTS, {}); assert.ok("theme" in result); - assert.equal(result.theme.primary, DEFAULT_THEME.primary); + assert.equal(result.theme.primary, LIGHT_THEME.primary); }); test("resolveSettingsSources resolves custom theme from user settings", () => { @@ -285,12 +320,12 @@ test("resolveSettingsSources resolves custom theme from project settings", () => test("resolveSettingsSources uses default theme when preset is not custom", () => { const result = resolveSettingsSources( - { theme: { preset: "default", overrides: { primary: "#abcdef" } } }, + { theme: { preset: "light", overrides: { primary: "#abcdef" } } }, null, DEFAULTS, {} ); - assert.equal(result.theme.primary, DEFAULT_THEME.primary); + assert.equal(result.theme.primary, LIGHT_THEME.primary); }); // --------------------------------------------------------------------------- @@ -298,9 +333,9 @@ test("resolveSettingsSources uses default theme when preset is not custom", () = // --------------------------------------------------------------------------- test("getScopeRiskColor returns default theme colors when no theme is passed", () => { - assert.equal(getScopeRiskColor("read-in-cwd"), "#52c41a"); - assert.equal(getScopeRiskColor("write-in-cwd"), "#faad14"); - assert.equal(getScopeRiskColor("write-out-cwd"), "#ff4d4f"); + assert.equal(getScopeRiskColor("read-in-cwd"), LIGHT_THEME.success); + assert.equal(getScopeRiskColor("write-in-cwd"), LIGHT_THEME.warning); + assert.equal(getScopeRiskColor("write-out-cwd"), LIGHT_THEME.error); }); test("getScopeRiskColor uses theme semantic colors when theme is provided", () => { diff --git a/src/ui/components/DropdownMenu/index.tsx b/src/ui/components/DropdownMenu/index.tsx index f6439ff..6ad43d0 100644 --- a/src/ui/components/DropdownMenu/index.tsx +++ b/src/ui/components/DropdownMenu/index.tsx @@ -10,10 +10,14 @@ export type DropdownMenuItem = { key: string; /** Main label text (can include status indicators) */ label: string; + /** Custom color for the label text */ + labelColor?: string; /** Secondary description text (dimmed) */ description?: string; /** Whether this item is currently selected */ selected?: boolean; + /** Whether this item is disabled (cannot be selected) */ + disabled?: boolean; /** Whether to show a special status indicator (e.g., loaded checkmark) */ statusIndicator?: { symbol: string; @@ -156,18 +160,28 @@ const DropdownMenu = React.memo(function DropdownMenu({ } // Default rendering with selection indicator and optional features + const labelColor = item.disabled ? undefined : isActive ? effectiveActiveColor : item.labelColor; + return ( - - {isActive ? "> " : " "} - {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} + + {isActive && !item.disabled ? "> " : " "} + {item.selected !== undefined ? (item.selected ? "●" : "○") : null}{" "} + {item.label} {item.statusIndicator ? ( {item.statusIndicator.symbol} ) : null} - {item.description ? {`${item.description}`} : null} + + {item.description ? ( + {`${item.description}`} + ) : null} + ); })} diff --git a/src/ui/components/ThemeDropdown/index.tsx b/src/ui/components/ThemeDropdown/index.tsx new file mode 100644 index 0000000..a7f1405 --- /dev/null +++ b/src/ui/components/ThemeDropdown/index.tsx @@ -0,0 +1,175 @@ +import React, { useEffect, useRef, useState, useCallback } from "react"; +import { useInput } from "ink"; +import DropdownMenu from "../DropdownMenu"; +import { PRESETS } from "../../theme"; +import type { ThemePreset } from "../../theme"; + +const THEME_PRESETS: ThemePreset[] = [ + "light", + "dark", + "github-light", + "github-dark", + "gitlab-light", + "gitlab-dark", + "monokai", + "dracula", + "custom", +]; + +type Props = { + open: boolean; + width: number; + hasCustomConfig: boolean; + currentPreset: ThemePreset; + onClose: () => void; + onThemeChange: (preset: ThemePreset) => void; + onThemePreview?: (preset: ThemePreset) => void; + onThemeRevert?: () => void; + onStatusMessage?: (message: string | null) => void; +}; + +const ThemeDropdown: React.FC = ({ + open, + width, + hasCustomConfig, + currentPreset, + onClose, + onThemeChange, + onThemePreview, + onThemeRevert, + onStatusMessage, +}) => { + const [activeIndex, setActiveIndex] = useState(0); + // 记录打开时的主题,用于取消时回退 + const originalPresetRef = useRef(null); + + // 检查项是否禁用 + const isItemDisabled = useCallback( + (preset: ThemePreset): boolean => { + return preset === "custom" && !hasCustomConfig; + }, + [hasCustomConfig] + ); + + // 获取下一个可用的索引 + const getNextEnabledIndex = useCallback( + (currentIndex: number, direction: 1 | -1): number => { + const length = THEME_PRESETS.length; + let nextIndex = currentIndex; + for (let i = 0; i < length; i++) { + nextIndex = (nextIndex + direction + length) % length; + if (!isItemDisabled(THEME_PRESETS[nextIndex])) { + return nextIndex; + } + } + return currentIndex; // 如果没有可用项,返回当前索引 + }, + [isItemDisabled] + ); + + // Initialize state when opened + useEffect(() => { + if (open) { + originalPresetRef.current = currentPreset; + const currentIndex = THEME_PRESETS.findIndex((p) => p === currentPreset); + const initialIndex = currentIndex >= 0 ? currentIndex : 0; + // 如果初始索引是禁用项,找下一个可用项 + if (isItemDisabled(THEME_PRESETS[initialIndex])) { + setActiveIndex(getNextEnabledIndex(initialIndex, 1)); + } else { + setActiveIndex(initialIndex); + } + } + }, [open, currentPreset, isItemDisabled, getNextEnabledIndex]); + + function selectItem(): void { + const preset = THEME_PRESETS[activeIndex]; + if (preset && !isItemDisabled(preset)) { + onThemeChange(preset); + onStatusMessage?.(`Theme changed to ${preset}`); + onClose(); + } + } + + function cancelSelection(): void { + // 回退到打开时的主题 + if (originalPresetRef.current && onThemeRevert) { + onThemeRevert(); + } + onClose(); + } + + useInput( + (input, key) => { + if (!open) { + return; + } + + if (key.upArrow) { + const nextIndex = getNextEnabledIndex(activeIndex, -1); + setActiveIndex(nextIndex); + // 预览主题 + const preset = THEME_PRESETS[nextIndex]; + if (preset && !isItemDisabled(preset)) { + onThemePreview?.(preset); + } + return; + } + if (key.downArrow) { + const nextIndex = getNextEnabledIndex(activeIndex, 1); + setActiveIndex(nextIndex); + // 预览主题 + const preset = THEME_PRESETS[nextIndex]; + if (preset && !isItemDisabled(preset)) { + onThemePreview?.(preset); + } + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + selectItem(); + return; + } + if (key.tab || key.escape) { + cancelSelection(); + return; + } + }, + { isActive: open } + ); + + if (!open) { + return null; + } + + const items = THEME_PRESETS.map((preset) => { + const presetTheme = PRESETS[preset]; + return { + key: preset, + label: preset, + labelColor: presetTheme?.primary, + description: + preset === currentPreset + ? "current theme" + : preset === "custom" + ? hasCustomConfig + ? "use custom config" + : "not configured" + : "", + selected: preset === currentPreset, + disabled: isItemDisabled(preset), + }; + }); + + return ( + + ); +}; + +export default ThemeDropdown; diff --git a/src/ui/components/ThemeableStatic/index.tsx b/src/ui/components/ThemeableStatic/index.tsx new file mode 100644 index 0000000..cdd00aa --- /dev/null +++ b/src/ui/components/ThemeableStatic/index.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from "react"; +import { Box } from "ink"; + +type Props = { + items: T[]; + themeVersion: number; + /** 当此值变化时强制重新挂载,用于清除终端旧内容(如 /new 切换会话) */ + resetKey?: number; + children: (item: T, index: number) => React.ReactNode; +}; + +/** + * 支持主题重新渲染的 Static 组件。 + * + * Ink 的 组件只渲染新增的 items,已渲染的 items 不会重新渲染。 + * 这个组件始终渲染所有 items,使用 key={themeVersion}:{resetKey} 在主题变化或内容重置时强制重新挂载。 + */ +export default function ThemeableStatic({ + items, + themeVersion, + resetKey, + children: render, +}: Props): React.ReactElement { + const children = useMemo(() => { + return items.map((item, index) => render(item, index)); + }, [items, render]); + + const compositeKey = `${themeVersion}:${resetKey ?? 0}`; + + return ( + + {children} + + ); +} diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts index f3cbd67..fc92efe 100644 --- a/src/ui/components/index.ts +++ b/src/ui/components/index.ts @@ -3,5 +3,7 @@ export { MessageView } from "./MessageView"; export { RawModeExitPrompt } from "./RawModeExitPrompt"; export { default as SkillsDropdown } from "./SkillsDropdown"; export { default as ModelsDropdown } from "./ModelsDropdown"; +export { default as ThemeDropdown } from "./ThemeDropdown"; +export { default as ThemeableStatic } from "./ThemeableStatic"; export { default as FileMentionMenu } from "./FileMentionMenu"; export { default as DropdownMenu } from "./DropdownMenu"; diff --git a/src/ui/contexts/AppContext.tsx b/src/ui/contexts/AppContext.tsx index 41b1d1d..e596a70 100644 --- a/src/ui/contexts/AppContext.tsx +++ b/src/ui/contexts/AppContext.tsx @@ -1,7 +1,14 @@ import { createContext, useContext } from "react"; +import type { ThemeTokens, ThemePreset } from "../theme"; export interface AppState { version: string; + hasCustomThemeConfig: boolean; + themeVersion: number; + currentPreset: ThemePreset; + switchTheme?: (presetOrTokens: string | Partial) => void; + previewTheme?: (presetOrTokens: string | Partial) => void; + revertTheme?: () => void; } export const AppContext = createContext(null); @@ -10,7 +17,7 @@ export const useAppContext = (): AppState => { const context = useContext(AppContext); if (!context) { // Safe fallback when App is rendered without AppContainer (e.g., in tests). - return { version: "unknown" }; + return { version: "unknown", hasCustomThemeConfig: false, themeVersion: 0, currentPreset: "light" }; } return context; }; diff --git a/src/ui/core/slash-commands.ts b/src/ui/core/slash-commands.ts index 04840ba..41010ed 100644 --- a/src/ui/core/slash-commands.ts +++ b/src/ui/core/slash-commands.ts @@ -4,6 +4,7 @@ export type SlashCommandKind = | "skill" | "skills" | "model" + | "theme" | "new" | "init" | "resume" @@ -35,11 +36,17 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/model", description: "Select model, thinking mode and effort control", }, + { + kind: "theme", + name: "theme", + label: "/theme", + description: "Change the theme", + }, { kind: "new", name: "new", label: "/new", - description: "Start a fresh conversation", + description: "Start a new session with empty context; previous session stays on disk (resumable with /resume)", }, { kind: "init", diff --git a/src/ui/exit-summary.ts b/src/ui/exit-summary.ts index 0400845..60774a8 100644 --- a/src/ui/exit-summary.ts +++ b/src/ui/exit-summary.ts @@ -145,3 +145,40 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { return [top, body, bottom].join("\n"); } + +// --------------------------------------------------------------------------- +// Structured exit summary for React rendering +// --------------------------------------------------------------------------- + +export type ExitSummaryRow = { + modelName: string; + reqs: number; + inputTokens: number; + outputTokens: number; + cachedTokens: number; +}; + +export type ExitSummaryData = { + rows: ExitSummaryRow[]; + hasUsage: boolean; +}; + +export function buildExitSummaryData(input: ExitSummaryInput): ExitSummaryData { + const { session } = input; + + const rows = Object.entries(session?.usagePerModel ?? {}) + .map(([modelName, usage]) => { + const fields = extractUsageFields(usage); + return { + modelName, + reqs: fields.totalReqs, + inputTokens: fields.promptTokens, + outputTokens: fields.completionTokens, + cachedTokens: fields.cachedTokens, + }; + }) + .filter((row) => row.reqs > 0 || row.inputTokens > 0 || row.outputTokens > 0 || row.cachedTokens > 0) + .sort((left, right) => right.reqs - left.reqs || left.modelName.localeCompare(right.modelName)); + + return { rows, hasUsage: rows.length > 0 }; +} diff --git a/src/ui/theme/ThemeContext.tsx b/src/ui/theme/ThemeContext.tsx index bc76b46..2b44d08 100644 --- a/src/ui/theme/ThemeContext.tsx +++ b/src/ui/theme/ThemeContext.tsx @@ -1,9 +1,9 @@ import { createContext, useContext } from "react"; import type { ThemeTokens } from "./types"; -import { DEFAULT_THEME } from "./presets"; +import { LIGHT_THEME } from "./presets"; /** 主题 React Context */ -const ThemeContext = createContext(DEFAULT_THEME); +const ThemeContext = createContext(LIGHT_THEME); /** 主题 Provider */ export const ThemeProvider = ThemeContext.Provider; diff --git a/src/ui/theme/chalk-theme.ts b/src/ui/theme/chalk-theme.ts index e451c2b..e0fa043 100644 --- a/src/ui/theme/chalk-theme.ts +++ b/src/ui/theme/chalk-theme.ts @@ -39,6 +39,7 @@ export interface ThemedChalk { secondary: (text: string) => string; text: (text: string) => string; textDim: (text: string) => string; + textBright: (text: string) => string; success: (text: string) => string; error: (text: string) => string; warning: (text: string) => string; @@ -50,6 +51,7 @@ export function createThemedChalk(theme: ThemeTokens): ThemedChalk { const se = chalkColor(theme.secondary); const tx = chalkColor(theme.text); const td = chalkColor(theme.textDim); + const tb = chalkColor(theme.textBright); const cd = chalkColor(theme.code); const sc = chalkColor(theme.success); const er = chalkColor(theme.error); @@ -74,6 +76,7 @@ export function createThemedChalk(theme: ThemeTokens): ThemedChalk { secondary: (text: string) => se(text), text: (text: string) => tx(text), textDim: (text: string) => td(text), + textBright: (text: string) => tb(text), success: (text: string) => sc(text), error: (text: string) => er(text), warning: (text: string) => wr(text), diff --git a/src/ui/theme/current-theme.ts b/src/ui/theme/current-theme.ts index 60957d6..a8a9396 100644 --- a/src/ui/theme/current-theme.ts +++ b/src/ui/theme/current-theme.ts @@ -1,9 +1,9 @@ -import { DEFAULT_THEME } from "./presets"; +import { LIGHT_THEME } from "./presets"; import { createThemedChalk, type ThemedChalk } from "./chalk-theme"; import type { ThemeTokens } from "./types"; -let currentThemedChalk: ThemedChalk = createThemedChalk(DEFAULT_THEME); -let currentThemeTokens: ThemeTokens = DEFAULT_THEME; +let currentThemedChalk: ThemedChalk = createThemedChalk(LIGHT_THEME); +let currentThemeTokens: ThemeTokens = LIGHT_THEME; /** 设置当前主题(在 AppContainer 中调用一次) */ export function setCurrentTheme(theme: ThemeTokens): void { diff --git a/src/ui/theme/index.ts b/src/ui/theme/index.ts index 5988468..e09feda 100644 --- a/src/ui/theme/index.ts +++ b/src/ui/theme/index.ts @@ -1,5 +1,15 @@ export type { ThemeTokens, ThemePreset, ThemeSettings } from "./types"; -export { DEFAULT_THEME, PRESETS } from "./presets"; +export { + LIGHT_THEME, + DARK_THEME, + MONOKAI_THEME, + DRACULA_THEME, + GITHUB_LIGHT_THEME, + GITHUB_DARK_THEME, + GITLAB_LIGHT_THEME, + GITLAB_DARK_THEME, + PRESETS, +} from "./presets"; export { resolveTheme } from "./resolver"; export { ThemeProvider, useTheme } from "./ThemeContext"; export { createThemedChalk } from "./chalk-theme"; diff --git a/src/ui/theme/presets.ts b/src/ui/theme/presets.ts index 39a8204..ff5acc3 100644 --- a/src/ui/theme/presets.ts +++ b/src/ui/theme/presets.ts @@ -1,7 +1,7 @@ import type { ThemeTokens } from "./types"; -/** 系统默认主题(唯一内置主题) */ -export const DEFAULT_THEME: ThemeTokens = { +/** 浅色主题(默认主题) */ +export const LIGHT_THEME: ThemeTokens = { primary: "#229ac3", secondary: "#229ac3e6", success: "#1a7f37", @@ -10,13 +10,132 @@ export const DEFAULT_THEME: ThemeTokens = { info: "#0969da", text: "#3D4149", textDim: "#646A71", - textBright: "#646A71", + textBright: "#1F2329", code: "#787f8a", border: "#999", gradients: ["#229ac3", "#8250df"], }; +/** 暗色主题 */ +export const DARK_THEME: ThemeTokens = { + primary: "#229ac3", + secondary: "#229ac3e6", + success: "#3fb950", + error: "#f85149", + warning: "#d29922", + info: "#58a6ff", + text: "#c9d1d9", + textDim: "#8b949e", + textBright: "#f0f6fc", + code: "#8b949e", + border: "#30363d", + gradients: ["#229ac3", "#8250df"], +}; + +/** Monokai 主题 */ +export const MONOKAI_THEME: ThemeTokens = { + primary: "#f92672", + secondary: "#f92672cc", + success: "#a6e22e", + error: "#f92672", + warning: "#fd971f", + info: "#66d9ef", + text: "#f8f8f2", + textDim: "#75715e", + textBright: "#f8f8f2", + code: "#75715e", + border: "#49483e", + gradients: ["#f92672", "#ae81ff"], +}; + +/** Dracula 主题 */ +export const DRACULA_THEME: ThemeTokens = { + primary: "#bd93f9", + secondary: "#bd93f9cc", + success: "#50fa7b", + error: "#ff5555", + warning: "#ffb86c", + info: "#8be9fd", + text: "#f8f8f2", + textDim: "#6272a4", + textBright: "#f8f8f2", + code: "#6272a4", + border: "#44475a", + gradients: ["#bd93f9", "#ff79c6"], +}; + +/** GitHub Light 主题 */ +export const GITHUB_LIGHT_THEME: ThemeTokens = { + primary: "#0969da", + secondary: "#0969dae6", + success: "#1a7f37", + error: "#cf222e", + warning: "#9a6700", + info: "#0969da", + text: "#1F2328", + textDim: "#656d76", + textBright: "#0d1117", + code: "#656d76", + border: "#d0d7de", + gradients: ["#0969da", "#8250df"], +}; + +/** GitHub Dark 主题 */ +export const GITHUB_DARK_THEME: ThemeTokens = { + primary: "#58a6ff", + secondary: "#58a6ffcc", + success: "#3fb950", + error: "#f85149", + warning: "#d29922", + info: "#58a6ff", + text: "#c9d1d9", + textDim: "#8b949e", + textBright: "#f0f6fc", + code: "#8b949e", + border: "#30363d", + gradients: ["#58a6ff", "#bc8cff"], +}; + +/** GitLab Light 主题 */ +export const GITLAB_LIGHT_THEME: ThemeTokens = { + primary: "#1068bf", + secondary: "#1068bfe6", + success: "#108548", + error: "#dd2b0e", + warning: "#c17d10", + info: "#1068bf", + text: "#1f1e24", + textDim: "#626168", + textBright: "#0f0e11", + code: "#626168", + border: "#dcdcde", + gradients: ["#1068bf", "#694cc0"], +}; + +/** GitLab Dark 主题 */ +export const GITLAB_DARK_THEME: ThemeTokens = { + primary: "#63a0d4", + secondary: "#63a0d4cc", + success: "#26a269", + error: "#e24329", + warning: "#c17d10", + info: "#63a0d4", + text: "#ececef", + textDim: "#a1a1a9", + textBright: "#ffffff", + code: "#a1a1a9", + border: "#3b3b3f", + gradients: ["#63a0d4", "#9785d4"], +}; + /** 预设主题映射表 */ export const PRESETS: Record = { - default: DEFAULT_THEME, + light: LIGHT_THEME, + dark: DARK_THEME, + monokai: MONOKAI_THEME, + dracula: DRACULA_THEME, + "github-light": GITHUB_LIGHT_THEME, + "github-dark": GITHUB_DARK_THEME, + "gitlab-light": GITLAB_LIGHT_THEME, + "gitlab-dark": GITLAB_DARK_THEME, }; diff --git a/src/ui/theme/resolver.ts b/src/ui/theme/resolver.ts index f635217..7166f50 100644 --- a/src/ui/theme/resolver.ts +++ b/src/ui/theme/resolver.ts @@ -1,5 +1,5 @@ import { type ThemeTokens, type ThemeSettings } from "./types"; -import { DEFAULT_THEME } from "./presets"; +import { LIGHT_THEME, PRESETS } from "./presets"; /** * 深度合并两个对象。right 的值覆盖 left。 @@ -25,27 +25,32 @@ function deepMerge(left: T, right: object): T { /** * 解析主题配置,返回最终的 ThemeTokens。 * - * - 未配置 / preset="default":使用系统默认 DEFAULT_THEME - * - preset="custom":使用用户自定义 tokens 或 overrides 合并到 DEFAULT_THEME + * - 未配置 / preset="light":使用浅色主题 LIGHT_THEME + * - preset 为预设名称(如 "dark", "monokai", "dracula"):使用对应预设 + * - preset="custom":使用用户自定义 tokens 或 overrides 合并到 LIGHT_THEME */ export function resolveTheme(themeSettings: ThemeSettings | undefined): ThemeTokens { if (!themeSettings) { - return DEFAULT_THEME; + return LIGHT_THEME; } - // preset 不为 "custom" 时使用默认主题 - if (themeSettings.preset !== "custom") { - return DEFAULT_THEME; + const { preset } = themeSettings; + + // preset 为预设名称时使用对应预设 + if (preset && preset !== "custom" && preset in PRESETS) { + return PRESETS[preset]; } // preset="custom":应用用户自定义 - if (themeSettings.tokens) { - return deepMerge(DEFAULT_THEME, themeSettings.tokens); - } - if (themeSettings.overrides) { - return deepMerge(DEFAULT_THEME, themeSettings.overrides); + if (preset === "custom") { + if (themeSettings.tokens) { + return deepMerge(LIGHT_THEME, themeSettings.tokens); + } + if (themeSettings.overrides) { + return deepMerge(LIGHT_THEME, themeSettings.overrides); + } } - // preset="custom" 但没有提供自定义内容,回退默认 - return DEFAULT_THEME; + // 未配置或无效 preset,回退默认 + return LIGHT_THEME; } diff --git a/src/ui/theme/types.ts b/src/ui/theme/types.ts index 5a099e2..d15f943 100644 --- a/src/ui/theme/types.ts +++ b/src/ui/theme/types.ts @@ -34,7 +34,16 @@ export interface ThemeTokens { } /** 预设主题名称 */ -export type ThemePreset = "default" | "custom"; +export type ThemePreset = + | "light" + | "dark" + | "monokai" + | "dracula" + | "github-light" + | "github-dark" + | "gitlab-light" + | "gitlab-dark" + | "custom"; /** 主题配置(用户可配置部分) */ export type ThemeSettings = { diff --git a/src/ui/views/App.tsx b/src/ui/views/App.tsx index 916c5cb..ae3f2d1 100644 --- a/src/ui/views/App.tsx +++ b/src/ui/views/App.tsx @@ -1,11 +1,12 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink"; -import { getCurrentThemedChalk, useTheme } from "../theme"; +import { Box, Text, useApp, useStdout, useWindowSize } from "ink"; +import { useTheme } from "../theme"; +import { useAppContext } from "../contexts"; import { createOpenAIClient } from "../../common/openai-client"; import type { PermissionScope } from "../../settings"; import { type ModelConfigSelection } from "../../settings"; import { type PromptDraft, PromptInput, type PromptSubmission } from "./PromptInput"; -import { MessageView, RawModeExitPrompt } from "../components"; +import { MessageView, RawModeExitPrompt, ThemeableStatic } from "../components"; import { SessionList } from "./SessionList"; import { type UndoRestoreMode, UndoSelector } from "./UndoSelector"; import { buildLoadingText } from "../core/loading-text"; @@ -20,7 +21,7 @@ import { formatAskUserQuestionAnswers, } from "../core/ask-user-question"; import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt"; -import { buildExitSummaryText } from "../exit-summary"; +import ExitSummaryView from "./ExitSummaryView"; import { RawMode, useRawModeContext } from "../contexts"; import { renderMessageToStdout } from "../components/MessageView/utils"; import { @@ -54,12 +55,13 @@ type AppProps = { onRestart?: () => void; }; -function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { +function App({ projectRoot, initialPrompt }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); const { columns, rows } = useWindowSize(); const { mode, setMode } = useRawModeContext(); const theme = useTheme(); + const { themeVersion, currentPreset, previewTheme, revertTheme } = useAppContext(); const initialPromptSubmittedRef = useRef(false); const processStdoutRef = useRef>(new Map()); const rawModeRef = useRef(mode); @@ -86,6 +88,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl } | null>(null); const [dismissedQuestionIds, setDismissedQuestionIds] = useState>(() => new Set()); const [isExiting, setIsExiting] = useState(false); + const [exitSession, setExitSession] = useState(null); const [showWelcome, setShowWelcome] = useState(true); const [welcomeNonce, setWelcomeNonce] = useState(0); const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); @@ -154,10 +157,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl * Reset the static view to the welcome screen. */ const resetStaticView = useCallback( - (loadedMessages: SessionMessage[], options?: { clearScreen?: boolean }) => { - if (options?.clearScreen) { - process.stdout.write(ANSI_CLEAR_SCREEN); - } + (loadedMessages: SessionMessage[]) => { setMessages([]); setWelcomeNonce((n) => n + 1); navigateToSubView("chat"); @@ -177,6 +177,18 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl return () => clearInterval(id); }, [busy]); + // After exit summary is rendered by Ink, dispose and exit. + useEffect(() => { + if (!isExiting) { + return; + } + const timer = setTimeout(() => { + sessionManager.dispose(); + exit(); + }, 100); + return () => clearTimeout(timer); + }, [isExiting, sessionManager, exit]); + function loadVisibleMessages(manager: SessionManager, sessionId: string): SessionMessage[] { return manager.listSessionMessages(sessionId).filter((m) => m.visible); } @@ -199,9 +211,9 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl /** * Reset the app to the welcome screen. + * 先清屏,等 Ink 渲染完空状态后,再显示 welcome 页面。 */ const resetToWelcome = useCallback(async () => { - writeRef.current(ANSI_CLEAR_SCREEN); sessionManager.setActiveSessionId(null); setStatusLine(""); setErrorLine(null); @@ -210,9 +222,17 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl setActiveAskPermissions(undefined); setPendingPermissionReply(null); setDismissedQuestionIds(new Set()); - resetStaticView([]); + setMessages([]); + setShowWelcome(false); await refreshSkills(); - }, [sessionManager, resetStaticView, refreshSkills]); + // 第一步:清屏 + 清空消息,等 Ink 渲染空状态 + process.stdout.write(ANSI_CLEAR_SCREEN); + // 第二步:等 Ink 完成空状态渲染后,再显示 welcome 页面 + setTimeout(() => { + setWelcomeNonce((n) => n + 1); + setShowWelcome(true); + }, 50); + }, [sessionManager, refreshSkills]); /** * Refresh the list of sessions. @@ -250,29 +270,18 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl const handlePrompt = useCallback( async (submission: PromptSubmission) => { if (submission.command === "exit") { + const activeSessionId = sessionManager.getActiveSessionId(); + const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; + setExitSession(session); setIsExiting(true); - setTimeout(() => { - const activeSessionId = sessionManager.getActiveSessionId(); - const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; - const summary = buildExitSummaryText({ session }); - const tc = getCurrentThemedChalk(); - process.stdout.write("\n"); - process.stdout.write(tc.primary("> /exit ")); - process.stdout.write("\n\n"); - process.stdout.write(summary); - process.stdout.write("\n\n"); - sessionManager.dispose(); - exit(); - }, 0); return; } if (submission.command === "new") { - if (onRestart) { - onRestart(); - } else { - await resetToWelcome(); - refreshSessionsList(); - } + // if (onRestart) { + // onRestart(); + // } + await resetToWelcome(); + refreshSessionsList(); return; } if (submission.command === "resume") { @@ -349,16 +358,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl setRunningProcesses(null); } }, - [ - sessionManager, - pendingPermissionReply, - exit, - onRestart, - refreshSkills, - refreshSessionsList, - navigateToSubView, - resetToWelcome, - ] + [sessionManager, pendingPermissionReply, refreshSkills, refreshSessionsList, navigateToSubView, resetToWelcome] ); const handleInterrupt = useCallback(() => { @@ -431,7 +431,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl const reloadActiveSessionView = useCallback( (sessionId: string): void => { - resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); + resetStaticView(loadVisibleMessages(sessionManager, sessionId)); }, [resetStaticView, sessionManager] ); @@ -452,8 +452,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl const handleSelectSession = useCallback( async (sessionId: string) => { sessionManager.setActiveSessionId(sessionId); - // Clear first so resets its index to 0. - resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); + resetStaticView(loadVisibleMessages(sessionManager, sessionId)); const session = sessionManager.getSession(sessionId); setStatusLine(session ? buildStatusLine(session) : ""); setRunningProcesses(session?.processes ?? null); @@ -697,7 +696,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl return ( - + {(item) => { if (item.id.startsWith("__welcome__")) { return ( @@ -719,7 +718,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl /> ); }} - + {statusLine ? ( {statusLine} @@ -782,7 +781,9 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl onSubmit={handlePermissionResult} onCancel={handlePermissionCancel} /> - ) : isExiting ? null : ( + ) : isExiting ? ( + + ) : ( )} diff --git a/src/ui/views/AppContainer.tsx b/src/ui/views/AppContainer.tsx index 159a77b..c3d7197 100644 --- a/src/ui/views/AppContainer.tsx +++ b/src/ui/views/AppContainer.tsx @@ -1,9 +1,10 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback, useMemo } from "react"; import { AppContext } from "../contexts"; import App from "./App"; import { RawModeProvider } from "../contexts"; -import { ThemeProvider, setCurrentTheme } from "../theme"; -import { resolveCurrentSettings } from "../../settings"; +import { ThemeProvider, setCurrentTheme, resolveTheme } from "../theme"; +import { resolveCurrentSettings, readSettings, readProjectSettings, writeSettings } from "../../settings"; +import type { ThemeTokens, ThemePreset, ThemeSettings } from "../theme"; const AppContainer: React.FC<{ projectRoot: string; @@ -12,15 +13,84 @@ const AppContainer: React.FC<{ onRestart: () => void; }> = ({ version, projectRoot, initialPrompt, onRestart }) => { const settings = resolveCurrentSettings(projectRoot); - const [theme] = useState(settings.theme); + const [theme, setTheme] = useState(settings.theme); + const [currentPreset, setCurrentPreset] = useState(() => { + const userSettings = readSettings(); + const projectSettings = readProjectSettings(projectRoot); + return (userSettings?.theme?.preset ?? projectSettings?.theme?.preset ?? "light") as ThemePreset; + }); + const [themeVersion, setThemeVersion] = useState(0); + + // 检查是否有 custom 主题配置 + const hasCustomThemeConfig = useMemo(() => { + const userSettings = readSettings(); + const projectSettings = readProjectSettings(projectRoot); + const themeSettings = userSettings?.theme ?? projectSettings?.theme; + return themeSettings?.preset === "custom" && !!(themeSettings?.overrides || themeSettings?.tokens); + }, [projectRoot]); useEffect(() => { // 初始设置全局 chalk 主题 setCurrentTheme(theme); }, [theme]); + /** 应用主题到 UI(不持久化) */ + const applyThemeToUI = useCallback((newTheme: ThemeTokens) => { + setTheme(newTheme); + setCurrentTheme(newTheme); + setThemeVersion((v) => v + 1); + }, []); + + /** 预览主题:仅切换 UI,不保存到 settings,不更新 currentPreset */ + const previewTheme = useCallback( + (presetOrTokens: string | Partial) => { + const newTheme = resolveTheme( + typeof presetOrTokens === "string" + ? { preset: presetOrTokens as ThemePreset } + : { preset: "custom", overrides: presetOrTokens } + ); + applyThemeToUI(newTheme); + }, + [applyThemeToUI] + ); + + /** 切换主题并持久化到 settings.json */ + const switchTheme = useCallback( + (presetOrTokens: string | Partial) => { + const preset: ThemePreset = typeof presetOrTokens === "string" ? (presetOrTokens as ThemePreset) : "custom"; + const newTheme = resolveTheme( + typeof presetOrTokens === "string" + ? { preset: presetOrTokens as ThemePreset } + : { preset: "custom", overrides: presetOrTokens } + ); + + setCurrentPreset(preset); + applyThemeToUI(newTheme); + + // 持久化到 settings.json + const currentSettings = readSettings() ?? {}; + const newThemeSettings: ThemeSettings = { + preset, + ...(typeof presetOrTokens !== "string" ? { overrides: presetOrTokens } : {}), + }; + writeSettings({ ...currentSettings, theme: newThemeSettings }); + }, + [applyThemeToUI] + ); + + /** 回退到 settings 中已保存的主题 */ + const revertTheme = useCallback(() => { + const savedSettings = resolveCurrentSettings(projectRoot); + const userSettings = readSettings(); + const projectSettings = readProjectSettings(projectRoot); + setCurrentPreset((userSettings?.theme?.preset ?? projectSettings?.theme?.preset ?? "light") as ThemePreset); + applyThemeToUI(savedSettings.theme); + }, [projectRoot, applyThemeToUI]); + return ( - + diff --git a/src/ui/views/ExitSummaryView.tsx b/src/ui/views/ExitSummaryView.tsx new file mode 100644 index 0000000..6c9d299 --- /dev/null +++ b/src/ui/views/ExitSummaryView.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { Box, Text } from "ink"; +import gradientString from "gradient-string"; +import type { SessionEntry } from "../../session"; +import { useTheme } from "../theme"; +import { buildExitSummaryData } from "../exit-summary"; + +type Props = { + session: SessionEntry | null; + width: number; +}; + +function formatNumber(n: number): string { + return n.toLocaleString("en-US"); +} + +const COL_MODEL = 34; +const COL_REQS = 8; +const COL_INPUT = 16; +const COL_OUTPUT = 16; +const COL_CACHED = 18; + +export default function ExitSummaryView({ session }: Props): React.ReactElement { + const theme = useTheme(); + const data = buildExitSummaryData({ session }); + const gradient = gradientString(...theme.gradients); + + return ( + + {/* Goodbye! header */} + + {gradient("Goodbye!")} + + + {/* Usage table */} + {data.hasUsage && ( + <> + {/* Table header */} + + + Model Usage + + + Reqs + + + Input Tokens + + + Output Tokens + + + Cached Tokens + + + {/* Data rows */} + {data.rows.map((row) => ( + + + {row.modelName} + + + {formatNumber(row.reqs)} + + + {formatNumber(row.inputTokens)} + + + {formatNumber(row.outputTokens)} + + + {formatNumber(row.cachedTokens)} + + + ))} + + )} + + ); +} diff --git a/src/ui/views/PermissionPrompt.tsx b/src/ui/views/PermissionPrompt.tsx index a4dfa09..a16662c 100644 --- a/src/ui/views/PermissionPrompt.tsx +++ b/src/ui/views/PermissionPrompt.tsx @@ -3,7 +3,7 @@ import { Box, Text } from "ink"; import { useTerminalInput } from "../hooks"; import type { AskPermissionRequest, AskPermissionScope, UserToolPermission } from "../../common/permissions"; import type { PermissionScope } from "../../settings"; -import { useTheme } from "../theme"; +import { useTheme, LIGHT_THEME } from "../theme"; import type { ThemeTokens } from "../theme"; export type PermissionPromptResult = { @@ -232,7 +232,7 @@ function isAlwaysAllowedScope(scope: AskPermissionScope): scope is PermissionSco } export function getScopeRiskColor(scope: AskPermissionScope, theme?: ThemeTokens): string { - const t = theme ?? ({ success: "#52c41a", warning: "#faad14", error: "#ff4d4f" } as ThemeTokens); + const t = theme ?? LIGHT_THEME; switch (scope) { case "read-in-cwd": case "query-git-log": diff --git a/src/ui/views/PromptInput.tsx b/src/ui/views/PromptInput.tsx index 3259c92..48c388e 100644 --- a/src/ui/views/PromptInput.tsx +++ b/src/ui/views/PromptInput.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; import { useTheme } from "../theme"; +import { useAppContext } from "../contexts"; import { ARGS_SEPARATOR } from "../constants"; import { EMPTY_BUFFER, @@ -54,9 +55,10 @@ import { } from "../hooks"; import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu"; import type { ModelConfigSelection, PermissionScope } from "../../settings"; -import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "../components"; +import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown, ThemeDropdown } from "../components"; import type { SessionEntry, SkillInfo } from "../../session"; import type { UserToolPermission } from "../../common/permissions"; +import type { ThemePreset } from "../theme"; export type PromptSubmission = { text: string; @@ -85,11 +87,14 @@ type Props = { placeholder?: string; runningProcesses?: SessionEntry["processes"]; promptDraft?: PromptDraft | null; + currentPreset: ThemePreset; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onRawModeChange?: (mode: string) => void; onInterrupt: () => void; onToggleProcessStdout?: () => void; + onThemePreview?: (preset: ThemePreset) => void; + onThemeRevert?: () => void; }; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -125,15 +130,19 @@ export const PromptInput = React.memo(function PromptInput({ placeholder, runningProcesses, promptDraft, + currentPreset, onSubmit, onModelConfigChange, onInterrupt, onToggleProcessStdout, onRawModeChange, + onThemePreview, + onThemeRevert, }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); const theme = useTheme(); + const { switchTheme, hasCustomThemeConfig } = useAppContext(); const [buffer, setBuffer] = useState(EMPTY_BUFFER); const [imageUrls, setImageUrls] = useState([]); const [selectedSkills, setSelectedSkills] = useState([]); @@ -143,6 +152,7 @@ export const PromptInput = React.memo(function PromptInput({ const [showSkillsDropdown, setShowSkillsDropdown] = useState(false); const [openRawModelDropdown, setOpenRawModelDropdown] = useState(false); const [showModelDropdown, setShowModelDropdown] = useState(false); + const [showThemeDropdown, setShowThemeDropdown] = useState(false); const [fileMentionItems, setFileMentionItems] = useState(() => scanFileMentionItems(projectRoot)); const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null); const [hasTerminalFocus, setHasTerminalFocus] = useState(true); @@ -171,18 +181,19 @@ export const PromptInput = React.memo(function PromptInput({ const showFileMentionMenu = !showSkillsDropdown && !showModelDropdown && + !showThemeDropdown && fileMentionToken !== null && fileMentionKey !== dismissedFileMentionKey; const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); const slashMenu = React.useMemo( () => - showSkillsDropdown || showModelDropdown || showFileMentionMenu + showSkillsDropdown || showModelDropdown || showThemeDropdown || showFileMentionMenu ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : [], - [showSkillsDropdown, showModelDropdown, showFileMentionMenu, slashToken, slashItems] + [showSkillsDropdown, showModelDropdown, showThemeDropdown, showFileMentionMenu, slashToken, slashItems] ); const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); @@ -340,7 +351,7 @@ export const PromptInput = React.memo(function PromptInput({ setPendingExit(false); } - if (openRawModelDropdown || showSkillsDropdown || showModelDropdown) { + if (openRawModelDropdown || showSkillsDropdown || showModelDropdown || showThemeDropdown) { return; } @@ -638,6 +649,12 @@ export const PromptInput = React.memo(function PromptInput({ setOpenRawModelDropdown(true); return; } + if (item.kind === "theme") { + clearSlashToken(); + setShowSkillsDropdown(false); + setShowThemeDropdown(true); + return; + } if (item.kind === "new") { onSubmit({ text: "", imageUrls: [], command: "new" }); resetPromptInput(); @@ -718,8 +735,14 @@ export const PromptInput = React.memo(function PromptInput({ } const showFooterText = useMemo( - () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu, - [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu] + () => + showMenu || + showSkillsDropdown || + openRawModelDropdown || + showModelDropdown || + showThemeDropdown || + showFileMentionMenu, + [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showThemeDropdown, showFileMentionMenu] ); const isFocused = useMemo(() => !disabled && hasTerminalFocus, [disabled, hasTerminalFocus]); @@ -778,6 +801,17 @@ export const PromptInput = React.memo(function PromptInput({ onModelConfigChange={onModelConfigChange} onStatusMessage={setStatusMessage} /> + setShowThemeDropdown(false)} + onThemeChange={(preset: ThemePreset) => switchTheme?.(preset)} + onThemePreview={onThemePreview} + onThemeRevert={onThemeRevert} + onStatusMessage={setStatusMessage} + /> Date: Sat, 30 May 2026 13:31:11 +0800 Subject: [PATCH 10/19] =?UTF-8?q?docs(config):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2=E8=AF=B4=E6=98=8E=E4=B8=8E?= =?UTF-8?q?README=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 详细说明运行时使用 `/theme` 命令打开主题选择器的交互方式 - 更新主题切换保存机制说明,支持实时预览及取消恢复 - README添加多套预设主题介绍及默认主题调整为浅色主题 - 说明 `theme.preset` 可切换预设主题或完全自定义主题,包含配置示例 - 补充新增亮色文字 `textBright` 颜色Token及其默认值 - 修改类型定义,明确预设主题可选值示例及“custom”含义 - 修改代码块语言标签颜色显示方式,增强可读性 --- README-en.md | 26 +++++++++++++++++------ README-zh_CN.md | 26 +++++++++++++++++------ README.md | 26 +++++++++++++++++------ docs/configuration.md | 11 +++------- docs/configuration_en.md | 11 +++------- src/ui/components/MessageView/markdown.ts | 2 +- src/ui/theme/types.ts | 2 +- 7 files changed, 68 insertions(+), 36 deletions(-) diff --git a/README-en.md b/README-en.md index 2367fc3..c04938e 100644 --- a/README-en.md +++ b/README-en.md @@ -143,11 +143,23 @@ No. Deep Code has a built-in fine-grained permission control mechanism that lets ### How do I customize the theme? -Deep Code CLI includes a built-in default theme (`DEFAULT_THEME`) that works out of the box. To customize colors, set `theme.preset` to `"custom"` in `settings.json` and provide `overrides` or `tokens`. +Deep Code CLI includes multiple built-in preset themes, defaulting to the light theme (`light`). You can switch presets by setting `theme.preset` in `settings.json`, or set it to `"custom"` for full customization. -**Using the default theme (no config required)** +**Using preset themes** -No settings needed — the built-in theme is used automatically. +Set `theme.preset` in `settings.json` to switch: + +```json +{ + "theme": { + "preset": "dark" + } +} +``` + +Available presets: `light` (default), `dark`, `github-light`, `github-dark`, `gitlab-light`, `gitlab-dark`, `monokai`, `dracula`. + +You can also use the `/theme` command at runtime to open the theme picker with live preview. **Option 1: Partial overrides (preset="custom" + overrides)** @@ -167,7 +179,7 @@ Override only the colors you want to change; the rest keep their defaults: **Option 2: Full customization (preset="custom" + tokens)** -Provide a complete tokens object, merged on top of the default theme: +Provide a complete tokens object, merged on top of the light theme: ```json { @@ -182,6 +194,7 @@ Provide a complete tokens object, merged on top of the default theme: "info": "magenta", "text": "white", "textDim": "gray", + "textBright": "white", "code": "cyan", "border": "gray", "gradients": ["#229ac3e6", "#229ac3e6"] @@ -190,9 +203,9 @@ Provide a complete tokens object, merged on top of the default theme: } ``` -> Note: `overrides` and `tokens` only take effect when `preset` is set to `"custom"`. When `preset` is `"default"` or unset, the built-in default theme is always used. +> Note: `overrides` and `tokens` only take effect when `preset` is set to `"custom"`. When `preset` is unset, the `light` theme is used by default. -Default theme color values (`DEFAULT_THEME`): +Default light theme (`light`) color values: | Token | Default | Used For | |-------|---------|----------| @@ -204,6 +217,7 @@ Default theme color values (`DEFAULT_THEME`): | `info` | `#0969da` | Info: skill loading tips, image attachment status | | `text` | `#3D4149` | Body text: permission prompt text, question text, ProcessStdout title | | `textDim` | `#646A71` | Secondary text: status line params, search placeholder, diff context, Markdown blockquotes | +| `textBright` | `#1F2329` | Bright text: emphasized hints | | `code` | `#787f8a` | Code blocks and inline code | | `border` | `#999` | All component borders | | `gradients` | `["#229ac3", "#8250df"]` | Logo and exit panel gradient colors | diff --git a/README-zh_CN.md b/README-zh_CN.md index 1203113..76886f8 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -143,11 +143,23 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 ### 如何自定义主题? -Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可使用。如需自定义颜色,在 `settings.json` 中设置 `theme.preset` 为 `"custom"` 后提供 `overrides` 或 `tokens`。 +Deep Code CLI 内置多套预设主题,默认使用浅色主题(`light`)。可在 `settings.json` 中设置 `theme.preset` 切换预设,或设为 `"custom"` 自定义颜色。 -**使用默认主题(无需配置)** +**使用预设主题** -直接使用内置主题,不做任何设置。 +在 `settings.json` 中设置 `theme.preset` 即可切换: + +```json +{ + "theme": { + "preset": "dark" + } +} +``` + +可用预设:`light`(默认)、`dark`、`github-light`、`github-dark`、`gitlab-light`、`gitlab-dark`、`monokai`、`dracula`。 + +也可在运行时使用 `/theme` 命令打开主题选择器,实时预览并切换。 **方式一:局部覆盖(preset="custom" + overrides)** @@ -167,7 +179,7 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 **方式二:完全自定义(preset="custom" + tokens)** -提供完整的 tokens 对象,基于默认主题合并: +提供完整的 tokens 对象,基于浅色主题合并: ```json { @@ -182,6 +194,7 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 "info": "magenta", "text": "white", "textDim": "gray", + "textBright": "white", "code": "cyan", "border": "gray", "gradients": ["#229ac3e6", "#229ac3e6"] @@ -190,9 +203,9 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 } ``` -> 注意:`preset` 必须设为 `"custom"` 时 `overrides` 和 `tokens` 才会生效。`preset` 为 `"default"` 或不配置时始终使用系统默认主题。 +> 注意:`overrides` 和 `tokens` 仅在 `preset` 为 `"custom"` 时生效。不配置 `preset` 时默认使用 `light` 主题。 -默认主题色值(`DEFAULT_THEME`): +默认浅色主题(`light`)色值: | Token | 默认值 | 用途 | |-------|--------|------| @@ -204,6 +217,7 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 | `info` | `#0969da` | 特殊指示:技能加载提示、图片附件状态 | | `text` | `#3D4149` | 主文字色:权限提示正文、问题文字、ProcessStdout 标题 | | `textDim` | `#646A71` | 次要文字:状态行参数、搜索占位符、diff 上下文行、Markdown 引用块 | +| `textBright` | `#1F2329` | 亮色文字:强调提示 | | `code` | `#787f8a` | 代码块/内联代码 | | `border` | `#999` | 所有组件的边框色 | | `gradients` | `["#229ac3", "#8250df"]` | Logo 与退出面板的渐变色数组 | diff --git a/README.md b/README.md index 1203113..76886f8 100644 --- a/README.md +++ b/README.md @@ -143,11 +143,23 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 ### 如何自定义主题? -Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可使用。如需自定义颜色,在 `settings.json` 中设置 `theme.preset` 为 `"custom"` 后提供 `overrides` 或 `tokens`。 +Deep Code CLI 内置多套预设主题,默认使用浅色主题(`light`)。可在 `settings.json` 中设置 `theme.preset` 切换预设,或设为 `"custom"` 自定义颜色。 -**使用默认主题(无需配置)** +**使用预设主题** -直接使用内置主题,不做任何设置。 +在 `settings.json` 中设置 `theme.preset` 即可切换: + +```json +{ + "theme": { + "preset": "dark" + } +} +``` + +可用预设:`light`(默认)、`dark`、`github-light`、`github-dark`、`gitlab-light`、`gitlab-dark`、`monokai`、`dracula`。 + +也可在运行时使用 `/theme` 命令打开主题选择器,实时预览并切换。 **方式一:局部覆盖(preset="custom" + overrides)** @@ -167,7 +179,7 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 **方式二:完全自定义(preset="custom" + tokens)** -提供完整的 tokens 对象,基于默认主题合并: +提供完整的 tokens 对象,基于浅色主题合并: ```json { @@ -182,6 +194,7 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 "info": "magenta", "text": "white", "textDim": "gray", + "textBright": "white", "code": "cyan", "border": "gray", "gradients": ["#229ac3e6", "#229ac3e6"] @@ -190,9 +203,9 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 } ``` -> 注意:`preset` 必须设为 `"custom"` 时 `overrides` 和 `tokens` 才会生效。`preset` 为 `"default"` 或不配置时始终使用系统默认主题。 +> 注意:`overrides` 和 `tokens` 仅在 `preset` 为 `"custom"` 时生效。不配置 `preset` 时默认使用 `light` 主题。 -默认主题色值(`DEFAULT_THEME`): +默认浅色主题(`light`)色值: | Token | 默认值 | 用途 | |-------|--------|------| @@ -204,6 +217,7 @@ Deep Code CLI 内置一套默认主题(`DEFAULT_THEME`),无需配置即可 | `info` | `#0969da` | 特殊指示:技能加载提示、图片附件状态 | | `text` | `#3D4149` | 主文字色:权限提示正文、问题文字、ProcessStdout 标题 | | `textDim` | `#646A71` | 次要文字:状态行参数、搜索占位符、diff 上下文行、Markdown 引用块 | +| `textBright` | `#1F2329` | 亮色文字:强调提示 | | `code` | `#787f8a` | 代码块/内联代码 | | `border` | `#999` | 所有组件的边框色 | | `gradients` | `["#229ac3", "#8250df"]` | Logo 与退出面板的渐变色数组 | diff --git a/docs/configuration.md b/docs/configuration.md index 4811a21..bece68f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -194,18 +194,13 @@ Deep Code 支持自定义主题颜色,让你的终端界面更符合个人喜 **运行时切换主题** -在 CLI 中使用 `/theme` 命令可以快速切换预设主题: +在 CLI 中使用 `/theme` 命令打开主题选择器,使用方向键浏览主题,按 Space 或 Enter 确认选择: ``` -/theme # 显示主题选择器 -/theme dark # 切换到暗色主题 -/theme light # 切换回浅色主题 -/theme github-dark # 切换到 GitHub Dark 主题 -/theme gitlab-light # 切换到 GitLab Light 主题 -/theme monokai # 切换到 Monokai 主题 +/theme # 打开主题选择器 ``` -切换后会自动保存到 `settings.json`,下次启动时生效。 +选择器中可用的主题与上表一致。在浏览过程中会实时预览主题效果,按 Esc 可取消并恢复原主题。确认后会自动保存到 `settings.json`,下次启动时生效。 #### `debugLogEnabled` — 调试日志 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index da6d11a..77f219f 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -193,18 +193,13 @@ Color values support the following formats: **Runtime Theme Switching** -Use the `/theme` command in the CLI to quickly switch preset themes: +Use the `/theme` command in the CLI to open the theme picker. Browse with arrow keys and confirm with Space or Enter: ``` -/theme # Show theme picker -/theme dark # Switch to dark theme -/theme light # Switch back to light theme -/theme github-dark # Switch to GitHub Dark theme -/theme gitlab-light # Switch to GitLab Light theme -/theme monokai # Switch to Monokai theme +/theme # Open theme picker ``` -The switch is automatically saved to `settings.json` and will take effect on the next launch. +Available themes in the picker match the table above. The theme is previewed in real-time as you browse. Press Esc to cancel and revert to the original theme. Once confirmed, the selection is automatically saved to `settings.json` and will take effect on the next launch. #### `debugLogEnabled` — Debug Log diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index a3e8092..82ebc97 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -37,7 +37,7 @@ export function renderMarkdownSegments(text: string, maxWidth?: number): Markdow for (const seg of fenceSegments) { if (seg.kind === "code") { const tc = getCurrentThemedChalk(); - const langTag = seg.lang ? tc.dim(`[${seg.lang}]`) + "\n" : ""; + const langTag = seg.lang ? tc.textBright(`[${seg.lang}]`) + "\n" : ""; segments.push({ kind: "code", body: langTag + tc.code(seg.body), lang: seg.lang }); continue; } diff --git a/src/ui/theme/types.ts b/src/ui/theme/types.ts index d15f943..0fbaa7e 100644 --- a/src/ui/theme/types.ts +++ b/src/ui/theme/types.ts @@ -47,7 +47,7 @@ export type ThemePreset = /** 主题配置(用户可配置部分) */ export type ThemeSettings = { - /** 选择预设主题:"default" 使用系统默认,"custom" 使用用户自定义 */ + /** 选择预设主题,如 "light"、"dark" 等;"custom" 使用用户自定义 */ preset?: ThemePreset; /** 覆盖部分 token(仅 preset="custom" 时生效) */ overrides?: Partial; From ad855d9a5d16ae688e59ad6fee9b4c65186b0d2d Mon Sep 17 00:00:00 2001 From: hcyang Date: Sat, 30 May 2026 22:09:57 +0800 Subject: [PATCH 11/19] =?UTF-8?q?feat(cli):=20=E6=B7=BB=E5=8A=A0=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E6=9B=B4=E6=8D=A2=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在命令列表中增加了 /theme 命令 - 支持用户通过命令行更换界面主题 - 提升了用户自定义界面的灵活性和体验 --- src/cli.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli.tsx b/src/cli.tsx index 87fb9fb..5c55de8 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -42,6 +42,7 @@ if (args.includes("--help") || args.includes("-h")) { " esc Interrupt the current model turn", " / Open the skills/commands menu", " /skills List available skills", + " /theme Change the theme", " /model Select model, thinking mode and effort control", " /new Start a fresh conversation", " /init Initialize an AGENTS.md file with instructions for LLM", From 34acff4bdf73d8fe69c1e9ad933b7ea0d3aeb5eb Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 1 Jun 2026 09:38:22 +0800 Subject: [PATCH 12/19] =?UTF-8?q?feat(ui):=20=E5=AE=8C=E5=96=84=E9=87=8D?= =?UTF-8?q?=E5=90=AF=E5=92=8C=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=8F=8A=E7=95=8C=E9=9D=A2=E5=85=83=E7=B4=A0=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 App 组件中新增 onRestart 支持,区别处理生产环境和测试环境重启逻辑 - 修改 CLI 逻辑,实现 Ink 实例销毁后再清屏和启动应用,避免闪烁 - 在会话界面选择和视图重载时加入清屏控制,提升用户体验 - 调整 MessageView 组件中 Thinking 状态的颜色风格,增强主题一致性 - 修改状态参数文字颜色为正常文本色,提高可读性 - 在 README 文档各语言版本中新增 /theme 命令说明,支持主题选择功能 --- README-en.md | 1 + README-zh_CN.md | 1 + README.md | 1 + src/cli.tsx | 9 ++++++--- src/ui/components/MessageView/index.tsx | 6 +++--- src/ui/views/App.tsx | 27 ++++++++++++++++++------- 6 files changed, 32 insertions(+), 13 deletions(-) diff --git a/README-en.md b/README-en.md index c04938e..683e24a 100644 --- a/README-en.md +++ b/README-en.md @@ -71,6 +71,7 @@ Deep Code CLI supports agent skills that allow you to extend the assistant's cap | `/resume` | Choose a previous conversation to continue | | `/continue` | Continue the active conversation or pick one to resume | | `/model` | Switch model, thinking mode, and reasoning effort | +| `/theme` | Open theme picker with live preview | | `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) | | `/init` | Initialize an AGENTS.md file (LLM project instructions) | | `/skills` | List available skills | diff --git a/README-zh_CN.md b/README-zh_CN.md index 76886f8..9beca95 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -70,6 +70,7 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: | `/resume` | 选择历史对话继续 | | `/continue` | 继续当前对话,或选择历史对话恢复 | | `/model` | 切换模型、思考模式和推理强度 | +| `/theme` | 打开主题选择器,实时预览并切换主题 | | `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | | `/init` | 初始化 AGENTS.md 文件 | | `/skills` | 列出可用 skills | diff --git a/README.md b/README.md index 76886f8..9beca95 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: | `/resume` | 选择历史对话继续 | | `/continue` | 继续当前对话,或选择历史对话恢复 | | `/model` | 切换模型、思考模式和推理强度 | +| `/theme` | 打开主题选择器,实时预览并切换主题 | | `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | | `/init` | 初始化 AGENTS.md 文件 | | `/skills` | 列出可用 skills | diff --git a/src/cli.tsx b/src/cli.tsx index 5c55de8..65e8843 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -44,7 +44,7 @@ if (args.includes("--help") || args.includes("-h")) { " /skills List available skills", " /theme Change the theme", " /model Select model, thinking mode and effort control", - " /new Start a fresh conversation", + " /new Start a new session (previous session resumable with /resume)", " /init Initialize an AGENTS.md file with instructions for LLM", " /resume Pick a previous conversation to continue", " /continue Continue the active conversation, or resume one if empty", @@ -98,9 +98,12 @@ async function main(): Promise { restartRef.current = () => { restarting = true; - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); inkInstance.unmount(); - startApp(); + // waitUntilExit 在 Ink 完成终端清理后 resolve,比 setTimeout 更精确。 + void inkInstance.waitUntilExit().then(() => { + process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); + startApp(); + }); }; inkInstance.waitUntilExit().then(() => { diff --git a/src/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx index 40970f1..bf7e877 100644 --- a/src/ui/components/MessageView/index.tsx +++ b/src/ui/components/MessageView/index.tsx @@ -46,13 +46,13 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps if (collapsed !== false) { return ( - + ); } return ( - + {content ? {renderMarkdown(content)} : null} @@ -174,7 +174,7 @@ function StatusLine({ {name} {params ? ( - + {` ${params}`} ) : null} diff --git a/src/ui/views/App.tsx b/src/ui/views/App.tsx index ae3f2d1..b028264 100644 --- a/src/ui/views/App.tsx +++ b/src/ui/views/App.tsx @@ -55,7 +55,7 @@ type AppProps = { onRestart?: () => void; }; -function App({ projectRoot, initialPrompt }: AppProps): React.ReactElement { +function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); const { columns, rows } = useWindowSize(); @@ -277,11 +277,14 @@ function App({ projectRoot, initialPrompt }: AppProps): React.ReactElement { return; } if (submission.command === "new") { - // if (onRestart) { - // onRestart(); - // } - await resetToWelcome(); - refreshSessionsList(); + if (onRestart) { + // 生产环境:完全销毁重建 Ink 实例,清屏最可靠 + onRestart(); + } else { + // 测试环境:在同一实例内重置状态 + await resetToWelcome(); + refreshSessionsList(); + } return; } if (submission.command === "resume") { @@ -358,7 +361,15 @@ function App({ projectRoot, initialPrompt }: AppProps): React.ReactElement { setRunningProcesses(null); } }, - [sessionManager, pendingPermissionReply, refreshSkills, refreshSessionsList, navigateToSubView, resetToWelcome] + [ + sessionManager, + pendingPermissionReply, + onRestart, + refreshSkills, + refreshSessionsList, + navigateToSubView, + resetToWelcome, + ] ); const handleInterrupt = useCallback(() => { @@ -431,6 +442,7 @@ function App({ projectRoot, initialPrompt }: AppProps): React.ReactElement { const reloadActiveSessionView = useCallback( (sessionId: string): void => { + process.stdout.write(ANSI_CLEAR_SCREEN); resetStaticView(loadVisibleMessages(sessionManager, sessionId)); }, [resetStaticView, sessionManager] @@ -452,6 +464,7 @@ function App({ projectRoot, initialPrompt }: AppProps): React.ReactElement { const handleSelectSession = useCallback( async (sessionId: string) => { sessionManager.setActiveSessionId(sessionId); + process.stdout.write(ANSI_CLEAR_SCREEN); resetStaticView(loadVisibleMessages(sessionManager, sessionId)); const session = sessionManager.getSession(sessionId); setStatusLine(session ? buildStatusLine(session) : ""); From c7b2a492ed0cfd99dcb5012f9399b541089fe98a Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 1 Jun 2026 21:49:06 +0800 Subject: [PATCH 13/19] =?UTF-8?q?refactor(theme):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E9=A2=9C=E8=89=B2=E4=B8=8E=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 终端输出相关 chalk 样式函数支持更多颜色分组 - 主题样式函数接口新增多种状态、风险、品牌等分类 - React 组件中统一使用 theme.status 和 theme.brand 下的颜色 - ExitSummaryView 和退出摘要相关样式颜色更新为更合理的边框和状态色 - DropdownMenu 和 FileMentionMenu 组件中选中、激活颜色改为 brand.accent - MessageView 组件中统一使用 brand.accent 和 status.info,提升语义明确性 - 文档更新,新增 ANSI 16色主题,调整自定义主题颜色配置示例 - 主题导出增加 ColorsTheme 类型和 buildThemeTokens 函数 - 优化配置文档中颜色 Token 结构,提供详细分组说明和新版示例配置 --- README-en.md | 2 +- README-zh_CN.md | 2 +- README.md | 2 +- docs/configuration.md | 92 +++- docs/configuration_en.md | 92 +++- src/common/update-check.ts | 2 +- src/tests/permission-prompt.test.ts | 22 +- src/tests/theme.test.ts | 279 ++++++------ src/ui/components/DropdownMenu/index.tsx | 22 +- src/ui/components/FileMentionMenu/index.tsx | 4 +- src/ui/components/MessageView/index.tsx | 65 ++- src/ui/components/MessageView/markdown.ts | 22 +- src/ui/components/MessageView/utils.ts | 4 +- src/ui/components/SkillsDropdown/index.tsx | 2 +- src/ui/components/ThemeDropdown/index.tsx | 8 +- src/ui/exit-summary.ts | 6 +- src/ui/theme/chalk-theme.ts | 481 +++++++++++++++++--- src/ui/theme/colors-theme.ts | 203 +++++++++ src/ui/theme/index.ts | 6 +- src/ui/theme/presets.ts | 285 +++++++----- src/ui/theme/resolver.ts | 19 +- src/ui/theme/types.ts | 296 ++++++++++-- src/ui/views/App.tsx | 2 +- src/ui/views/AskUserQuestionPrompt.tsx | 14 +- src/ui/views/ExitSummaryView.tsx | 12 +- src/ui/views/McpStatusList.tsx | 57 ++- src/ui/views/PermissionPrompt.tsx | 20 +- src/ui/views/ProcessStdoutView.tsx | 2 +- src/ui/views/PromptInput.tsx | 10 +- src/ui/views/SessionList.tsx | 26 +- src/ui/views/SlashCommandMenu.tsx | 4 +- src/ui/views/ThemedGradient.tsx | 4 +- src/ui/views/UndoSelector.tsx | 18 +- src/ui/views/UpdatePrompt.tsx | 4 +- src/ui/views/WelcomeScreen.tsx | 4 +- 35 files changed, 1552 insertions(+), 541 deletions(-) create mode 100644 src/ui/theme/colors-theme.ts diff --git a/README-en.md b/README-en.md index 683e24a..5d8cae1 100644 --- a/README-en.md +++ b/README-en.md @@ -158,7 +158,7 @@ Set `theme.preset` in `settings.json` to switch: } ``` -Available presets: `light` (default), `dark`, `github-light`, `github-dark`, `gitlab-light`, `gitlab-dark`, `monokai`, `dracula`. +Available presets: `light` (default), `dark`, `github-light`, `github-dark`, `monokai`, `dracula`, `ansi`. You can also use the `/theme` command at runtime to open the theme picker with live preview. diff --git a/README-zh_CN.md b/README-zh_CN.md index 9beca95..a19adac 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -158,7 +158,7 @@ Deep Code CLI 内置多套预设主题,默认使用浅色主题(`light`) } ``` -可用预设:`light`(默认)、`dark`、`github-light`、`github-dark`、`gitlab-light`、`gitlab-dark`、`monokai`、`dracula`。 +可用预设:`light`(默认)、`dark`、`github-light`、`github-dark`、`monokai`、`dracula`、`ansi`。 也可在运行时使用 `/theme` 命令打开主题选择器,实时预览并切换。 diff --git a/README.md b/README.md index 9beca95..a19adac 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Deep Code CLI 内置多套预设主题,默认使用浅色主题(`light`) } ``` -可用预设:`light`(默认)、`dark`、`github-light`、`github-dark`、`gitlab-light`、`gitlab-dark`、`monokai`、`dracula`。 +可用预设:`light`(默认)、`dark`、`github-light`、`github-dark`、`monokai`、`dracula`、`ansi`。 也可在运行时使用 `/theme` 命令打开主题选择器,实时预览并切换。 diff --git a/docs/configuration.md b/docs/configuration.md index bece68f..9d014ba 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -150,43 +150,93 @@ Deep Code 支持自定义主题颜色,让你的终端界面更符合个人喜 | `dark` | 暗色主题(深色背景优化) | | `github-light` | GitHub Light 风格主题 | | `github-dark` | GitHub Dark 风格主题 | -| `gitlab-light` | GitLab Light 风格主题 | -| `gitlab-dark` | GitLab Dark 风格主题 | | `monokai` | Monokai 风格主题 | | `dracula` | Dracula 风格主题 | +| `ansi-light` | ANSI 浅色主题(标准 16 色) | +| `ansi-dark` | ANSI 暗色主题(标准 16 色) | **自定义主题颜色** -使用 `preset: "custom"` 并通过 `overrides` 覆盖部分颜色: +推荐使用 `colors` 简化色板配置,只需定义 16 个基础色,系统自动推导完整主题: ```json { "theme": { "preset": "custom", + "colors": { + "Background": "#ffffff", + "Foreground": "#1F2328", + "Gray": "#8b949e", + "LightBlue": "#0969da", + "AccentBlue": "#ff6600", + "AccentPurple": "#8250df", + "AccentCyan": "#0550ae", + "AccentGreen": "#1a7f37", + "AccentYellow": "#fa8c16", + "AccentRed": "#d1242f", + "AccentYellowDim": "#9a6700", + "AccentRedDim": "#a40e26", + "DiffAdded": "#dafbe1", + "DiffRemoved": "#ffebe9", + "Comment": "#6e7781" + } + } +} +``` + +也可在 `colors` 基础上用 `overrides` 微调个别 token: + +```json +{ + "theme": { + "preset": "custom", + "colors": { "Background": "#1a1a2e", "Foreground": "#e0e0e0", "..." : "..." }, + "overrides": { + "agent": { "streaming": "#ffcc00" } + } + } +} +``` + +高级用法:通过 `base` 基于其他预设,用 `overrides` 微调: + +```json +{ + "theme": { + "preset": "custom", + "base": "dark", "overrides": { - "primary": "#ff6600", - "success": "greenBright" + "brand": { "primary": "#ff6600" } } } } ``` -**可用的颜色 Token** - -| Token | 说明 | 默认值 | -| ------------ | -------------------------------------------- | ---------- | -| `primary` | 品牌色:Logo、用户消息、选中项、标题 | `#229ac3` | -| `secondary` | 辅助品牌色:边框、渐变 | `#229ac3e6`| -| `success` | 成功:工具执行成功、低风险操作 | `#1a7f37` | -| `error` | 错误:工具执行失败、高风险操作 | `#d1242f` | -| `warning` | 警告:进行中状态、中风险操作 | `#fa8c16` | -| `info` | 信息:技能、图片附件 | `#0969da` | -| `text` | 主文字颜色 | `#3D4149` | -| `textDim` | 次要文字:暗化提示、引用块 | `#646A71` | -| `textBright` | 亮色文字:强调提示 | `#1F2329` | -| `code` | 代码块/内联代码 | `#787f8a` | -| `border` | 边框 | `#999` | -| `gradients` | Logo 渐变色数组 | `["#229ac3", "#8250df"]` | +**可用的颜色 Token 分组** + +| 分组 | 说明 | Token | +| ---- | ---- | ----- | +| `text` | 文字层级 | `primary`、`secondary`、`muted`、`disabled`、`inverse` | +| `border` | 边框层级 | `default`、`subtle`、`active`、`focus` | +| `surface` | 表面色 | `default`、`elevated`、`muted`、`code`、`panel`、`quote`、`selection` | +| `brand` | 品牌色 | `primary`、`secondary`、`accent` | +| `status` | 状态色 | `success`、`warning`、`danger`、`info` | +| `risk` | 风险色 | `low`、`medium`、`high`、`critical` | +| `typography` | 排版色 | `h1`-`h6`、`paragraph`、`strong`、`emphasis`、`delete` | +| `link` | 链接色 | `default`、`visited`、`hover` | +| `inlineCode` | 行内代码 | `foreground`、`background`、`border` | +| `codeBlock` | 代码块 | `foreground`、`background`、`border`、`title`、`lineNumber`、`highlight` | +| `syntax` | 语法高亮 | `keyword`、`string`、`function`、`variable`、`property`、`type`、`number`、`operator`、`punctuation`、`comment`、`regexp`、`constant` | +| `blockquote` | 引用块 | `foreground`、`border` | +| `list` | 列表 | `bullet`、`ordered`、`marker` | +| `task` | 任务列表 | `checked`、`unchecked` | +| `table` | 表格 | `border`、`headerForeground`、`headerBackground`、`cellForeground` | +| `hr` | 分割线 | `foreground` | +| `admonition` | 提示框 | `note`、`tip`、`warning`、`important`、`caution` | +| `diff` | Diff | `added`、`removed`、`modified`、`addedBackground`、`removedBackground`、`modifiedBackground` | +| `agent` | Agent 状态 | `thinking`、`reasoning`、`toolCall`、`toolResult`、`streaming`、`completed` | +| `approval` | 审批 | `allow`、`deny`、`review` | +| `gradients` | 渐变 | `banner`、`logo`、`thinking`(每个是颜色数组) | 颜色值支持以下格式: - Hex 格式:`"#ff6600"`、`"#ff6600cc"`(带透明度) diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 77f219f..75b4e76 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -149,43 +149,93 @@ Available preset themes: | `dark` | Dark theme (optimized for dark backgrounds) | | `github-light` | GitHub Light style theme | | `github-dark` | GitHub Dark style theme | -| `gitlab-light` | GitLab Light style theme | -| `gitlab-dark` | GitLab Dark style theme | | `monokai` | Monokai-style theme | | `dracula` | Dracula-style theme | +| `ansi-light` | ANSI light theme (standard 16 colors) | +| `ansi-dark` | ANSI dark theme (standard 16 colors) | **Custom Theme Colors** -Use `preset: "custom"` with `overrides` to customize specific colors: +The recommended way is to use `colors` — a simplified palette of 16 base colors. The system automatically derives the full theme: ```json { "theme": { "preset": "custom", + "colors": { + "Background": "#ffffff", + "Foreground": "#1F2328", + "Gray": "#8b949e", + "LightBlue": "#0969da", + "AccentBlue": "#ff6600", + "AccentPurple": "#8250df", + "AccentCyan": "#0550ae", + "AccentGreen": "#1a7f37", + "AccentYellow": "#fa8c16", + "AccentRed": "#d1242f", + "AccentYellowDim": "#9a6700", + "AccentRedDim": "#a40e26", + "DiffAdded": "#dafbe1", + "DiffRemoved": "#ffebe9", + "Comment": "#6e7781" + } + } +} +``` + +You can also combine `colors` with `overrides` to fine-tune specific tokens: + +```json +{ + "theme": { + "preset": "custom", + "colors": { "Background": "#1a1a2e", "Foreground": "#e0e0e0", "..." : "..." }, + "overrides": { + "agent": { "streaming": "#ffcc00" } + } + } +} +``` + +Advanced: use `base` to inherit from another preset, with `overrides` to tweak: + +```json +{ + "theme": { + "preset": "custom", + "base": "dark", "overrides": { - "primary": "#ff6600", - "success": "greenBright" + "brand": { "primary": "#ff6600" } } } } ``` -**Available Color Tokens** - -| Token | Description | Default Value | -| ------------ | ------------------------------------------------ | ------------- | -| `primary` | Brand color: logo, user messages, selected items, headings | `#229ac3` | -| `secondary` | Auxiliary brand color: borders, gradients | `#229ac3e6` | -| `success` | Success: tool execution success, low-risk ops | `#1a7f37` | -| `error` | Error: tool execution failure, high-risk ops | `#d1242f` | -| `warning` | Warning: in-progress state, mid-risk ops | `#fa8c16` | -| `info` | Info: skills, image attachments | `#0969da` | -| `text` | Main text color | `#3D4149` | -| `textDim` | Secondary text: dimmed hints, quote blocks | `#646A71` | -| `textBright` | Bright text: emphasized hints | `#1F2329` | -| `code` | Code blocks / inline code | `#787f8a` | -| `border` | Borders | `#999` | -| `gradients` | Logo gradient color array | `["#229ac3", "#8250df"]` | +**Available Color Token Groups** + +| Group | Description | Tokens | +| ----- | ----------- | ------ | +| `text` | Text hierarchy | `primary`, `secondary`, `muted`, `disabled`, `inverse` | +| `border` | Border hierarchy | `default`, `subtle`, `active`, `focus` | +| `surface` | Surface colors | `default`, `elevated`, `muted`, `code`, `panel`, `quote`, `selection` | +| `brand` | Brand colors | `primary`, `secondary`, `accent` | +| `status` | Status colors | `success`, `warning`, `danger`, `info` | +| `risk` | Risk levels | `low`, `medium`, `high`, `critical` | +| `typography` | Typography colors | `h1`-`h6`, `paragraph`, `strong`, `emphasis`, `delete` | +| `link` | Link colors | `default`, `visited`, `hover` | +| `inlineCode` | Inline code | `foreground`, `background`, `border` | +| `codeBlock` | Code blocks | `foreground`, `background`, `border`, `title`, `lineNumber`, `highlight` | +| `syntax` | Syntax highlighting | `keyword`, `string`, `function`, `variable`, `property`, `type`, `number`, `operator`, `punctuation`, `comment`, `regexp`, `constant` | +| `blockquote` | Blockquotes | `foreground`, `border` | +| `list` | Lists | `bullet`, `ordered`, `marker` | +| `task` | Task lists | `checked`, `unchecked` | +| `table` | Tables | `border`, `headerForeground`, `headerBackground`, `cellForeground` | +| `hr` | Horizontal rules | `foreground` | +| `admonition` | Admonitions | `note`, `tip`, `warning`, `important`, `caution` | +| `diff` | Diff | `added`, `removed`, `modified`, `addedBackground`, `removedBackground`, `modifiedBackground` | +| `agent` | Agent states | `thinking`, `reasoning`, `toolCall`, `toolResult`, `streaming`, `completed` | +| `approval` | Approval | `allow`, `deny`, `review` | +| `gradients` | Gradients | `banner`, `logo`, `thinking` (each is a color array) | Color values support the following formats: - Hex format: `"#ff6600"`, `"#ff6600cc"` (with alpha) diff --git a/src/common/update-check.ts b/src/common/update-check.ts index 5ab3efc..4c97d8e 100644 --- a/src/common/update-check.ts +++ b/src/common/update-check.ts @@ -59,7 +59,7 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< if (ok) { writeUpdateState({ ...state, pending: null }); process.stdout.write( - `\n${chalk.hex(LIGHT_THEME.error)("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n` + `\n${chalk.hex(LIGHT_THEME.status.danger)("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n` ); } return { installed: ok }; diff --git a/src/tests/permission-prompt.test.ts b/src/tests/permission-prompt.test.ts index c41b651..26ef1a4 100644 --- a/src/tests/permission-prompt.test.ts +++ b/src/tests/permission-prompt.test.ts @@ -4,17 +4,17 @@ import { getScopeRiskColor } from "../ui/views/PermissionPrompt"; import { LIGHT_THEME } from "../ui/theme"; test("getScopeRiskColor maps permission scopes by risk", () => { - assert.equal(getScopeRiskColor("read-in-cwd"), LIGHT_THEME.success); - assert.equal(getScopeRiskColor("query-git-log"), LIGHT_THEME.success); + assert.equal(getScopeRiskColor("read-in-cwd"), LIGHT_THEME.risk.low); + assert.equal(getScopeRiskColor("query-git-log"), LIGHT_THEME.risk.low); - assert.equal(getScopeRiskColor("read-out-cwd"), LIGHT_THEME.warning); - assert.equal(getScopeRiskColor("write-in-cwd"), LIGHT_THEME.warning); - assert.equal(getScopeRiskColor("network"), LIGHT_THEME.warning); - assert.equal(getScopeRiskColor("mcp"), LIGHT_THEME.warning); + assert.equal(getScopeRiskColor("read-out-cwd"), LIGHT_THEME.risk.medium); + assert.equal(getScopeRiskColor("write-in-cwd"), LIGHT_THEME.risk.medium); + assert.equal(getScopeRiskColor("network"), LIGHT_THEME.risk.medium); + assert.equal(getScopeRiskColor("mcp"), LIGHT_THEME.risk.medium); - assert.equal(getScopeRiskColor("write-out-cwd"), LIGHT_THEME.error); - assert.equal(getScopeRiskColor("delete-in-cwd"), LIGHT_THEME.error); - assert.equal(getScopeRiskColor("delete-out-cwd"), LIGHT_THEME.error); - assert.equal(getScopeRiskColor("mutate-git-log"), LIGHT_THEME.error); - assert.equal(getScopeRiskColor("unknown"), LIGHT_THEME.error); + assert.equal(getScopeRiskColor("write-out-cwd"), LIGHT_THEME.risk.high); + assert.equal(getScopeRiskColor("delete-in-cwd"), LIGHT_THEME.risk.high); + assert.equal(getScopeRiskColor("delete-out-cwd"), LIGHT_THEME.risk.high); + assert.equal(getScopeRiskColor("mutate-git-log"), LIGHT_THEME.risk.high); + assert.equal(getScopeRiskColor("unknown"), LIGHT_THEME.risk.critical); }); diff --git a/src/tests/theme.test.ts b/src/tests/theme.test.ts index eeccd0c..66cb41d 100644 --- a/src/tests/theme.test.ts +++ b/src/tests/theme.test.ts @@ -9,8 +9,6 @@ import { DRACULA_THEME, GITHUB_LIGHT_THEME, GITHUB_DARK_THEME, - GITLAB_LIGHT_THEME, - GITLAB_DARK_THEME, PRESETS, } from "../ui/theme"; import { resolveTheme } from "../ui/theme"; @@ -19,31 +17,10 @@ import { setCurrentTheme, getCurrentThemedChalk, getCurrentThemeTokens } from ". import { resolveSettingsSources } from "../settings"; import { getScopeRiskColor } from "../ui/views/PermissionPrompt"; -import type { ThemeTokens } from "../ui/theme"; +import type { ThemeTokens, ThemePreset } from "../ui/theme"; -// Force chalk to produce ANSI escapes even in non-TTY test environments. chalk.level = 1; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** All token keys that every ThemeTokens must define. */ -const REQUIRED_TOKEN_KEYS: Array = [ - "primary", - "secondary", - "success", - "error", - "warning", - "info", - "text", - "textDim", - "textBright", - "code", - "border", - "gradients", -]; - const DEFAULTS = { model: "test-model", baseURL: "https://test.example.com", @@ -53,29 +30,59 @@ const DEFAULTS = { // Presets // --------------------------------------------------------------------------- -test("LIGHT_THEME has all required token keys", () => { - for (const key of REQUIRED_TOKEN_KEYS) { - assert.ok(key in LIGHT_THEME, `LIGHT_THEME is missing key: ${key}`); +test("LIGHT_THEME has all required top-level groups", () => { + const groups = [ + "mode", + "text", + "border", + "surface", + "brand", + "status", + "risk", + "typography", + "link", + "inlineCode", + "codeBlock", + "syntax", + "blockquote", + "list", + "task", + "table", + "hr", + "admonition", + "diff", + "agent", + "approval", + "gradients", + ]; + for (const key of groups) { + assert.ok(key in LIGHT_THEME, `LIGHT_THEME is missing group: ${key}`); } }); -test("LIGHT_THEME primary matches expected brand color", () => { - assert.equal(LIGHT_THEME.primary, "#229ac3"); - assert.equal(LIGHT_THEME.secondary, "#229ac3e6"); +test("LIGHT_THEME brand colors match expected values", () => { + assert.equal(LIGHT_THEME.brand.primary, "#229ac3"); + assert.equal(LIGHT_THEME.brand.secondary, "#229ac3cc"); }); -test("LIGHT_THEME semantic colors match expected values", () => { - assert.equal(LIGHT_THEME.success, "#1a7f37"); - assert.equal(LIGHT_THEME.error, "#d1242f"); - assert.equal(LIGHT_THEME.warning, "#fa8c16"); - assert.equal(LIGHT_THEME.info, "#0969da"); +test("all presets have a name field", () => { + for (const [key, preset] of Object.entries(PRESETS)) { + assert.ok(typeof preset.name === "string" && preset.name.length > 0, `preset "${key}" missing name`); + } +}); + +test("LIGHT_THEME status colors match expected values", () => { + assert.equal(LIGHT_THEME.status.success, "#1a7f37"); + assert.equal(LIGHT_THEME.status.danger, "#d1242f"); + assert.equal(LIGHT_THEME.status.warning, "#fa8c16"); + assert.equal(LIGHT_THEME.status.info, "#0969da"); }); -test("LIGHT_THEME base colors match expected values", () => { - assert.equal(LIGHT_THEME.text, "#3D4149"); - assert.equal(LIGHT_THEME.textDim, "#646A71"); - assert.equal(LIGHT_THEME.textBright, "#1F2329"); - assert.equal(LIGHT_THEME.code, "#787f8a"); +test("LIGHT_THEME text colors match expected values", () => { + assert.equal(LIGHT_THEME.text.primary, "#1F2328"); + assert.equal(LIGHT_THEME.text.secondary, "#46484b"); + assert.equal(LIGHT_THEME.text.muted, "#8b949e"); + assert.equal(LIGHT_THEME.text.inverse, "#1F2328"); }); test("PRESETS map contains all presets", () => { @@ -85,8 +92,8 @@ test("PRESETS map contains all presets", () => { assert.ok("dracula" in PRESETS); assert.ok("github-light" in PRESETS); assert.ok("github-dark" in PRESETS); - assert.ok("gitlab-light" in PRESETS); - assert.ok("gitlab-dark" in PRESETS); + assert.ok("ansi-light" in PRESETS); + assert.ok("ansi-dark" in PRESETS); assert.equal(Object.keys(PRESETS).length, 8); assert.equal(PRESETS.light, LIGHT_THEME); assert.equal(PRESETS.dark, DARK_THEME); @@ -100,133 +107,119 @@ test("PRESETS map contains all presets", () => { test("resolveTheme returns LIGHT_THEME when settings is undefined", () => { const result = resolveTheme(undefined); - assert.equal(result.primary, LIGHT_THEME.primary); - assert.equal(result.success, LIGHT_THEME.success); + assert.equal(result.brand.primary, LIGHT_THEME.brand.primary); + assert.equal(result.status.success, LIGHT_THEME.status.success); }); test("resolveTheme returns LIGHT_THEME for explicit 'light' preset", () => { const result = resolveTheme({ preset: "light" }); - assert.equal(result.primary, LIGHT_THEME.primary); + assert.equal(result.brand.primary, LIGHT_THEME.brand.primary); }); test("resolveTheme returns DARK_THEME for 'dark' preset", () => { const result = resolveTheme({ preset: "dark" }); - assert.equal(result.primary, DARK_THEME.primary); - assert.equal(result.text, DARK_THEME.text); + assert.equal(result.brand.primary, DARK_THEME.brand.primary); + assert.equal(result.text.primary, DARK_THEME.text.primary); }); test("resolveTheme returns MONOKAI_THEME for 'monokai' preset", () => { const result = resolveTheme({ preset: "monokai" }); - assert.equal(result.primary, MONOKAI_THEME.primary); - assert.equal(result.text, MONOKAI_THEME.text); + assert.equal(result.brand.primary, MONOKAI_THEME.brand.primary); + assert.equal(result.text.primary, MONOKAI_THEME.text.primary); }); test("resolveTheme returns DRACULA_THEME for 'dracula' preset", () => { const result = resolveTheme({ preset: "dracula" }); - assert.equal(result.primary, DRACULA_THEME.primary); - assert.equal(result.text, DRACULA_THEME.text); + assert.equal(result.brand.primary, DRACULA_THEME.brand.primary); + assert.equal(result.text.primary, DRACULA_THEME.text.primary); }); test("resolveTheme applies overrides when preset is 'custom'", () => { const result = resolveTheme({ preset: "custom", - overrides: { primary: "#ff0000" }, + overrides: { brand: { primary: "#ff0000", secondary: "#ff0000", accent: "#ff0000" } }, }); - assert.equal(result.primary, "#ff0000"); - assert.equal(result.success, LIGHT_THEME.success); -}); - -test("resolveTheme applies multiple overrides with custom preset", () => { - const result = resolveTheme({ - preset: "custom", - overrides: { - primary: "#ff6600", - success: "greenBright", - warning: "yellowBright", - }, - }); - assert.equal(result.primary, "#ff6600"); - assert.equal(result.success, "greenBright"); - assert.equal(result.warning, "yellowBright"); - assert.equal(result.error, LIGHT_THEME.error); + assert.equal(result.brand.primary, "#ff0000"); + assert.equal(result.status.success, LIGHT_THEME.status.success); }); test("resolveTheme full custom tokens with custom preset", () => { - const customTokens: ThemeTokens = { - primary: "#aaaaaa", - secondary: "#aaaaaacc", - success: "blue", - error: "blue", - warning: "blue", - info: "blue", - text: "blue", - textDim: "blue", - textBright: "blue", - code: "blue", - border: "blue", - gradients: ["#aaaaaa", "#bbbbbb"], - }; + const customTokens = { ...LIGHT_THEME, brand: { primary: "#aaaaaa", secondary: "#aaaaaacc", accent: "#aaaaaa" } }; const result = resolveTheme({ preset: "custom", tokens: customTokens }); - assert.equal(result.primary, "#aaaaaa"); - assert.equal(result.code, "blue"); - assert.deepEqual(result.gradients, ["#aaaaaa", "#bbbbbb"]); + assert.equal(result.brand.primary, "#aaaaaa"); }); test("resolveTheme handles override with undefined fields gracefully", () => { const result = resolveTheme({ preset: "custom", - overrides: { primary: undefined, success: undefined } as Partial, + overrides: { brand: undefined } as Partial, }); - assert.equal(result.primary, LIGHT_THEME.primary); - assert.equal(result.success, LIGHT_THEME.success); + assert.equal(result.brand.primary, LIGHT_THEME.brand.primary); }); test("resolveTheme ignores overrides when preset is not custom", () => { const result = resolveTheme({ preset: "light", - overrides: { primary: "#ff0000" }, + overrides: { brand: { primary: "#ff0000", secondary: "#ff0000", accent: "#ff0000" } }, }); - assert.equal(result.primary, LIGHT_THEME.primary); + assert.equal(result.brand.primary, LIGHT_THEME.brand.primary); +}); + +test("resolveTheme returns LIGHT_THEME for custom preset without token/overrides", () => { + const result = resolveTheme({ preset: "custom" }); + assert.equal(result.brand.primary, LIGHT_THEME.brand.primary); }); -test("resolveTheme ignores tokens when preset is not custom", () => { +test("resolveTheme custom with base='dark' merges overrides onto DARK_THEME", () => { const result = resolveTheme({ - tokens: { primary: "#ff0000" } as ThemeTokens, + preset: "custom", + base: "dark", + overrides: { brand: { primary: "#ff0000", secondary: "#ff0000", accent: "#ff0000" } }, }); - assert.equal(result.primary, LIGHT_THEME.primary); + // brand.primary should be overridden + assert.equal(result.brand.primary, "#ff0000"); + // other tokens should come from DARK_THEME + assert.equal(result.mode, "dark"); + assert.equal(result.text.primary, DARK_THEME.text.primary); + assert.equal(result.status.success, DARK_THEME.status.success); }); -test("resolveTheme returns LIGHT_THEME for custom preset without token/overrides", () => { - const result = resolveTheme({ preset: "custom" }); - assert.equal(result.primary, LIGHT_THEME.primary); +test("resolveTheme custom with invalid base falls back to LIGHT_THEME", () => { + const result = resolveTheme({ + preset: "custom", + base: "nonexistent" as ThemePreset, + overrides: { brand: { primary: "#ff0000", secondary: "#ff0000", accent: "#ff0000" } }, + }); + assert.equal(result.brand.primary, "#ff0000"); + assert.equal(result.mode, "light"); // falls back to LIGHT_THEME }); // --------------------------------------------------------------------------- -// createThemedChalk — markdown 方法直接复用顶层 token +// createThemedChalk // --------------------------------------------------------------------------- -test("createThemedChalk heading1 produces styled output via primary", () => { +test("createThemedChalk heading1 produces styled output via typography.h1", () => { const tc = createThemedChalk(LIGHT_THEME); assert.notEqual(tc.heading1("Hello"), "Hello"); }); -test("createThemedChalk heading1 changes when primary changes", () => { - const custom: ThemeTokens = { ...LIGHT_THEME, primary: "#ff0000" }; +test("createThemedChalk heading1 changes when typography.h1 changes", () => { + const custom: ThemeTokens = { ...LIGHT_THEME, typography: { ...LIGHT_THEME.typography, h1: "#ff0000" } }; assert.notEqual(createThemedChalk(LIGHT_THEME).heading1("test"), createThemedChalk(custom).heading1("test")); }); -test("createThemedChalk inlineCode changes when code changes", () => { - const custom: ThemeTokens = { ...LIGHT_THEME, code: "#ff0000" }; +test("createThemedChalk inlineCode changes when inlineCode.foreground changes", () => { + const custom: ThemeTokens = { ...LIGHT_THEME, inlineCode: { ...LIGHT_THEME.inlineCode, foreground: "#ff0000" } }; assert.notEqual(createThemedChalk(LIGHT_THEME).inlineCode("test"), createThemedChalk(custom).inlineCode("test")); }); -test("createThemedChalk listBullet changes when warning changes", () => { - const custom: ThemeTokens = { ...LIGHT_THEME, warning: "#ff0000" }; +test("createThemedChalk listBullet changes when list.bullet changes", () => { + const custom: ThemeTokens = { ...LIGHT_THEME, list: { ...LIGHT_THEME.list, bullet: "#ff0000" } }; assert.notEqual(createThemedChalk(LIGHT_THEME).listBullet("test"), createThemedChalk(custom).listBullet("test")); }); -test("createThemedChalk quote changes when textDim changes", () => { - const custom: ThemeTokens = { ...LIGHT_THEME, textDim: "#ff0000" }; +test("createThemedChalk quote changes when blockquote.foreground changes", () => { + const custom: ThemeTokens = { ...LIGHT_THEME, blockquote: { ...LIGHT_THEME.blockquote, foreground: "#ff0000" } }; assert.notEqual(createThemedChalk(LIGHT_THEME).quote("test"), createThemedChalk(custom).quote("test")); }); @@ -237,22 +230,10 @@ test("createThemedChalk bold / italic / dim produce styled output", () => { assert.notEqual(tc.dim("dim"), "dim"); }); -test("createThemedChalk produces different output for different primary values", () => { - const custom1: ThemeTokens = { ...LIGHT_THEME, primary: "#ff0000" }; - const custom2: ThemeTokens = { ...LIGHT_THEME, primary: "#00ff00" }; - assert.notEqual(createThemedChalk(custom1).primary("test"), createThemedChalk(custom2).primary("test")); -}); - -test("createThemedChalk handles hex colors correctly", () => { - const hexTheme: ThemeTokens = { - ...LIGHT_THEME, - primary: "#ff6600", - warning: "#ffcc00", - code: "#00ccff", - }; - const tc = createThemedChalk(hexTheme); - assert.notEqual(tc.heading1("test"), "test"); - assert.notEqual(tc.inlineCode("test"), "test"); +test("createThemedChalk produces different output for different text.primary values", () => { + const custom1: ThemeTokens = { ...LIGHT_THEME, text: { ...LIGHT_THEME.text, primary: "#ff0000" } }; + const custom2: ThemeTokens = { ...LIGHT_THEME, text: { ...LIGHT_THEME.text, primary: "#00ff00" } }; + assert.notEqual(createThemedChalk(custom1).text("test"), createThemedChalk(custom2).text("test")); }); // --------------------------------------------------------------------------- @@ -261,16 +242,16 @@ test("createThemedChalk handles hex colors correctly", () => { test("getCurrentThemedChalk returns LIGHT_THEME chalk by default", () => { setCurrentTheme(LIGHT_THEME); - assert.notEqual(getCurrentThemedChalk().primary("test"), "test"); + assert.notEqual(getCurrentThemedChalk().brandPrimary("test"), "test"); }); test("setCurrentTheme changes getCurrentThemedChalk output", () => { setCurrentTheme(LIGHT_THEME); - const first = getCurrentThemedChalk().primary("test"); + const first = getCurrentThemedChalk().text("test"); - const custom: ThemeTokens = { ...LIGHT_THEME, primary: "#ff0000" }; + const custom: ThemeTokens = { ...LIGHT_THEME, text: { ...LIGHT_THEME.text, primary: "#ff0000" } }; setCurrentTheme(custom); - const second = getCurrentThemedChalk().primary("test"); + const second = getCurrentThemedChalk().text("test"); assert.notEqual(first, second); @@ -279,11 +260,11 @@ test("setCurrentTheme changes getCurrentThemedChalk output", () => { test("setCurrentTheme changes getCurrentThemeTokens output", () => { setCurrentTheme(LIGHT_THEME); - assert.equal(getCurrentThemeTokens().primary, LIGHT_THEME.primary); + assert.equal(getCurrentThemeTokens().brand.primary, LIGHT_THEME.brand.primary); - const custom: ThemeTokens = { ...LIGHT_THEME, primary: "#ff0000" }; + const custom: ThemeTokens = { ...LIGHT_THEME, brand: { ...LIGHT_THEME.brand, primary: "#ff0000" } }; setCurrentTheme(custom); - assert.equal(getCurrentThemeTokens().primary, "#ff0000"); + assert.equal(getCurrentThemeTokens().brand.primary, "#ff0000"); setCurrentTheme(LIGHT_THEME); }); @@ -295,37 +276,49 @@ test("setCurrentTheme changes getCurrentThemeTokens output", () => { test("resolveSettingsSources includes theme field in resolved settings", () => { const result = resolveSettingsSources(null, null, DEFAULTS, {}); assert.ok("theme" in result); - assert.equal(result.theme.primary, LIGHT_THEME.primary); + assert.equal(result.theme.brand.primary, LIGHT_THEME.brand.primary); }); test("resolveSettingsSources resolves custom theme from user settings", () => { const result = resolveSettingsSources( - { theme: { preset: "custom", overrides: { primary: "#abcdef" } } }, + { + theme: { + preset: "custom", + overrides: { brand: { primary: "#abcdef", secondary: "#abcdef", accent: "#abcdef" } }, + }, + }, null, DEFAULTS, {} ); - assert.equal(result.theme.primary, "#abcdef"); + assert.equal(result.theme.brand.primary, "#abcdef"); }); test("resolveSettingsSources resolves custom theme from project settings", () => { const result = resolveSettingsSources( null, - { theme: { preset: "custom", overrides: { primary: "#123456" } } }, + { + theme: { + preset: "custom", + overrides: { brand: { primary: "#123456", secondary: "#123456", accent: "#123456" } }, + }, + }, DEFAULTS, {} ); - assert.equal(result.theme.primary, "#123456"); + assert.equal(result.theme.brand.primary, "#123456"); }); test("resolveSettingsSources uses default theme when preset is not custom", () => { const result = resolveSettingsSources( - { theme: { preset: "light", overrides: { primary: "#abcdef" } } }, + { + theme: { preset: "light", overrides: { brand: { primary: "#abcdef", secondary: "#abcdef", accent: "#abcdef" } } }, + }, null, DEFAULTS, {} ); - assert.equal(result.theme.primary, LIGHT_THEME.primary); + assert.equal(result.theme.brand.primary, LIGHT_THEME.brand.primary); }); // --------------------------------------------------------------------------- @@ -333,16 +326,14 @@ test("resolveSettingsSources uses default theme when preset is not custom", () = // --------------------------------------------------------------------------- test("getScopeRiskColor returns default theme colors when no theme is passed", () => { - assert.equal(getScopeRiskColor("read-in-cwd"), LIGHT_THEME.success); - assert.equal(getScopeRiskColor("write-in-cwd"), LIGHT_THEME.warning); - assert.equal(getScopeRiskColor("write-out-cwd"), LIGHT_THEME.error); + assert.equal(getScopeRiskColor("read-in-cwd"), LIGHT_THEME.risk.low); + assert.equal(getScopeRiskColor("write-in-cwd"), LIGHT_THEME.risk.medium); + assert.equal(getScopeRiskColor("write-out-cwd"), LIGHT_THEME.risk.high); }); -test("getScopeRiskColor uses theme semantic colors when theme is provided", () => { +test("getScopeRiskColor uses theme risk colors when theme is provided", () => { const custom: Partial = { - success: "#aaaaaa", - warning: "#bbbbbb", - error: "#cccccc", + risk: { low: "#aaaaaa", medium: "#bbbbbb", high: "#cccccc", critical: "#dddddd" }, }; assert.equal(getScopeRiskColor("read-in-cwd", custom as ThemeTokens), "#aaaaaa"); assert.equal(getScopeRiskColor("mcp", custom as ThemeTokens), "#bbbbbb"); diff --git a/src/ui/components/DropdownMenu/index.tsx b/src/ui/components/DropdownMenu/index.tsx index 6ad43d0..f5a7291 100644 --- a/src/ui/components/DropdownMenu/index.tsx +++ b/src/ui/components/DropdownMenu/index.tsx @@ -76,8 +76,8 @@ const DropdownMenu = React.memo(function DropdownMenu({ renderItem, }: DropdownMenuProps): React.ReactElement | null { const theme = useTheme(); - const effectiveTitleColor = titleColor ?? theme.primary; - const effectiveActiveColor = activeColor ?? theme.primary; + const effectiveTitleColor = titleColor ?? theme.brand.accent; + const effectiveActiveColor = activeColor ?? theme.brand.accent; // Calculate visible window const visibleStart = calculateVisibleStart(activeIndex, items?.length, maxVisible); const visibleItems = items?.slice(visibleStart, visibleStart + maxVisible); @@ -125,11 +125,11 @@ const DropdownMenu = React.memo(function DropdownMenu({ flexDirection="column" marginBottom={1} borderStyle={"round"} - borderBottom={true} - borderTop={true} + borderBottom={false} + borderTop={false} borderLeft={false} borderRight={false} - borderColor={theme.border} + borderColor={theme.border.default} width={width} > {/* Title */} @@ -196,7 +196,17 @@ const DropdownMenu = React.memo(function DropdownMenu({ {/* Help text */} {helpText ? ( - + {helpText} ) : null} diff --git a/src/ui/components/FileMentionMenu/index.tsx b/src/ui/components/FileMentionMenu/index.tsx index 96da8db..ca3c285 100644 --- a/src/ui/components/FileMentionMenu/index.tsx +++ b/src/ui/components/FileMentionMenu/index.tsx @@ -98,9 +98,9 @@ const FileMentionMenu: React.FC = ({ open, width, token, items, onClose, maxVisible={8} renderItem={(item, isActive) => ( - {isActive ? "> " : " "} + {isActive ? "> " : " "} - + {item.label} diff --git a/src/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx index bf7e877..e028de8 100644 --- a/src/ui/components/MessageView/index.tsx +++ b/src/ui/components/MessageView/index.tsx @@ -25,12 +25,12 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return ( - {`>`} + {`>`} - {text} + {text} {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( - {` 📎 ${message.contentParams.length} image attachment(s)`} + {` 📎 ${message.contentParams.length} image attachment(s)`} ) : null} @@ -46,13 +46,13 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps if (collapsed !== false) { return ( - + ); } return ( - + {content ? {renderMarkdown(content)} : null} @@ -66,7 +66,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return ( - + {content @@ -98,7 +98,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps @@ -114,10 +114,10 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return ( - {`>`} + {`>`} - {message.content} + {message.content} ); @@ -126,7 +126,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps if (message.meta?.skill) { return ( - ⚡ Loaded skill: {message.meta.skill.name} + ⚡ Loaded skill: {message.meta.skill.name} ); } @@ -174,7 +174,7 @@ function StatusLine({ {name} {params ? ( - + {` ${params}`} ) : null} @@ -186,19 +186,44 @@ function StatusLine({ function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElement { const theme = useTheme(); + const getBackgroundColor = (kind: string) => { + switch (kind) { + case "added": + return theme.diff.addedBackground; + case "removed": + return theme.diff.removedBackground; + case "modified": + return theme.diff.modifiedBackground; + default: + return undefined; + } + }; + const getColor = (kind: string) => { + switch (kind) { + case "added": + return theme.diff.added; + case "removed": + return theme.diff.removed; + case "modified": + return theme.diff.modified; + default: + return undefined; + } + }; return ( └ Changes - + {lines.map((line, index) => ( - - - {line.marker} - - - {line.content} - - + + {line.marker} + {line.content} + ))} diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index 82ebc97..c7f4a40 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; import { getCurrentThemedChalk } from "../../theme"; +import type { ThemedChalk } from "../../theme"; /** * A rendered piece of markdown. Consumers should use `wrap="truncate-end"` for @@ -34,17 +35,17 @@ export function renderMarkdownSegments(text: string, maxWidth?: number): Markdow const segments: MarkdownSegment[] = []; const fenceSegments = splitByFences(text); + const tc = getCurrentThemedChalk(); for (const seg of fenceSegments) { if (seg.kind === "code") { - const tc = getCurrentThemedChalk(); - const langTag = seg.lang ? tc.textBright(`[${seg.lang}]`) + "\n" : ""; - segments.push({ kind: "code", body: langTag + tc.code(seg.body), lang: seg.lang }); + const langTag = seg.lang ? tc.dim(`[${seg.lang}]`) + "\n" : ""; + segments.push({ kind: "code", body: langTag + tc.dim(seg.body), lang: seg.lang }); continue; } const blocks = splitTableBlocks(seg.body); for (const b of blocks) { if (b.kind === "table") { - segments.push({ kind: "table", body: renderTableBorder(b.rows, maxWidth) }); + segments.push({ kind: "table", body: renderTableBorder(b.rows, maxWidth, tc) }); } else { const body = b.body .split("\n") @@ -227,7 +228,7 @@ function isWideChar(code: number): boolean { // Table rendering // --------------------------------------------------------------------------- -function renderTableBorder(rows: string[][], maxWidth?: number): string { +function renderTableBorder(rows: string[][], maxWidth?: number, tc?: ThemedChalk): string { if (rows.length === 0) return ""; const colCount = rows[0].length; @@ -342,10 +343,11 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - visualWidth(s))); - const top = "┌" + colWidths.map((w) => "─".repeat(w + 2)).join("┬") + "┐"; - const hdr = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; - const sep = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; - const bot = "└" + colWidths.map((w) => "─".repeat(w + 2)).join("┴") + "┘"; + const b = tc?.tableBorder ?? ((s: string) => s); + const top = b("┌") + colWidths.map((w) => b("─".repeat(w + 2))).join(b("┬")) + b("┐"); + const hdr = b("├") + colWidths.map((w) => b("─".repeat(w + 2))).join(b("┼")) + b("┤"); + const sep = b("├") + colWidths.map((w) => b("─".repeat(w + 2))).join(b("┼")) + b("┤"); + const bot = b("└") + colWidths.map((w) => b("─".repeat(w + 2))).join(b("┴")) + b("┘"); const out: string[] = [top]; @@ -353,7 +355,7 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { const h = heights[ri]; for (let li = 0; li < h; li++) { const line = wrapped[ri].map((cellLines, ci) => " " + pad(cellLines[li] ?? "", colWidths[ci]) + " "); - out.push("│" + line.join("│") + "│"); + out.push(b("│") + line.join(b("│")) + b("│")); } if (ri === 0 && rows.length > 1) out.push(hdr); else if (ri < rows.length - 1) out.push(sep); diff --git a/src/ui/components/MessageView/utils.ts b/src/ui/components/MessageView/utils.ts index bc1d730..771da59 100644 --- a/src/ui/components/MessageView/utils.ts +++ b/src/ui/components/MessageView/utils.ts @@ -212,7 +212,7 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s if (message.role === "user") { const text = message.content || "(no content)"; - return tc.primary(`> ${text}`); + return tc.brandPrimary(`> ${text}`); } if (message.role === "assistant") { @@ -258,7 +258,7 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s if (message.role === "system") { if (message.meta?.isModelChange) { - return tc.primary(`> ${message.content}`); + return tc.brandPrimary(`> ${message.content}`); } if (message.meta?.skill && typeof message.meta.skill === "object") { const skillName = (message.meta.skill as { name?: unknown }).name; diff --git a/src/ui/components/SkillsDropdown/index.tsx b/src/ui/components/SkillsDropdown/index.tsx index 8da231a..0b6ff8d 100644 --- a/src/ui/components/SkillsDropdown/index.tsx +++ b/src/ui/components/SkillsDropdown/index.tsx @@ -64,7 +64,7 @@ const SkillsDropdown: React.FC<{ label: skill.name, description: skill.path, selected: isSkillSelected(selectedSkills, skill), - statusIndicator: skill.isLoaded ? { symbol: "✓", color: theme.success } : undefined, + statusIndicator: skill.isLoaded ? { symbol: "✓", color: theme.status.success } : undefined, }))} activeIndex={skillsDropdownIndex} maxVisible={6} diff --git a/src/ui/components/ThemeDropdown/index.tsx b/src/ui/components/ThemeDropdown/index.tsx index a7f1405..ed3f5af 100644 --- a/src/ui/components/ThemeDropdown/index.tsx +++ b/src/ui/components/ThemeDropdown/index.tsx @@ -9,10 +9,10 @@ const THEME_PRESETS: ThemePreset[] = [ "dark", "github-light", "github-dark", - "gitlab-light", - "gitlab-dark", "monokai", "dracula", + "ansi-light", + "ansi-dark", "custom", ]; @@ -145,8 +145,8 @@ const ThemeDropdown: React.FC = ({ const presetTheme = PRESETS[preset]; return { key: preset, - label: preset, - labelColor: presetTheme?.primary, + label: presetTheme?.name ?? preset, + labelColor: presetTheme?.brand.primary, description: preset === currentPreset ? "current theme" diff --git a/src/ui/exit-summary.ts b/src/ui/exit-summary.ts index 60774a8..a7c1933 100644 --- a/src/ui/exit-summary.ts +++ b/src/ui/exit-summary.ts @@ -75,8 +75,8 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const theme = getCurrentThemeTokens(); const tc = getCurrentThemedChalk(); - const borderColor = chalk.hex(theme.secondary); - const titleColor = gradientString(...theme.gradients); + const borderColor = chalk.hex(theme.border.subtle); + const titleColor = gradientString(...theme.gradients.logo); const line = (text: string) => `${borderColor("│")} ${padRight(text, contentWidth)} ${borderColor("│")}`; const header = chalk.bold(titleColor("Goodbye!")); @@ -116,7 +116,7 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { padLeft("Output Tokens", colOutput) + padLeft("Cached Tokens", colCached); rows.push(chalk.bold(headerRow)); - rows.push(tc.textDim(divider)); + rows.push(tc.textMuted(divider)); for (const { modelName, usage } of usageRows) { const reqsStr = formatNumber(usage.totalReqs).padStart(colReqs); diff --git a/src/ui/theme/chalk-theme.ts b/src/ui/theme/chalk-theme.ts index e0fa043..12c0cf7 100644 --- a/src/ui/theme/chalk-theme.ts +++ b/src/ui/theme/chalk-theme.ts @@ -7,11 +7,9 @@ import type { ThemeTokens } from "./types"; * 对于 hex 颜色,直接用 chalk.hex()。 */ function chalkColor(color: string): ChalkInstance { - // 尝试 hex 格式 if (color.startsWith("#")) { return chalk.hex(color); } - // 尝试 chalk 命名颜色(如 "cyanBright" → chalk.cyanBright) const chalkWithIndex = chalk as unknown as Record; const instance = chalkWithIndex[color]; if (instance) { @@ -21,65 +19,438 @@ function chalkColor(color: string): ChalkInstance { } /** - * 根据主题创建 chalk 样式函数集合。 - * 用于 markdown 渲染、raw mode 输出等非 Ink 组件的终端输出。 + * 创建背景色 chalk 实例。 + * hex 颜色使用 chalk.bgHex(),命名颜色使用 chalk.bgXxx()。 + */ +function chalkBgColor(color: string): ChalkInstance { + if (color.startsWith("#")) { + return chalk.bgHex(color); + } + const name = `bg${color.charAt(0).toUpperCase()}${color.slice(1)}`; + const chalkWithIndex = chalk as unknown as Record; + const instance = chalkWithIndex[name]; + if (instance) { + return instance; + } + return chalk; +} + +type StyleFn = (text: string) => string; + +/** + * 主题化 chalk 样式函数集合。 + * 与 ThemeTokens 分组一一对应,用于 markdown 渲染、raw mode 输出等非 Ink 组件的终端输出。 */ export interface ThemedChalk { - heading1: (text: string) => string; - heading2: (text: string) => string; - heading3: (text: string) => string; - listBullet: (text: string) => string; - quote: (text: string) => string; - inlineCode: (text: string) => string; - code: (text: string) => string; - bold: (text: string) => string; - italic: (text: string) => string; - dim: (text: string) => string; - primary: (text: string) => string; - secondary: (text: string) => string; - text: (text: string) => string; - textDim: (text: string) => string; - textBright: (text: string) => string; - success: (text: string) => string; - error: (text: string) => string; - warning: (text: string) => string; - info: (text: string) => string; + // ——— text ——— + text: StyleFn; + textSecondary: StyleFn; + textMuted: StyleFn; + textDisabled: StyleFn; + textInverse: StyleFn; + + // ——— border ——— + borderDefault: StyleFn; + borderSubtle: StyleFn; + borderActive: StyleFn; + borderFocus: StyleFn; + + // ——— surface ——— + surfaceDefault: StyleFn; + surfaceElevated: StyleFn; + surfaceMuted: StyleFn; + surfaceCode: StyleFn; + surfacePanel: StyleFn; + surfaceQuote: StyleFn; + surfaceSelection: StyleFn; + + // ——— brand ——— + brandPrimary: StyleFn; + brandSecondary: StyleFn; + brandAccent: StyleFn; + + // ——— status ——— + success: StyleFn; + warning: StyleFn; + danger: StyleFn; + info: StyleFn; + + // ——— risk ——— + riskLow: StyleFn; + riskMedium: StyleFn; + riskHigh: StyleFn; + riskCritical: StyleFn; + + // ——— typography (Markdown 渲染) ——— + heading1: StyleFn; + heading2: StyleFn; + heading3: StyleFn; + heading4: StyleFn; + heading5: StyleFn; + heading6: StyleFn; + paragraph: StyleFn; + strong: StyleFn; + emphasis: StyleFn; + delete: StyleFn; + + // ——— link ——— + link: StyleFn; + linkVisited: StyleFn; + linkHover: StyleFn; + + // ——— inlineCode ——— + inlineCode: StyleFn; + inlineCodeBg: StyleFn; + inlineCodeBorder: StyleFn; + + // ——— codeBlock ——— + code: StyleFn; + codeBg: StyleFn; + codeBorder: StyleFn; + codeTitle: StyleFn; + lineNumber: StyleFn; + codeHighlight: StyleFn; + + // ——— syntax ——— + syntaxKeyword: StyleFn; + syntaxString: StyleFn; + syntaxFunction: StyleFn; + syntaxVariable: StyleFn; + syntaxProperty: StyleFn; + syntaxType: StyleFn; + syntaxNumber: StyleFn; + syntaxOperator: StyleFn; + syntaxPunctuation: StyleFn; + syntaxComment: StyleFn; + syntaxRegexp: StyleFn; + syntaxConstant: StyleFn; + + // ——— blockquote ——— + quote: StyleFn; + quoteBorder: StyleFn; + + // ——— list ——— + listBullet: StyleFn; + listOrdered: StyleFn; + listMarker: StyleFn; + + // ——— task ——— + taskChecked: StyleFn; + taskUnchecked: StyleFn; + + // ——— table ——— + tableBorder: StyleFn; + tableHeaderFg: StyleFn; + tableHeaderBg: StyleFn; + tableCellFg: StyleFn; + + // ——— hr ——— + hr: StyleFn; + + // ——— admonition ——— + admonitionNote: StyleFn; + admonitionTip: StyleFn; + admonitionWarning: StyleFn; + admonitionImportant: StyleFn; + admonitionCaution: StyleFn; + + // ——— diff ——— + diffAdded: StyleFn; + diffRemoved: StyleFn; + diffModified: StyleFn; + diffAddedBg: StyleFn; + diffRemovedBg: StyleFn; + diffModifiedBg: StyleFn; + + // ——— agent ——— + agentThinking: StyleFn; + agentReasoning: StyleFn; + agentToolCall: StyleFn; + agentToolResult: StyleFn; + agentStreaming: StyleFn; + agentCompleted: StyleFn; + + // ——— approval ——— + approvalAllow: StyleFn; + approvalDeny: StyleFn; + approvalReview: StyleFn; + + // ——— chalk 修饰符(不依赖主题色) ——— + bold: StyleFn; + italic: StyleFn; + dim: StyleFn; } export function createThemedChalk(theme: ThemeTokens): ThemedChalk { - const pr = chalkColor(theme.primary); - const se = chalkColor(theme.secondary); - const tx = chalkColor(theme.text); - const td = chalkColor(theme.textDim); - const tb = chalkColor(theme.textBright); - const cd = chalkColor(theme.code); - const sc = chalkColor(theme.success); - const er = chalkColor(theme.error); - const wr = chalkColor(theme.warning); - const inf = chalkColor(theme.info); + // text + const txPrimary = chalkColor(theme.text.primary); + const txSecondary = chalkColor(theme.text.secondary); + const txMuted = chalkColor(theme.text.muted); + const txDisabled = chalkColor(theme.text.disabled); + const txInverse = chalkColor(theme.text.inverse); + + // border + const brDefault = chalkColor(theme.border.default); + const brSubtle = chalkColor(theme.border.subtle); + const brActive = chalkColor(theme.border.active); + const brFocus = chalkColor(theme.border.focus); + + // surface (background) + const sfDefault = chalkBgColor(theme.surface.default); + const sfElevated = chalkBgColor(theme.surface.elevated); + const sfMuted = chalkBgColor(theme.surface.muted); + const sfCode = chalkBgColor(theme.surface.code); + const sfPanel = chalkBgColor(theme.surface.panel); + const sfQuote = chalkBgColor(theme.surface.quote); + const sfSelection = chalkBgColor(theme.surface.selection); + + // brand + const brBrandPrimary = chalkColor(theme.brand.primary); + const brBrandSecondary = chalkColor(theme.brand.secondary); + const brBrandAccent = chalkColor(theme.brand.accent); + + // status + const stSuccess = chalkColor(theme.status.success); + const stWarning = chalkColor(theme.status.warning); + const stDanger = chalkColor(theme.status.danger); + const stInfo = chalkColor(theme.status.info); + + // risk + const rkLow = chalkColor(theme.risk.low); + const rkMedium = chalkColor(theme.risk.medium); + const rkHigh = chalkColor(theme.risk.high); + const rkCritical = chalkColor(theme.risk.critical); + + // typography + const h1 = chalkColor(theme.typography.h1); + const h2 = chalkColor(theme.typography.h2); + const h3 = chalkColor(theme.typography.h3); + const h4 = chalkColor(theme.typography.h4); + const h5 = chalkColor(theme.typography.h5); + const h6 = chalkColor(theme.typography.h6); + const strong = chalkColor(theme.typography.strong); + const em = chalkColor(theme.typography.emphasis); + const del = chalkColor(theme.typography.delete); + + // link + const lnk = chalkColor(theme.link.default); + const lnkVisited = chalkColor(theme.link.visited); + const lnkHover = chalkColor(theme.link.hover); + + // inlineCode + const icFg = chalkColor(theme.inlineCode.foreground); + const icBg = chalkBgColor(theme.inlineCode.background); + const icBorder = chalkColor(theme.inlineCode.border); + + // codeBlock + const cbFg = chalkColor(theme.codeBlock.foreground); + const cbBg = chalkBgColor(theme.codeBlock.background); + const cbBorder = chalkColor(theme.codeBlock.border); + const cbTitle = chalkColor(theme.codeBlock.title); + const cbLineNo = chalkColor(theme.codeBlock.lineNumber); + const cbHighlight = chalkColor(theme.codeBlock.highlight); + + // syntax + const synKeyword = chalkColor(theme.syntax.keyword); + const synString = chalkColor(theme.syntax.string); + const synFunction = chalkColor(theme.syntax.function); + const synVariable = chalkColor(theme.syntax.variable); + const synProperty = chalkColor(theme.syntax.property); + const synType = chalkColor(theme.syntax.type); + const synNumber = chalkColor(theme.syntax.number); + const synOperator = chalkColor(theme.syntax.operator); + const synPunctuation = chalkColor(theme.syntax.punctuation); + const synComment = chalkColor(theme.syntax.comment); + const synRegexp = chalkColor(theme.syntax.regexp); + const synConstant = chalkColor(theme.syntax.constant); + + // blockquote + const bqFg = chalkColor(theme.blockquote.foreground); + const bqBorder = chalkColor(theme.blockquote.border); + + // list + const lsBullet = chalkColor(theme.list.bullet); + const lsOrdered = chalkColor(theme.list.ordered); + const lsMarker = chalkColor(theme.list.marker); + + // task + const tkChecked = chalkColor(theme.task.checked); + const tkUnchecked = chalkColor(theme.task.unchecked); + + // table + const tblBorder = chalkColor(theme.table.border); + const tblHeaderFg = chalkColor(theme.table.headerForeground); + const tblHeaderBg = chalkBgColor(theme.table.headerBackground); + const tblCellFg = chalkColor(theme.table.cellForeground); + + // hr + const hrFg = chalkColor(theme.hr.foreground); + + // admonition + const admNote = chalkColor(theme.admonition.note); + const admTip = chalkColor(theme.admonition.tip); + const admWarning = chalkColor(theme.admonition.warning); + const admImportant = chalkColor(theme.admonition.important); + const admCaution = chalkColor(theme.admonition.caution); + + // diff + const dfAdded = chalkColor(theme.diff.added); + const dfRemoved = chalkColor(theme.diff.removed); + const dfModified = chalkColor(theme.diff.modified); + const dfAddedBg = chalkBgColor(theme.diff.addedBackground); + const dfRemovedBg = chalkBgColor(theme.diff.removedBackground); + const dfModifiedBg = chalkBgColor(theme.diff.modifiedBackground); + + // agent + const agThinking = chalkColor(theme.agent.thinking); + const agReasoning = chalkColor(theme.agent.reasoning); + const agToolCall = chalkColor(theme.agent.toolCall); + const agToolResult = chalkColor(theme.agent.toolResult); + const agStreaming = chalkColor(theme.agent.streaming); + const agCompleted = chalkColor(theme.agent.completed); + + // approval + const apAllow = chalkColor(theme.approval.allow); + const apDeny = chalkColor(theme.approval.deny); + const apReview = chalkColor(theme.approval.review); return { - // Markdown 渲染 — 直接复用顶层 token - heading1: (text: string) => chalk.bold(pr(text)), - heading2: (text: string) => chalk.bold(pr(text)), - heading3: (text: string) => chalk.bold(pr(text)), - listBullet: (text: string) => wr(text), - quote: (text: string) => chalk.italic(td(text)), - inlineCode: (text: string) => cd(text), - code: (text: string) => cd(text), - // 基础样式 - bold: (text: string) => chalk.bold(text), - italic: (text: string) => chalk.italic(text), - dim: (text: string) => chalk.dim(text), - // 语义色 - primary: (text: string) => pr(text), - secondary: (text: string) => se(text), - text: (text: string) => tx(text), - textDim: (text: string) => td(text), - textBright: (text: string) => tb(text), - success: (text: string) => sc(text), - error: (text: string) => er(text), - warning: (text: string) => wr(text), - info: (text: string) => inf(text), + // text + text: (t) => txPrimary(t), + textSecondary: (t) => txSecondary(t), + textMuted: (t) => txMuted(t), + textDisabled: (t) => txDisabled(t), + textInverse: (t) => txInverse(t), + + // border + borderDefault: (t) => brDefault(t), + borderSubtle: (t) => brSubtle(t), + borderActive: (t) => brActive(t), + borderFocus: (t) => brFocus(t), + + // surface (background) + surfaceDefault: (t) => sfDefault(t), + surfaceElevated: (t) => sfElevated(t), + surfaceMuted: (t) => sfMuted(t), + surfaceCode: (t) => sfCode(t), + surfacePanel: (t) => sfPanel(t), + surfaceQuote: (t) => sfQuote(t), + surfaceSelection: (t) => sfSelection(t), + + // brand + brandPrimary: (t) => brBrandPrimary(t), + brandSecondary: (t) => brBrandSecondary(t), + brandAccent: (t) => brBrandAccent(t), + + // status + success: (t) => stSuccess(t), + warning: (t) => stWarning(t), + danger: (t) => stDanger(t), + info: (t) => stInfo(t), + + // risk + riskLow: (t) => rkLow(t), + riskMedium: (t) => rkMedium(t), + riskHigh: (t) => rkHigh(t), + riskCritical: (t) => rkCritical(t), + + // typography + heading1: (t) => chalk.bold(h1(t)), + heading2: (t) => chalk.bold(h2(t)), + heading3: (t) => chalk.bold(h3(t)), + heading4: (t) => chalk.bold(h4(t)), + heading5: (t) => chalk.bold(h5(t)), + heading6: (t) => chalk.bold(h6(t)), + paragraph: (t) => txPrimary(t), + strong: (t) => chalk.bold(strong(t)), + emphasis: (t) => chalk.italic(em(t)), + delete: (t) => del(t), + + // link + link: (t) => lnk(t), + linkVisited: (t) => lnkVisited(t), + linkHover: (t) => lnkHover(t), + + // inlineCode + inlineCode: (t) => icFg(t), + inlineCodeBg: (t) => icBg(t), + inlineCodeBorder: (t) => icBorder(t), + + // codeBlock + code: (t) => cbFg(t), + codeBg: (t) => cbBg(t), + codeBorder: (t) => cbBorder(t), + codeTitle: (t) => cbTitle(t), + lineNumber: (t) => cbLineNo(t), + codeHighlight: (t) => cbHighlight(t), + + // syntax + syntaxKeyword: (t) => synKeyword(t), + syntaxString: (t) => synString(t), + syntaxFunction: (t) => synFunction(t), + syntaxVariable: (t) => synVariable(t), + syntaxProperty: (t) => synProperty(t), + syntaxType: (t) => synType(t), + syntaxNumber: (t) => synNumber(t), + syntaxOperator: (t) => synOperator(t), + syntaxPunctuation: (t) => synPunctuation(t), + syntaxComment: (t) => synComment(t), + syntaxRegexp: (t) => synRegexp(t), + syntaxConstant: (t) => synConstant(t), + + // blockquote + quote: (t) => chalk.italic(bqFg(t)), + quoteBorder: (t) => bqBorder(t), + + // list + listBullet: (t) => lsBullet(t), + listOrdered: (t) => lsOrdered(t), + listMarker: (t) => lsMarker(t), + + // task + taskChecked: (t) => tkChecked(t), + taskUnchecked: (t) => tkUnchecked(t), + + // table + tableBorder: (t) => tblBorder(t), + tableHeaderFg: (t) => tblHeaderFg(t), + tableHeaderBg: (t) => tblHeaderBg(t), + tableCellFg: (t) => tblCellFg(t), + + // hr + hr: (t) => hrFg(t), + + // admonition + admonitionNote: (t) => admNote(t), + admonitionTip: (t) => admTip(t), + admonitionWarning: (t) => admWarning(t), + admonitionImportant: (t) => admImportant(t), + admonitionCaution: (t) => admCaution(t), + + // diff + diffAdded: (t) => dfAdded(t), + diffRemoved: (t) => dfRemoved(t), + diffModified: (t) => dfModified(t), + diffAddedBg: (t) => dfAddedBg(t), + diffRemovedBg: (t) => dfRemovedBg(t), + diffModifiedBg: (t) => dfModifiedBg(t), + + // agent + agentThinking: (t) => agThinking(t), + agentReasoning: (t) => agReasoning(t), + agentToolCall: (t) => agToolCall(t), + agentToolResult: (t) => agToolResult(t), + agentStreaming: (t) => agStreaming(t), + agentCompleted: (t) => agCompleted(t), + + // approval + approvalAllow: (t) => apAllow(t), + approvalDeny: (t) => apDeny(t), + approvalReview: (t) => apReview(t), + + // chalk 修饰符 + bold: (t) => chalk.bold(t), + italic: (t) => chalk.italic(t), + dim: (t) => chalk.dim(t), }; } diff --git a/src/ui/theme/colors-theme.ts b/src/ui/theme/colors-theme.ts new file mode 100644 index 0000000..9dce676 --- /dev/null +++ b/src/ui/theme/colors-theme.ts @@ -0,0 +1,203 @@ +import type { ThemeTokens } from "./types"; + +/** + * 用户配置的简化主题色板。 + * 只需定义基础色,系统自动推导出完整的 ThemeTokens。 + */ +export interface ColorsTheme { + /** 主背景色 */ + Background: string; + /** 主前景/文字色 */ + Foreground: string; + /** 次要文字色(dimmed) */ + Gray: string; + /** 浅蓝:信息提示、链接 */ + LightBlue: string; + /** 强调蓝:品牌色、交互态、选中项 */ + AccentBlue: string; + /** 紫色:特殊强调、已访问链接 */ + AccentPurple: string; + /** 青色:代码高亮 */ + AccentCyan: string; + /** 绿色:成功、diff 新增 */ + AccentGreen: string; + /** 黄色:警告、进行中 */ + AccentYellow: string; + /** 红色:错误、危险 */ + AccentRed: string; + /** 黄色淡化:中风险、列表标记 */ + AccentYellowDim: string; + /** 红色淡化:高风险 */ + AccentRedDim: string; + /** Diff 新增行背景 */ + DiffAdded: string; + /** Diff 删除行背景 */ + DiffRemoved: string; + /** 注释色 */ + Comment: string; + /** 渐变色数组(可选) */ + GradientColors?: string[]; +} + +/** + * 将 hex 颜色淡化(混合灰色)。 + */ +function dimHex(hex: string, ratio: number): string { + const h = hex.replace("#", ""); + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + const gr = 128; + const dr = Math.round(r + (gr - r) * ratio); + const dg = Math.round(g + (gr - g) * ratio); + const db = Math.round(b + (gr - b) * ratio); + return `#${dr.toString(16).padStart(2, "0")}${dg.toString(16).padStart(2, "0")}${db.toString(16).padStart(2, "0")}`; +} + +/** + * 从 ColorsTheme 推导完整的 ThemeTokens。 + */ +export function buildThemeTokens(c: ColorsTheme, mode: "light" | "dark", name: string): ThemeTokens { + const gradient = c.GradientColors ?? [c.AccentBlue, c.AccentPurple]; + + return { + name, + mode, + text: { + primary: c.Foreground, + secondary: dimHex(c.Foreground, 0.4), + muted: c.Gray, + disabled: dimHex(c.Gray, 0.5), + inverse: mode === "dark" ? c.Background : c.Foreground, + }, + border: { + default: dimHex(c.Foreground, 0.7), + subtle: dimHex(c.Foreground, 0.85), + active: c.AccentBlue, + focus: c.AccentBlue, + }, + surface: { + default: c.Background, + elevated: mode === "dark" ? dimHex(c.Background, 0.15) : "#ffffff", + muted: dimHex(c.Background, 0.08), + code: dimHex(c.Background, 0.08), + panel: dimHex(c.Background, 0.08), + quote: dimHex(c.Background, 0.08), + selection: mode === "dark" ? "#264f78" : "#ddf4ff", + }, + brand: { + primary: c.AccentBlue, + secondary: `${c.AccentBlue}cc`, + accent: c.AccentBlue, + }, + status: { + success: c.AccentGreen, + warning: c.AccentYellow, + danger: c.AccentRed, + info: c.LightBlue, + }, + risk: { + low: c.AccentGreen, + medium: c.AccentYellowDim, + high: c.AccentRed, + critical: c.AccentRed, + }, + typography: { + h1: c.AccentBlue, + h2: c.AccentBlue, + h3: c.AccentBlue, + h4: c.AccentBlue, + h5: c.AccentBlue, + h6: c.AccentBlue, + paragraph: c.Foreground, + strong: c.Foreground, + emphasis: c.Foreground, + delete: c.AccentRed, + }, + link: { + default: c.LightBlue, + visited: c.AccentPurple, + hover: c.LightBlue, + }, + inlineCode: { + foreground: c.AccentBlue, + background: dimHex(c.Background, 0.08), + border: dimHex(c.Foreground, 0.7), + }, + codeBlock: { + foreground: c.Foreground, + background: mode === "dark" ? dimHex(c.Background, 0.15) : dimHex(c.Background, 0.05), + border: dimHex(c.Foreground, 0.7), + title: c.Foreground, + lineNumber: c.Gray, + highlight: mode === "dark" ? "#2d333b" : "#fff8c5", + }, + syntax: { + keyword: c.AccentRed, + string: mode === "dark" ? "#a5d6ff" : "#0a3069", + function: c.AccentPurple, + variable: mode === "dark" ? "#ffa657" : "#953800", + property: c.AccentCyan, + type: mode === "dark" ? "#ffa657" : "#953800", + number: c.AccentCyan, + operator: c.AccentRed, + punctuation: c.Foreground, + comment: c.Comment, + regexp: c.AccentGreen, + constant: c.AccentCyan, + }, + blockquote: { + foreground: c.Gray, + border: dimHex(c.Foreground, 0.7), + }, + list: { + bullet: c.AccentYellowDim, + ordered: c.AccentYellowDim, + marker: c.AccentYellowDim, + }, + task: { + checked: c.AccentGreen, + unchecked: dimHex(c.Foreground, 0.7), + }, + table: { + border: dimHex(c.Foreground, 0.7), + headerForeground: c.Foreground, + headerBackground: dimHex(c.Background, 0.08), + cellForeground: c.Foreground, + }, + hr: { foreground: dimHex(c.Foreground, 0.7) }, + admonition: { + note: c.LightBlue, + tip: c.AccentGreen, + warning: c.AccentYellow, + important: c.AccentPurple, + caution: c.AccentRed, + }, + diff: { + added: c.AccentGreen, + removed: c.AccentRed, + modified: c.AccentYellow, + addedBackground: c.DiffAdded, + removedBackground: c.DiffRemoved, + modifiedBackground: mode === "dark" ? "#2d2700" : "#fff8c5", + }, + agent: { + thinking: c.Comment, + reasoning: c.Comment, + toolCall: c.AccentBlue, + toolResult: c.Gray, + streaming: c.AccentYellow, + completed: c.AccentGreen, + }, + approval: { + allow: c.AccentGreen, + deny: c.AccentRed, + review: c.AccentYellow, + }, + gradients: { + banner: gradient, + logo: gradient, + thinking: [c.Comment, c.Gray], + }, + }; +} diff --git a/src/ui/theme/index.ts b/src/ui/theme/index.ts index e09feda..29d2258 100644 --- a/src/ui/theme/index.ts +++ b/src/ui/theme/index.ts @@ -1,4 +1,6 @@ export type { ThemeTokens, ThemePreset, ThemeSettings } from "./types"; +export type { ColorsTheme } from "./colors-theme"; +export { buildThemeTokens } from "./colors-theme"; export { LIGHT_THEME, DARK_THEME, @@ -6,8 +8,8 @@ export { DRACULA_THEME, GITHUB_LIGHT_THEME, GITHUB_DARK_THEME, - GITLAB_LIGHT_THEME, - GITLAB_DARK_THEME, + ANSI_LIGHT_THEME, + ANSI_DARK_THEME, PRESETS, } from "./presets"; export { resolveTheme } from "./resolver"; diff --git a/src/ui/theme/presets.ts b/src/ui/theme/presets.ts index ff5acc3..67ecec0 100644 --- a/src/ui/theme/presets.ts +++ b/src/ui/theme/presets.ts @@ -1,133 +1,190 @@ import type { ThemeTokens } from "./types"; +import type { ColorsTheme } from "./colors-theme"; +import { buildThemeTokens } from "./colors-theme"; -/** 浅色主题(默认主题) */ -export const LIGHT_THEME: ThemeTokens = { - primary: "#229ac3", - secondary: "#229ac3e6", - success: "#1a7f37", - error: "#d1242f", - warning: "#fa8c16", - info: "#0969da", - text: "#3D4149", - textDim: "#646A71", - textBright: "#1F2329", - code: "#787f8a", - border: "#999", - gradients: ["#229ac3", "#8250df"], +// ——— 预设色板 ——— + +const LIGHT_COLORS: ColorsTheme = { + Background: "#ffffff", + Foreground: "#1F2328", + Gray: "#8b949e", + LightBlue: "#0969da", + AccentBlue: "#229ac3", + AccentPurple: "#8250df", + AccentCyan: "#0550ae", + AccentGreen: "#1a7f37", + AccentYellow: "#fa8c16", + AccentRed: "#d1242f", + AccentYellowDim: "#9a6700", + AccentRedDim: "#a40e26", + DiffAdded: "#dafbe1", + DiffRemoved: "#ffebe9", + Comment: "#6e7781", + GradientColors: ["#229ac3", "#8250df"], }; -/** 暗色主题 */ -export const DARK_THEME: ThemeTokens = { - primary: "#229ac3", - secondary: "#229ac3e6", - success: "#3fb950", - error: "#f85149", - warning: "#d29922", - info: "#58a6ff", - text: "#c9d1d9", - textDim: "#8b949e", - textBright: "#f0f6fc", - code: "#8b949e", - border: "#30363d", - gradients: ["#229ac3", "#8250df"], +const DARK_COLORS: ColorsTheme = { + Background: "#0d1117", + Foreground: "#e6edf3", + Gray: "#6e7681", + LightBlue: "#58a6ff", + AccentBlue: "#229ac3", + AccentPurple: "#bc8cff", + AccentCyan: "#79c0ff", + AccentGreen: "#3fb950", + AccentYellow: "#d29922", + AccentRed: "#f85149", + AccentYellowDim: "#d29922", + AccentRedDim: "#f85149", + DiffAdded: "#12261e", + DiffRemoved: "#2d1518", + Comment: "#8b949e", + GradientColors: ["#229ac3", "#8250df"], }; -/** Monokai 主题 */ -export const MONOKAI_THEME: ThemeTokens = { - primary: "#f92672", - secondary: "#f92672cc", - success: "#a6e22e", - error: "#f92672", - warning: "#fd971f", - info: "#66d9ef", - text: "#f8f8f2", - textDim: "#75715e", - textBright: "#f8f8f2", - code: "#75715e", - border: "#49483e", - gradients: ["#f92672", "#ae81ff"], +const GITHUB_LIGHT_COLORS: ColorsTheme = { + Background: "#f8f8f8", + Foreground: "#24292E", + LightBlue: "#0086b3", + AccentBlue: "#458", + AccentPurple: "#900", + AccentCyan: "#009926", + AccentGreen: "#008080", + AccentYellow: "#990073", + AccentRed: "#d14", + AccentYellowDim: "#8B7000", + AccentRedDim: "#993333", + DiffAdded: "#C6EAD8", + DiffRemoved: "#FFCCCC", + Comment: "#998", + Gray: "#999", + GradientColors: ["#458", "#008080"], }; -/** Dracula 主题 */ -export const DRACULA_THEME: ThemeTokens = { - primary: "#bd93f9", - secondary: "#bd93f9cc", - success: "#50fa7b", - error: "#ff5555", - warning: "#ffb86c", - info: "#8be9fd", - text: "#f8f8f2", - textDim: "#6272a4", - textBright: "#f8f8f2", - code: "#6272a4", - border: "#44475a", - gradients: ["#bd93f9", "#ff79c6"], +const GITHUB_DARK_COLORS: ColorsTheme = { + Background: "#24292e", + Foreground: "#c0c4c8", + LightBlue: "#79B8FF", + AccentBlue: "#79B8FF", + AccentPurple: "#B392F0", + AccentCyan: "#9ECBFF", + AccentGreen: "#85E89D", + AccentYellow: "#FFAB70", + AccentRed: "#F97583", + AccentYellowDim: "#8B7530", + AccentRedDim: "#8B3A4A", + DiffAdded: "#3C4636", + DiffRemoved: "#502125", + Comment: "#6A737D", + Gray: "#6A737D", + GradientColors: ["#79B8FF", "#85E89D"], }; -/** GitHub Light 主题 */ -export const GITHUB_LIGHT_THEME: ThemeTokens = { - primary: "#0969da", - secondary: "#0969dae6", - success: "#1a7f37", - error: "#cf222e", - warning: "#9a6700", - info: "#0969da", - text: "#1F2328", - textDim: "#656d76", - textBright: "#0d1117", - code: "#656d76", - border: "#d0d7de", - gradients: ["#0969da", "#8250df"], +const DRACULA_THEME_COLORS: ColorsTheme = { + Background: "#282a36", + Foreground: "#a3afb7", + LightBlue: "#8be9fd", + AccentBlue: "#8be9fd", + AccentPurple: "#ff79c6", + AccentCyan: "#8be9fd", + AccentGreen: "#50fa7b", + AccentYellow: "#fff783", + AccentRed: "#ff5555", + AccentYellowDim: "#8B7530", + AccentRedDim: "#8B3A4A", + DiffAdded: "#11431d", + DiffRemoved: "#6e1818", + Comment: "#6272a4", + Gray: "#6272a4", + GradientColors: ["#ff79c6", "#8be9fd"], }; -/** GitHub Dark 主题 */ -export const GITHUB_DARK_THEME: ThemeTokens = { - primary: "#58a6ff", - secondary: "#58a6ffcc", - success: "#3fb950", - error: "#f85149", - warning: "#d29922", - info: "#58a6ff", - text: "#c9d1d9", - textDim: "#8b949e", - textBright: "#f0f6fc", - code: "#8b949e", - border: "#30363d", - gradients: ["#58a6ff", "#bc8cff"], +/** ANSI Light 终端色主题(浅色背景) */ +const ANSI_LIGHT_COLORS: ColorsTheme = { + Background: "white", + Foreground: "#444", + LightBlue: "blue", + AccentBlue: "blue", + AccentPurple: "purple", + AccentCyan: "cyan", + AccentGreen: "green", + AccentYellow: "orange", + AccentRed: "red", + AccentYellowDim: "orange", + AccentRedDim: "red", + DiffAdded: "#E5F2E5", + DiffRemoved: "#FFE5E5", + Comment: "gray", + Gray: "gray", + GradientColors: ["blue", "green"], }; -/** GitLab Light 主题 */ -export const GITLAB_LIGHT_THEME: ThemeTokens = { - primary: "#1068bf", - secondary: "#1068bfe6", - success: "#108548", - error: "#dd2b0e", - warning: "#c17d10", - info: "#1068bf", - text: "#1f1e24", - textDim: "#626168", - textBright: "#0f0e11", - code: "#626168", - border: "#dcdcde", - gradients: ["#1068bf", "#694cc0"], +/** ANSI Dark 终端色主题(深色背景) */ +const ANSI_DARK_COLORS: ColorsTheme = { + Background: "black", + Foreground: "white", + LightBlue: "bluebright", + AccentBlue: "blue", + AccentPurple: "magenta", + AccentCyan: "cyan", + AccentGreen: "green", + AccentYellow: "yellow", + AccentRed: "red", + AccentYellowDim: "yellow", + AccentRedDim: "red", + DiffAdded: "#003300", + DiffRemoved: "#4D0000", + Comment: "gray", + Gray: "gray", + GradientColors: ["cyan", "green"], }; -/** GitLab Dark 主题 */ -export const GITLAB_DARK_THEME: ThemeTokens = { - primary: "#63a0d4", - secondary: "#63a0d4cc", - success: "#26a269", - error: "#e24329", - warning: "#c17d10", - info: "#63a0d4", - text: "#ececef", - textDim: "#a1a1a9", - textBright: "#ffffff", - code: "#a1a1a9", - border: "#3b3b3f", - gradients: ["#63a0d4", "#9785d4"], +/** Monokai 色板(text.primary 使用品牌色而非前景色,需 overrides) */ +const MONOKAI_COLORS: ColorsTheme = { + Background: "#272822", + Foreground: "#f8f8f2", + Gray: "#75715e", + LightBlue: "#66d9ef", + AccentBlue: "#f92672", + AccentPurple: "#ae81ff", + AccentCyan: "#66d9ef", + AccentGreen: "#a6e22e", + AccentYellow: "#fd971f", + AccentRed: "#f92672", + AccentYellowDim: "#fd971f", + AccentRedDim: "#f92672", + DiffAdded: "#2d3a1f", + DiffRemoved: "#3d1a25", + Comment: "#75715e", + GradientColors: ["#f92672", "#ae81ff"], }; +// ——— 通过 ColorsTheme 自动推导的预设 ——— + +/** 浅色主题(默认) */ +export const LIGHT_THEME: ThemeTokens = buildThemeTokens(LIGHT_COLORS, "light", "Light"); + +/** 暗色主题 */ +export const DARK_THEME: ThemeTokens = buildThemeTokens(DARK_COLORS, "dark", "Dark"); + +/** GitHub Light 主题 */ +export const GITHUB_LIGHT_THEME: ThemeTokens = buildThemeTokens(GITHUB_LIGHT_COLORS, "light", "GitHub Light"); + +/** GitHub Dark 主题 */ +export const GITHUB_DARK_THEME: ThemeTokens = buildThemeTokens(GITHUB_DARK_COLORS, "dark", "GitHub Dark"); + +/** Dracula 主题 */ +export const DRACULA_THEME: ThemeTokens = buildThemeTokens(DRACULA_THEME_COLORS, "dark", "Dracula"); + +/** ANSI Light 终端色主题 */ +export const ANSI_LIGHT_THEME: ThemeTokens = buildThemeTokens(ANSI_LIGHT_COLORS, "light", "ANSI Light"); + +/** ANSI Dark 终端色主题 */ +export const ANSI_DARK_THEME: ThemeTokens = buildThemeTokens(ANSI_DARK_COLORS, "dark", "ANSI Dark"); + +/** Monokai 主题(text.primary 使用品牌色,typography 使用前景色) */ +export const MONOKAI_THEME: ThemeTokens = buildThemeTokens(MONOKAI_COLORS, "dark", "Monokai"); + /** 预设主题映射表 */ export const PRESETS: Record = { light: LIGHT_THEME, @@ -136,6 +193,6 @@ export const PRESETS: Record = { dracula: DRACULA_THEME, "github-light": GITHUB_LIGHT_THEME, "github-dark": GITHUB_DARK_THEME, - "gitlab-light": GITLAB_LIGHT_THEME, - "gitlab-dark": GITLAB_DARK_THEME, + "ansi-light": ANSI_LIGHT_THEME, + "ansi-dark": ANSI_DARK_THEME, }; diff --git a/src/ui/theme/resolver.ts b/src/ui/theme/resolver.ts index 7166f50..e361e6e 100644 --- a/src/ui/theme/resolver.ts +++ b/src/ui/theme/resolver.ts @@ -1,9 +1,10 @@ import { type ThemeTokens, type ThemeSettings } from "./types"; +import { buildThemeTokens } from "./colors-theme"; import { LIGHT_THEME, PRESETS } from "./presets"; /** * 深度合并两个对象。right 的值覆盖 left。 - * 仅支持最多两层嵌套(ThemeTokens)。 + * 支持任意深度嵌套。 */ function deepMerge(left: T, right: object): T { const result = { ...left }; @@ -41,13 +42,23 @@ export function resolveTheme(themeSettings: ThemeSettings | undefined): ThemeTok return PRESETS[preset]; } - // preset="custom":应用用户自定义 + // preset="custom":基于 base 预设应用用户自定义 if (preset === "custom") { + const baseName = themeSettings.base; + const baseTheme = baseName && baseName !== "custom" && baseName in PRESETS ? PRESETS[baseName] : LIGHT_THEME; + + // 优先级:tokens > colors + overrides > overrides > colors if (themeSettings.tokens) { - return deepMerge(LIGHT_THEME, themeSettings.tokens); + return deepMerge(baseTheme, themeSettings.tokens); + } + if (themeSettings.colors && themeSettings.overrides) { + return deepMerge(buildThemeTokens(themeSettings.colors, baseTheme.mode, "Custom"), themeSettings.overrides); + } + if (themeSettings.colors) { + return buildThemeTokens(themeSettings.colors, baseTheme.mode, "Custom"); } if (themeSettings.overrides) { - return deepMerge(LIGHT_THEME, themeSettings.overrides); + return deepMerge(baseTheme, themeSettings.overrides); } } diff --git a/src/ui/theme/types.ts b/src/ui/theme/types.ts index 0fbaa7e..5f2c746 100644 --- a/src/ui/theme/types.ts +++ b/src/ui/theme/types.ts @@ -1,36 +1,264 @@ +import type { ColorsTheme } from "./colors-theme"; + /** 主题颜色 Token 定义 */ export interface ThemeTokens { + /** 主题显示名称(在 /theme 选择器中展示) */ + name: string; + /** 主题模式 */ + mode: "light" | "dark"; + + // ——— 文字色 ——— + text: { + /** 主文字 */ + primary: string; + /** 次要文字:标签、描述 */ + secondary: string; + /** 暗化文字:提示、占位符、引用块 */ + muted: string; + /** 禁用文字 */ + disabled: string; + /** 反色文字:深色背景上的亮色文字(如代码块标签) */ + inverse: string; + }; + + // ——— 边框色 ——— + border: { + /** 默认边框 */ + default: string; + /** 淡化边框:内部分割线 */ + subtle: string; + /** 激活边框:选中项、下拉菜单 */ + active: string; + /** 聚焦边框:输入框聚焦态 */ + focus: string; + }; + + // ——— 表面色 ——— + surface: { + /** 默认背景 */ + default: string; + /** 提升背景:弹出层、卡片 */ + elevated: string; + /** 暗化背景:代码块、面板 */ + muted: string; + /** 代码背景 */ + code: string; + /** 面板背景 */ + panel: string; + /** 引用块背景 */ + quote: string; + /** 选中行背景 */ + selection: string; + }; + // ——— 品牌色 ——— - /** 主品牌色:Logo、用户消息、选中项,及 Markdown H1-H6 标题 */ - primary: string; - /** 辅助品牌色:边框、渐变 */ - secondary: string; - - // ——— 语义颜色 ——— - /** 成功:工具执行成功、MCP ready,低风险操作 */ - success: string; - /** 失败/错误:工具执行失败、错误信息,高风险操作 */ - error: string; - /** 警告/进行中:忙时 spinner、权限提示、中风险操作,及 Markdown 列表标记 */ - warning: string; - /** 特殊指示:技能、图片附件 */ - info: string; - - // ——— 基础色 ——— - /** 主文字颜色 */ - text: string; - /** 次要文字:暗化提示,及 Markdown 引用块 */ - textDim: string; - /** 亮色文字:强调提示 */ - textBright: string; - /** 代码块/内联代码 */ - code: string; - /** 边框 */ - border: string; + brand: { + /** 品牌主色:Logo、渐变起始色 */ + primary: string; + /** 品牌辅色:渐变终止色 */ + secondary: string; + /** 强调色:选中项、光标、交互态 */ + accent: string; + }; + + // ——— 状态色 ——— + status: { + /** 成功:工具执行成功、MCP ready */ + success: string; + /** 警告:MCP 启动/重连、进行中 */ + warning: string; + /** 危险:错误、工具失败 */ + danger: string; + /** 信息:技能加载、图片附件 */ + info: string; + }; + + // ——— 风险色 ——— + risk: { + /** 低风险:read-in-cwd、query-git-log */ + low: string; + /** 中风险:read-out-cwd、write-in-cwd、network、mcp */ + medium: string; + /** 高风险:write-out-cwd、delete-in-cwd */ + high: string; + /** 极高风险:delete-out-cwd、mutate-git-log */ + critical: string; + }; + + // ——— 排版色 ——— + typography: { + h1: string; + h2: string; + h3: string; + h4: string; + h5: string; + h6: string; + /** 段落文字 */ + paragraph: string; + /** 粗体 */ + strong: string; + /** 斜体 */ + emphasis: string; + /** 删除线 */ + delete: string; + }; + + // ——— 链接色 ——— + link: { + /** 默认链接 */ + default: string; + /** 已访问链接 */ + visited: string; + /** 悬停链接 */ + hover: string; + }; + + // ——— 行内代码 ——— + inlineCode: { + /** 前景色 */ + foreground: string; + /** 背景色 */ + background: string; + /** 边框色 */ + border: string; + }; + + // ——— 代码块 ——— + codeBlock: { + /** 前景色 */ + foreground: string; + /** 背景色 */ + background: string; + /** 边框色 */ + border: string; + /** 标题色 */ + title: string; + /** 行号色 */ + lineNumber: string; + /** 高亮行色 */ + highlight: string; + }; + + // ——— 语法高亮 ——— + syntax: { + keyword: string; + string: string; + function: string; + variable: string; + property: string; + type: string; + number: string; + operator: string; + punctuation: string; + comment: string; + regexp: string; + constant: string; + }; + + // ——— 引用块 ——— + blockquote: { + /** 引用文字色 */ + foreground: string; + /** 引用边框色 */ + border: string; + }; + + // ——— 列表 ——— + list: { + /** 无序列表标记 */ + bullet: string; + /** 有序列表标记 */ + ordered: string; + /** 列表标记(通用) */ + marker: string; + }; + + // ——— 任务列表 ——— + task: { + /** 已完成 */ + checked: string; + /** 未完成 */ + unchecked: string; + }; + + // ——— 表格 ——— + table: { + /** 表格边框 */ + border: string; + /** 表头文字 */ + headerForeground: string; + /** 表头背景 */ + headerBackground: string; + /** 单元格文字 */ + cellForeground: string; + }; + + // ——— 分割线 ——— + hr: { + /** 分割线颜色 */ + foreground: string; + }; + + // ——— 提示框 ——— + admonition: { + note: string; + tip: string; + warning: string; + important: string; + caution: string; + }; + + // ——— Diff ——— + diff: { + /** 新增行文字 */ + added: string; + /** 删除行文字 */ + removed: string; + /** 修改行文字 */ + modified: string; + /** 新增行背景 */ + addedBackground: string; + /** 删除行背景 */ + removedBackground: string; + /** 修改行背景 */ + modifiedBackground: string; + }; + + // ——— Agent 状态色 ——— + agent: { + /** 思考中 */ + thinking: string; + /** 推理中 */ + reasoning: string; + /** 工具调用 */ + toolCall: string; + /** 工具结果 */ + toolResult: string; + /** 流式输出/忙碌 */ + streaming: string; + /** 完成 */ + completed: string; + }; + + // ——— 审批色 ——— + approval: { + /** 允许 */ + allow: string; + /** 拒绝 */ + deny: string; + /** 审查 */ + review: string; + }; // ——— 渐变 ——— - /** Logo 渐变色数组 */ - gradients: string[]; + gradients: { + /** Banner 渐变 */ + banner: string[]; + /** Logo 渐变 */ + logo: string[]; + /** 思考状态渐变 */ + thinking: string[]; + }; } /** 预设主题名称 */ @@ -41,16 +269,20 @@ export type ThemePreset = | "dracula" | "github-light" | "github-dark" - | "gitlab-light" - | "gitlab-dark" + | "ansi-light" + | "ansi-dark" | "custom"; /** 主题配置(用户可配置部分) */ export type ThemeSettings = { /** 选择预设主题,如 "light"、"dark" 等;"custom" 使用用户自定义 */ preset?: ThemePreset; - /** 覆盖部分 token(仅 preset="custom" 时生效) */ + /** custom 模式下的基础预设,默认 "light"。基于此预设做 overrides 合并 */ + base?: ThemePreset; + /** 简化色板配置(仅 preset="custom" 时生效)。系统自动推导完整 token */ + colors?: ColorsTheme; + /** 覆盖部分 token(仅 preset="custom" 时生效,可与 colors 配合使用) */ overrides?: Partial; - /** 完全自定义(仅 preset="custom" 时生效,优先级高于 overrides) */ + /** 完全自定义(仅 preset="custom" 时生效,优先级最高) */ tokens?: ThemeTokens; }; diff --git a/src/ui/views/App.tsx b/src/ui/views/App.tsx index b028264..2a3f8ff 100644 --- a/src/ui/views/App.tsx +++ b/src/ui/views/App.tsx @@ -739,7 +739,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl ) : null} {errorLine ? ( - Error: {errorLine} + Error: {errorLine} ) : null} {showProcessStdout ? ( diff --git a/src/ui/views/AskUserQuestionPrompt.tsx b/src/ui/views/AskUserQuestionPrompt.tsx index 1eeb31d..a2b21f5 100644 --- a/src/ui/views/AskUserQuestionPrompt.tsx +++ b/src/ui/views/AskUserQuestionPrompt.tsx @@ -165,9 +165,9 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): } return ( - + - + Answer questions @@ -175,7 +175,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): {questionIndex + 1}/{questions.length} - + {question.question} @@ -187,7 +187,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): const marker = question.multiSelect ? (isSelected ? "[x]" : "[ ]") : isSelected ? "●" : "○"; return ( - + {isCursor ? "> " : " "} {marker} {option.label} @@ -196,14 +196,14 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): marginLeft={4} marginTop={0} borderStyle="single" - borderColor={isCursor ? theme.primary : theme.textDim} + borderColor={isCursor ? theme.brand.accent : theme.text.muted} paddingX={1} width={64} > {otherText ? ( - + {otherText} - {isCursor ? : null} + {isCursor ? : null} ) : ( {isCursor ? "type your answer here" : "type a custom answer"} diff --git a/src/ui/views/ExitSummaryView.tsx b/src/ui/views/ExitSummaryView.tsx index 6c9d299..3f8eccf 100644 --- a/src/ui/views/ExitSummaryView.tsx +++ b/src/ui/views/ExitSummaryView.tsx @@ -23,7 +23,7 @@ const COL_CACHED = 18; export default function ExitSummaryView({ session }: Props): React.ReactElement { const theme = useTheme(); const data = buildExitSummaryData({ session }); - const gradient = gradientString(...theme.gradients); + const gradient = gradientString(...theme.gradients.logo); return ( @@ -46,7 +46,7 @@ export default function ExitSummaryView({ session }: Props): React.ReactElement {/* Table header */} {formatNumber(row.reqs)} - {formatNumber(row.inputTokens)} + {formatNumber(row.inputTokens)} - {formatNumber(row.outputTokens)} + {formatNumber(row.outputTokens)} - {formatNumber(row.cachedTokens)} + {formatNumber(row.cachedTokens)} ))} diff --git a/src/ui/views/McpStatusList.tsx b/src/ui/views/McpStatusList.tsx index 3834454..c41a10d 100644 --- a/src/ui/views/McpStatusList.tsx +++ b/src/ui/views/McpStatusList.tsx @@ -40,9 +40,16 @@ export function McpStatusList({ statuses, onCancel, onReconnect }: Props): React if (statuses.length === 0) { return ( - + - + Manage MCP servers 0 servers @@ -190,18 +197,18 @@ function ServerListView({ paddingX={1} marginTop={1} > - + {/* Header row */} - + Manage MCP servers ( - {readyCount} ready, - {startingCount} starting, - {reconnectingCount > 0 && {reconnectingCount} reconnecting,} - {failedCount} failed + {readyCount} ready, + {startingCount} starting, + {reconnectingCount > 0 && {reconnectingCount} reconnecting,} + {failedCount} failed ) @@ -212,7 +219,7 @@ function ServerListView({ borderLeft={false} borderRight={false} borderStyle="round" - borderColor={theme.border} + borderColor={theme.border.default} flexDirection="column" flexGrow={1} paddingX={1} @@ -263,12 +270,12 @@ function ServerRow({ status.status === "ready" ? "✓" : status.status === "failed" ? "✗" : status.status === "reconnecting" ? "↻" : "●"; const color = status.status === "ready" - ? theme.success + ? theme.status.success : status.status === "failed" - ? theme.error + ? theme.status.danger : status.status === "reconnecting" - ? theme.warning - : theme.warning; + ? theme.status.warning + : theme.status.warning; // 加载动画:循环显示 (空) → . → .. → ... → (空) → ... const [dots, setDots] = React.useState(0); @@ -294,7 +301,7 @@ function ServerRow({ {/* Server row */} - + {selected ? "> " : " "} {icon} {status.name} @@ -420,12 +427,12 @@ function ServerDetailView({ server.status === "ready" ? "✓" : server.status === "failed" ? "✗" : server.status === "reconnecting" ? "↻" : "●"; const statusColor = server.status === "ready" - ? theme.success + ? theme.status.success : server.status === "failed" - ? theme.error + ? theme.status.danger : server.status === "reconnecting" - ? theme.warning - : theme.warning; + ? theme.status.warning + : theme.status.warning; return ( - + {/* Header row */} {statusIcon} - + {server.name} — {server.status === "ready" ? "Details" : "Status"} @@ -466,7 +473,7 @@ function ServerDetailView({ borderLeft={false} borderRight={false} borderStyle="round" - borderColor={theme.border} + borderColor={theme.border.default} flexDirection="column" flexGrow={1} paddingX={1} @@ -521,11 +528,11 @@ function ItemRow({ item, selected }: { item: { type: string; name: string }; sel const isAction = item.type === "action"; const icon = isAction ? "↻" : item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; const theme = useTheme(); - const color = isAction && selected ? theme.warning : selected ? theme.primary : undefined; + const color = isAction && selected ? theme.status.warning : selected ? theme.brand.accent : undefined; return ( - {selected ? "> " : " "} + {selected ? "> " : " "} {icon} {isAction ? `[${item.name}]` : item.name} @@ -545,11 +552,11 @@ function ErrorRow({ error }: { error: string }): React.ReactElement { marginTop={0} marginBottom={0} borderStyle="round" - borderColor={theme.error} + borderColor={theme.status.danger} > {lines.map((line, index) => ( - + {line} diff --git a/src/ui/views/PermissionPrompt.tsx b/src/ui/views/PermissionPrompt.tsx index a16662c..5ff825f 100644 --- a/src/ui/views/PermissionPrompt.tsx +++ b/src/ui/views/PermissionPrompt.tsx @@ -129,9 +129,9 @@ export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React } return ( - + - + Permission required @@ -139,17 +139,17 @@ export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React {Math.min(effectiveIndex + 1, prompts.length)}/{prompts.length} - + {prompt.request.name} - {prompt.request.command} + {prompt.request.command} {prompt.request.description ? {prompt.request.description} : null} - Do you want to proceed? + Do you want to proceed? {options.map((option, optionIndex) => ( - + {optionIndex === cursor ? "> " : " "} {optionIndex + 1}. {renderOptionLabel(option)} @@ -236,20 +236,20 @@ export function getScopeRiskColor(scope: AskPermissionScope, theme?: ThemeTokens switch (scope) { case "read-in-cwd": case "query-git-log": - return t.success; + return t.risk.low; case "read-out-cwd": case "write-in-cwd": case "network": case "mcp": - return t.warning; + return t.risk.medium; case "write-out-cwd": case "delete-in-cwd": case "delete-out-cwd": case "mutate-git-log": case "unknown": - return t.error; + return t.risk.high; default: - return t.error; + return t.risk.critical; } } diff --git a/src/ui/views/ProcessStdoutView.tsx b/src/ui/views/ProcessStdoutView.tsx index 5b68253..d0cbedc 100644 --- a/src/ui/views/ProcessStdoutView.tsx +++ b/src/ui/views/ProcessStdoutView.tsx @@ -135,7 +135,7 @@ export const ProcessStdoutView = React.memo(function ProcessStdoutView({ return ( - + 📟 Process Output {` (${formatTimeoutHint( diff --git a/src/ui/views/PromptInput.tsx b/src/ui/views/PromptInput.tsx index 48c388e..58bf753 100644 --- a/src/ui/views/PromptInput.tsx +++ b/src/ui/views/PromptInput.tsx @@ -115,7 +115,7 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }, [busy]); const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; - return {prefix}; + return {prefix}; }); export const PromptInput = React.memo(function PromptInput({ @@ -754,13 +754,13 @@ export const PromptInput = React.memo(function PromptInput({ {imageUrls.length > 0 ? ( - {formatImageAttachmentStatus(imageUrls.length)} + {formatImageAttachmentStatus(imageUrls.length)} {` (${IMAGE_ATTACHMENT_CLEAR_HINT})`} ) : null} {selectedSkills.length > 0 ? ( - + {formatSelectedSkillsStatus(selectedSkills)} (use /skills to edit) @@ -773,10 +773,10 @@ export const PromptInput = React.memo(function PromptInput({ borderBottom={true} borderLeft={false} borderRight={false} - borderColor={isFocused ? theme.primary : theme.border} + borderColor={isFocused ? theme.brand.accent : theme.border.default} > - {renderBufferWithCursor(buffer, isFocused, placeholder, pastesRef.current, theme.warning)} + {renderBufferWithCursor(buffer, isFocused, placeholder, pastesRef.current, theme.status.warning)} {inlineHint ? {inlineHint} : null} - No previous sessions found. + No previous sessions found. Press Esc to go back. ); @@ -197,21 +197,21 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): paddingX={1} marginTop={1} > - + {/* Header row */} - + Resume a session - + ({sessions.length} total {hasActiveSearch ? `, ${filteredSessions.length} matched` : ""}) {/* Search bar */} - + {searchQuery ? `Search: ${searchQuery}` : "Type to search\u2026"} {searchQuery ? | : null} @@ -225,7 +225,7 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): borderLeft={false} borderRight={false} borderStyle="round" - borderColor={theme.border} + borderColor={theme.border.default} flexDirection="column" flexGrow={1} paddingX={1} @@ -233,7 +233,7 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): > {filteredSessions.length === 0 ? ( - No sessions match "{searchQuery}". + No sessions match "{searchQuery}". ) : ( visibleSessions.map((session, i) => { @@ -243,15 +243,15 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): return ( - {isSelected ? "> " : " "} + {isSelected ? "> " : " "} - + {formatSessionTitle(session.summary || "Untitled")} {isConfirming ? ( - [Delete? Enter=yes, Esc=no] + [Delete? Enter=yes, Esc=no] ) : ( ({formatSessionStatus(session.status)}) )} @@ -277,12 +277,12 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): {confirmDeleteSessionId ? ( - Delete this session? - + Delete this session? + Enter to confirm · - + Esc to cancel diff --git a/src/ui/views/SlashCommandMenu.tsx b/src/ui/views/SlashCommandMenu.tsx index 73c3251..e7acdb2 100644 --- a/src/ui/views/SlashCommandMenu.tsx +++ b/src/ui/views/SlashCommandMenu.tsx @@ -58,14 +58,14 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ return ( - + {actualIndex === activeIndex ? "> " : " "} {formatSlashCommandLabel(item)} {item.args ? {item.args.join(ARGS_SEPARATOR)} : null} - + {formatSlashCommandDescription(item.description)} diff --git a/src/ui/views/ThemedGradient.tsx b/src/ui/views/ThemedGradient.tsx index c470d53..353cfcc 100644 --- a/src/ui/views/ThemedGradient.tsx +++ b/src/ui/views/ThemedGradient.tsx @@ -5,7 +5,7 @@ import { useTheme } from "../theme"; export const ThemedGradient: React.FC = ({ children, ...props }) => { const theme = useTheme(); - const gradient = theme.gradients; + const gradient = theme.gradients.logo; if (gradient && gradient.length >= 2) { return ( @@ -25,7 +25,7 @@ export const ThemedGradient: React.FC = ({ children, ...props }) => { // Fallback to primary color if no gradient return ( - + {children} ); diff --git a/src/ui/views/UndoSelector.tsx b/src/ui/views/UndoSelector.tsx index a1385fc..beeaa1e 100644 --- a/src/ui/views/UndoSelector.tsx +++ b/src/ui/views/UndoSelector.tsx @@ -84,7 +84,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac if (targets.length === 0) { return ( - Nothing to undo yet. + Nothing to undo yet. Press Esc to go back. ); @@ -99,9 +99,9 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac paddingX={1} marginTop={1} > - + - + Undo restore to the point before a prompt @@ -113,7 +113,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac borderLeft={false} borderRight={false} borderStyle="round" - borderColor={theme.border} + borderColor={theme.border.default} flexDirection="column" flexGrow={1} paddingX={1} @@ -124,9 +124,9 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac const isActive = actualIndex === safeTargetIndex; return ( - {isActive ? "> " : " "} + {isActive ? "> " : " "} - + {formatUndoMessage(target.message.content)} @@ -145,7 +145,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac borderLeft={false} borderRight={false} borderStyle="round" - borderColor={theme.border} + borderColor={theme.border.default} flexDirection="column" flexGrow={1} paddingX={1} @@ -154,7 +154,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac Selected prompt: {formatUndoMessage(selectedTarget?.message.content ?? "")} - + {modeIndex === 0 ? "> " : " "}Restore code and conversation @@ -163,7 +163,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac ? "Restore files from the recorded Git checkpoint, then fork the conversation." : "No code checkpoint is recorded for this prompt."} - + {modeIndex === 1 ? "> " : " "}Restore conversation {" "}Fork the conversation without changing files. diff --git a/src/ui/views/UpdatePrompt.tsx b/src/ui/views/UpdatePrompt.tsx index 4159569..73c54d3 100644 --- a/src/ui/views/UpdatePrompt.tsx +++ b/src/ui/views/UpdatePrompt.tsx @@ -62,14 +62,14 @@ export function UpdatePrompt({ currentVersion, latestVersion, installCommand, on return ( - + Deep Code latest version has been released: {currentVersion} -> {latestVersion} {options.map((option, index) => { const selected = index === selectedIndex; return ( - + {selected ? "> " : " "} {index + 1}. {option.label} diff --git a/src/ui/views/WelcomeScreen.tsx b/src/ui/views/WelcomeScreen.tsx index f9e437f..a9704bd 100644 --- a/src/ui/views/WelcomeScreen.tsx +++ b/src/ui/views/WelcomeScreen.tsx @@ -51,7 +51,7 @@ export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeS - {">"}_ Deep Code + {">"}_ Deep Code (v{version || "unknown"}) {!compact ? : null} From d023ba60f661070831ac663ed8800ed4db79e172 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 2 Jun 2026 11:53:22 +0800 Subject: [PATCH 14/19] =?UTF-8?q?feat(theme):=20=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E7=AE=A1=E7=90=86=E5=B9=B6=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E8=83=8C=E6=99=AF=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 ThemeManager 管理主题状态和事件,替代旧方式统一处理主题切换和预览 - AppContainer 中通过 ThemeManager 实例管理主题状态,订阅主题变更并自动更新 UI - 支持异步初始化主题管理器,检测终端背景并启动轮询动态刷新主题 - 在主题生成时根据终端背景自动反转文字色,提升不同终端背景的可读性 - 优化主题解析逻辑,新增 applyTerminalContrast 函数执行终端背景对比色调整 - 精简并重构主题相关代码和类型引用,去除冗余逻辑和重复状态维护 - 更新文档配置说明,详细介绍主题使用与自定义方案,支持更灵活预设与定制 - README 中新增主题使用快速切换说明和配置指南链接,提升用户体验 --- README-en.md | 117 ++------- README-zh_CN.md | 93 +------- README.md | 93 +------- docs/configuration.md | 32 +-- docs/configuration_en.md | 34 +-- src/common/update-check.ts | 3 +- src/tests/theme-manager.test.ts | 357 ++++++++++++++++++++++++++++ src/tests/theme.test.ts | 15 +- src/ui/theme/ThemeManager.ts | 185 ++++++++++++++ src/ui/theme/colors-theme.ts | 63 +++-- src/ui/theme/detect-system-theme.ts | 204 ++++++++++++++++ src/ui/theme/index.ts | 1 + src/ui/theme/resolver.ts | 75 +++++- src/ui/views/AppContainer.tsx | 100 ++++---- src/ui/views/UpdatePrompt.tsx | 6 +- 15 files changed, 965 insertions(+), 413 deletions(-) create mode 100644 src/tests/theme-manager.test.ts create mode 100644 src/ui/theme/ThemeManager.ts create mode 100644 src/ui/theme/detect-system-theme.ts diff --git a/README-en.md b/README-en.md index 5d8cae1..98296e2 100644 --- a/README-en.md +++ b/README-en.md @@ -103,129 +103,58 @@ Yes. Deep Code offers a full-featured VSCode extension, available on the [VSCode Deep Code supports multimodal input — you can paste images from the clipboard with `Ctrl+V`. However, `deepseek-v4` does not support multimodal yet. Some models have multimodal capabilities but impose strict limits on multi-turn dialogue requests. For multimodal input, we recommend using the Volcano Ark `Doubao-Seed-2.0-pro` model, which has the best integration. -### How to automatically send a Slack message after a task completes? +### How to send a Slack message after a task completes? -Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. For detailed steps, see [docs/notify_en.md](docs/notify_en.md). +Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. -### How do I enable web search? - -Deep Code comes with a built-in, free Web Search tool that works well for most use cases. If you prefer to use a custom script for web search, set the `webSearchTool` field in `~/.deepcode/settings.json` to the full path of your script. For detailed steps, refer to: https://github.com/qorzj/web_search_cli +> 📖 See [docs/notify_en.md](docs/notify_en.md) for details. -### Does it support Coding Plan? +### How do I enable web search? -Yes. Just set `env.BASE_URL` in `~/.deepcode/settings.json` to an OpenAI-compatible API endpoint. Take Volcano Ark's Coding Plan as an example: - -```json -{ - "env": { - "MODEL": "ark-code-latest", - "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", - "API_KEY": "**************" - }, - "thinkingEnabled": true -} -``` +Deep Code comes with a built-in, free Web Search tool that works well for most use cases. If you prefer to use a custom script for web search, set the `webSearchTool` field in `~/.deepcode/settings.json` to the full path of your script. For details, refer to: https://github.com/qorzj/web_search_cli ### How do I configure MCP? Deep Code supports MCP (Model Context Protocol) to connect external services such as GitHub, browsers, databases, and more. Configure the `mcpServers` field in `settings.json` to enable it, then use the `/mcp` command to view MCP server status and available tools. -For detailed setup instructions, see: [docs/mcp.md](docs/mcp.md) +> 📖 See [docs/mcp.md](docs/mcp.md) for details. -### How to configure Deep Code to send notifications after a task completes? +### How to configure notifications after a task completes? -When the AI assistant completes a task, Deep Code can automatically execute a notification script to send the task results to the specified channel (e.g., Slack, system notifications, etc.). +When the AI assistant completes a task, Deep Code can automatically execute a notification script to send the results to your specified channel (e.g., Slack, system notifications, etc.). -For detailed configuration instructions, see: [docs/notify_en.md](docs/notify_en.md) +> 📖 See [docs/notify_en.md](docs/notify_en.md) for details. ### Does Deep Code only support YOLO mode? No. Deep Code has a built-in fine-grained permission control mechanism that lets you confirm operations before the AI assistant executes shell commands, reads/writes files, accesses the network, and more. You can configure each permission scope's policy — always allow, always ask, or deny — via the `permissions` field in `settings.json`. See [docs/permission.md](docs/permission.md) for details. -### How do I customize the theme? - -Deep Code CLI includes multiple built-in preset themes, defaulting to the light theme (`light`). You can switch presets by setting `theme.preset` in `settings.json`, or set it to `"custom"` for full customization. - -**Using preset themes** - -Set `theme.preset` in `settings.json` to switch: - -```json -{ - "theme": { - "preset": "dark" - } -} -``` - -Available presets: `light` (default), `dark`, `github-light`, `github-dark`, `monokai`, `dracula`, `ansi`. - -You can also use the `/theme` command at runtime to open the theme picker with live preview. - -**Option 1: Partial overrides (preset="custom" + overrides)** +### Does it support Coding Plan? -Override only the colors you want to change; the rest keep their defaults: +Yes. Just set `env.BASE_URL` in `~/.deepcode/settings.json` to an OpenAI-compatible API endpoint. Take Volcano Ark's Coding Plan as an example: ```json { - "theme": { - "preset": "custom", - "overrides": { - "primary": "#ff6600", - "success": "greenBright" - } - } + "env": { + "MODEL": "ark-code-latest", + "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", + "API_KEY": "**************" + }, + "thinkingEnabled": true } ``` -**Option 2: Full customization (preset="custom" + tokens)** - -Provide a complete tokens object, merged on top of the light theme: - -```json -{ - "theme": { - "preset": "custom", - "tokens": { - "primary": "#229ac3", - "secondary": "#229ac3e6", - "success": "green", - "error": "red", - "warning": "yellow", - "info": "magenta", - "text": "white", - "textDim": "gray", - "textBright": "white", - "code": "cyan", - "border": "gray", - "gradients": ["#229ac3e6", "#229ac3e6"] - } - } -} -``` +### How to use and customize themes? -> Note: `overrides` and `tokens` only take effect when `preset` is set to `"custom"`. When `preset` is unset, the `light` theme is used by default. +Deep Code CLI includes 8 built-in preset themes, supports the `/theme` command for live preview and switching, and allows full customization via `settings.json`. -Default light theme (`light`) color values: +**Quick switch:** Run `/theme` to open the picker. Browse with arrow keys, confirm with Enter, cancel with Esc. -| Token | Default | Used For | -|-------|---------|----------| -| `primary` | `#229ac3` | Primary brand: user messages, selected items, status line bullets, Markdown headings | -| `secondary` | `#229ac3e6` | Secondary brand: welcome screen logo text/border, exit panel border | -| `success` | `#1a7f37` | Success: tool execution success, MCP ready, diff additions, low-risk permissions | -| `error` | `#d1242f` | Error: tool execution failure, error lines, diff deletions, high-risk permissions | -| `warning` | `#fa8c16` | Warning/in-progress: busy spinner, permission prompt border, list bullets, MCP starting | -| `info` | `#0969da` | Info: skill loading tips, image attachment status | -| `text` | `#3D4149` | Body text: permission prompt text, question text, ProcessStdout title | -| `textDim` | `#646A71` | Secondary text: status line params, search placeholder, diff context, Markdown blockquotes | -| `textBright` | `#1F2329` | Bright text: emphasized hints | -| `code` | `#787f8a` | Code blocks and inline code | -| `border` | `#999` | All component borders | -| `gradients` | `["#229ac3", "#8250df"]` | Logo and exit panel gradient colors | +**Available presets:** `light` (default), `dark`, `github-light`, `github-dark`, `monokai`, `dracula`, `ansi-light`, `ansi-dark`. -Color values support hex (`"#ff6600"`), hex with alpha (`"#229ac3e6"`), and chalk named colors (`"cyanBright"`, `"green"`). +**Custom themes:** Supports simplified color palette (`colors`), partial overrides (`overrides`), and full customization (`tokens`). -> Note: `tokens` takes priority over `overrides` — if both are specified, only `tokens` is used. Theme settings can be placed in the global `~/.deepcode/settings.json` or the project-root `.deepcode/settings.json`. +> 📖 See [docs/configuration.md](docs/configuration.md) for the full configuration guide. ## Contributing diff --git a/README-zh_CN.md b/README-zh_CN.md index a19adac..31e671a 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -105,7 +105,9 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 ### 怎样在任务完成后自动给 Slack 发消息? -编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。 +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。 + +> 📖 详细配置指南 [docs/notify.md](docs/notify.md)。 ### 怎样启用联网搜索功能? @@ -115,13 +117,13 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 -详细配置指南:[docs/mcp.md](docs/mcp.md) +> 📖 详细配置指南:[docs/mcp.md](docs/mcp.md) ### 如何配置 Deep Code 任务完成后发送通知? 当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 -详细配置指南:[docs/notify.md](docs/notify.md) +> 📖 详细配置指南:[docs/notify.md](docs/notify.md) ### Deep Code 只支持 YOLO 模式吗? @@ -142,90 +144,17 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 } ``` -### 如何自定义主题? - -Deep Code CLI 内置多套预设主题,默认使用浅色主题(`light`)。可在 `settings.json` 中设置 `theme.preset` 切换预设,或设为 `"custom"` 自定义颜色。 - -**使用预设主题** - -在 `settings.json` 中设置 `theme.preset` 即可切换: - -```json -{ - "theme": { - "preset": "dark" - } -} -``` - -可用预设:`light`(默认)、`dark`、`github-light`、`github-dark`、`monokai`、`dracula`、`ansi`。 - -也可在运行时使用 `/theme` 命令打开主题选择器,实时预览并切换。 - -**方式一:局部覆盖(preset="custom" + overrides)** - -只覆盖需要调整的颜色,其余保持默认值: - -```json -{ - "theme": { - "preset": "custom", - "overrides": { - "primary": "#ff6600", - "success": "greenBright" - } - } -} -``` - -**方式二:完全自定义(preset="custom" + tokens)** - -提供完整的 tokens 对象,基于浅色主题合并: - -```json -{ - "theme": { - "preset": "custom", - "tokens": { - "primary": "#229ac3", - "secondary": "#229ac3e6", - "success": "green", - "error": "red", - "warning": "yellow", - "info": "magenta", - "text": "white", - "textDim": "gray", - "textBright": "white", - "code": "cyan", - "border": "gray", - "gradients": ["#229ac3e6", "#229ac3e6"] - } - } -} -``` +### 如何使用和自定义主题? -> 注意:`overrides` 和 `tokens` 仅在 `preset` 为 `"custom"` 时生效。不配置 `preset` 时默认使用 `light` 主题。 +Deep Code CLI 内置 8 套预设主题,支持 `/theme` 命令实时预览切换,也支持通过 `settings.json` 自定义配色。 -默认浅色主题(`light`)色值: +**快速切换主题:** 运行 `/theme` 打开选择器,方向键浏览,Enter 确认,Esc 取消。 -| Token | 默认值 | 用途 | -|-------|--------|------| -| `primary` | `#229ac3` | 主品牌色:用户消息、选中项、状态行 bullet、Markdown 标题 | -| `secondary` | `#229ac3e6` | 辅助品牌色:欢迎屏 Logo 文字与边框、退出面板边框 | -| `success` | `#1a7f37` | 成功:工具执行成功、MCP ready、diff 新增行、低风险权限色 | -| `error` | `#d1242f` | 失败/错误:工具执行失败、Error 行、diff 删除行、高风险权限色 | -| `warning` | `#fa8c16` | 警告/进行中:忙时 spinner、权限提示边框、列表标记色、MCP 启动中 | -| `info` | `#0969da` | 特殊指示:技能加载提示、图片附件状态 | -| `text` | `#3D4149` | 主文字色:权限提示正文、问题文字、ProcessStdout 标题 | -| `textDim` | `#646A71` | 次要文字:状态行参数、搜索占位符、diff 上下文行、Markdown 引用块 | -| `textBright` | `#1F2329` | 亮色文字:强调提示 | -| `code` | `#787f8a` | 代码块/内联代码 | -| `border` | `#999` | 所有组件的边框色 | -| `gradients` | `["#229ac3", "#8250df"]` | Logo 与退出面板的渐变色数组 | +**可用预设:** `light`(默认)、`dark`、`github-light`、`github-dark`、`monokai`、`dracula`、`ansi-light`、`ansi-dark`。 -颜色值支持 hex(`"#ff6600"`)、hex 含透明度(`"#229ac3e6"`)、chalk 命名色(`"cyanBright"`、`"green"`)。 +**自定义主题:** 支持简化色板(`colors`)、部分覆盖(`overrides`)、完全自定义(`tokens`)三种方式。 -> 注意:`tokens` 优先级高于 `overrides`——如果同时指定两者,仅 `tokens` 生效。主题配置可放在全局 `~/.deepcode/settings.json` 或项目根 `.deepcode/settings.json` 中。 +> 📖 详细配置指南见 [docs/configuration.md](docs/configuration.md)。 ## 贡献 diff --git a/README.md b/README.md index a19adac..31e671a 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,9 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 ### 怎样在任务完成后自动给 Slack 发消息? -编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。 +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。 + +> 📖 详细配置指南 [docs/notify.md](docs/notify.md)。 ### 怎样启用联网搜索功能? @@ -115,13 +117,13 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 -详细配置指南:[docs/mcp.md](docs/mcp.md) +> 📖 详细配置指南:[docs/mcp.md](docs/mcp.md) ### 如何配置 Deep Code 任务完成后发送通知? 当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 -详细配置指南:[docs/notify.md](docs/notify.md) +> 📖 详细配置指南:[docs/notify.md](docs/notify.md) ### Deep Code 只支持 YOLO 模式吗? @@ -142,90 +144,17 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 } ``` -### 如何自定义主题? - -Deep Code CLI 内置多套预设主题,默认使用浅色主题(`light`)。可在 `settings.json` 中设置 `theme.preset` 切换预设,或设为 `"custom"` 自定义颜色。 - -**使用预设主题** - -在 `settings.json` 中设置 `theme.preset` 即可切换: - -```json -{ - "theme": { - "preset": "dark" - } -} -``` - -可用预设:`light`(默认)、`dark`、`github-light`、`github-dark`、`monokai`、`dracula`、`ansi`。 - -也可在运行时使用 `/theme` 命令打开主题选择器,实时预览并切换。 - -**方式一:局部覆盖(preset="custom" + overrides)** - -只覆盖需要调整的颜色,其余保持默认值: - -```json -{ - "theme": { - "preset": "custom", - "overrides": { - "primary": "#ff6600", - "success": "greenBright" - } - } -} -``` - -**方式二:完全自定义(preset="custom" + tokens)** - -提供完整的 tokens 对象,基于浅色主题合并: - -```json -{ - "theme": { - "preset": "custom", - "tokens": { - "primary": "#229ac3", - "secondary": "#229ac3e6", - "success": "green", - "error": "red", - "warning": "yellow", - "info": "magenta", - "text": "white", - "textDim": "gray", - "textBright": "white", - "code": "cyan", - "border": "gray", - "gradients": ["#229ac3e6", "#229ac3e6"] - } - } -} -``` +### 如何使用和自定义主题? -> 注意:`overrides` 和 `tokens` 仅在 `preset` 为 `"custom"` 时生效。不配置 `preset` 时默认使用 `light` 主题。 +Deep Code CLI 内置 8 套预设主题,支持 `/theme` 命令实时预览切换,也支持通过 `settings.json` 自定义配色。 -默认浅色主题(`light`)色值: +**快速切换主题:** 运行 `/theme` 打开选择器,方向键浏览,Enter 确认,Esc 取消。 -| Token | 默认值 | 用途 | -|-------|--------|------| -| `primary` | `#229ac3` | 主品牌色:用户消息、选中项、状态行 bullet、Markdown 标题 | -| `secondary` | `#229ac3e6` | 辅助品牌色:欢迎屏 Logo 文字与边框、退出面板边框 | -| `success` | `#1a7f37` | 成功:工具执行成功、MCP ready、diff 新增行、低风险权限色 | -| `error` | `#d1242f` | 失败/错误:工具执行失败、Error 行、diff 删除行、高风险权限色 | -| `warning` | `#fa8c16` | 警告/进行中:忙时 spinner、权限提示边框、列表标记色、MCP 启动中 | -| `info` | `#0969da` | 特殊指示:技能加载提示、图片附件状态 | -| `text` | `#3D4149` | 主文字色:权限提示正文、问题文字、ProcessStdout 标题 | -| `textDim` | `#646A71` | 次要文字:状态行参数、搜索占位符、diff 上下文行、Markdown 引用块 | -| `textBright` | `#1F2329` | 亮色文字:强调提示 | -| `code` | `#787f8a` | 代码块/内联代码 | -| `border` | `#999` | 所有组件的边框色 | -| `gradients` | `["#229ac3", "#8250df"]` | Logo 与退出面板的渐变色数组 | +**可用预设:** `light`(默认)、`dark`、`github-light`、`github-dark`、`monokai`、`dracula`、`ansi-light`、`ansi-dark`。 -颜色值支持 hex(`"#ff6600"`)、hex 含透明度(`"#229ac3e6"`)、chalk 命名色(`"cyanBright"`、`"green"`)。 +**自定义主题:** 支持简化色板(`colors`)、部分覆盖(`overrides`)、完全自定义(`tokens`)三种方式。 -> 注意:`tokens` 优先级高于 `overrides`——如果同时指定两者,仅 `tokens` 生效。主题配置可放在全局 `~/.deepcode/settings.json` 或项目根 `.deepcode/settings.json` 中。 +> 📖 详细配置指南见 [docs/configuration.md](docs/configuration.md)。 ## 贡献 diff --git a/docs/configuration.md b/docs/configuration.md index 9d014ba..66af0b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -212,36 +212,6 @@ Deep Code 支持自定义主题颜色,让你的终端界面更符合个人喜 } ``` -**可用的颜色 Token 分组** - -| 分组 | 说明 | Token | -| ---- | ---- | ----- | -| `text` | 文字层级 | `primary`、`secondary`、`muted`、`disabled`、`inverse` | -| `border` | 边框层级 | `default`、`subtle`、`active`、`focus` | -| `surface` | 表面色 | `default`、`elevated`、`muted`、`code`、`panel`、`quote`、`selection` | -| `brand` | 品牌色 | `primary`、`secondary`、`accent` | -| `status` | 状态色 | `success`、`warning`、`danger`、`info` | -| `risk` | 风险色 | `low`、`medium`、`high`、`critical` | -| `typography` | 排版色 | `h1`-`h6`、`paragraph`、`strong`、`emphasis`、`delete` | -| `link` | 链接色 | `default`、`visited`、`hover` | -| `inlineCode` | 行内代码 | `foreground`、`background`、`border` | -| `codeBlock` | 代码块 | `foreground`、`background`、`border`、`title`、`lineNumber`、`highlight` | -| `syntax` | 语法高亮 | `keyword`、`string`、`function`、`variable`、`property`、`type`、`number`、`operator`、`punctuation`、`comment`、`regexp`、`constant` | -| `blockquote` | 引用块 | `foreground`、`border` | -| `list` | 列表 | `bullet`、`ordered`、`marker` | -| `task` | 任务列表 | `checked`、`unchecked` | -| `table` | 表格 | `border`、`headerForeground`、`headerBackground`、`cellForeground` | -| `hr` | 分割线 | `foreground` | -| `admonition` | 提示框 | `note`、`tip`、`warning`、`important`、`caution` | -| `diff` | Diff | `added`、`removed`、`modified`、`addedBackground`、`removedBackground`、`modifiedBackground` | -| `agent` | Agent 状态 | `thinking`、`reasoning`、`toolCall`、`toolResult`、`streaming`、`completed` | -| `approval` | 审批 | `allow`、`deny`、`review` | -| `gradients` | 渐变 | `banner`、`logo`、`thinking`(每个是颜色数组) | - -颜色值支持以下格式: -- Hex 格式:`"#ff6600"`、`"#ff6600cc"`(带透明度) -- Chalk 命名颜色:`"greenBright"`、`"cyanBright"`、`"red"` 等 - **运行时切换主题** 在 CLI 中使用 `/theme` 命令打开主题选择器,使用方向键浏览主题,按 Space 或 Enter 确认选择: @@ -250,7 +220,7 @@ Deep Code 支持自定义主题颜色,让你的终端界面更符合个人喜 /theme # 打开主题选择器 ``` -选择器中可用的主题与上表一致。在浏览过程中会实时预览主题效果,按 Esc 可取消并恢复原主题。确认后会自动保存到 `settings.json`,下次启动时生效。 +在选择器中浏览过程中会实时预览主题效果,按 Esc 可取消并恢复原主题。确认后会自动保存到 `settings.json`,并立即生效。 #### `debugLogEnabled` — 调试日志 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 75b4e76..11bcc23 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -211,45 +211,15 @@ Advanced: use `base` to inherit from another preset, with `overrides` to tweak: } ``` -**Available Color Token Groups** - -| Group | Description | Tokens | -| ----- | ----------- | ------ | -| `text` | Text hierarchy | `primary`, `secondary`, `muted`, `disabled`, `inverse` | -| `border` | Border hierarchy | `default`, `subtle`, `active`, `focus` | -| `surface` | Surface colors | `default`, `elevated`, `muted`, `code`, `panel`, `quote`, `selection` | -| `brand` | Brand colors | `primary`, `secondary`, `accent` | -| `status` | Status colors | `success`, `warning`, `danger`, `info` | -| `risk` | Risk levels | `low`, `medium`, `high`, `critical` | -| `typography` | Typography colors | `h1`-`h6`, `paragraph`, `strong`, `emphasis`, `delete` | -| `link` | Link colors | `default`, `visited`, `hover` | -| `inlineCode` | Inline code | `foreground`, `background`, `border` | -| `codeBlock` | Code blocks | `foreground`, `background`, `border`, `title`, `lineNumber`, `highlight` | -| `syntax` | Syntax highlighting | `keyword`, `string`, `function`, `variable`, `property`, `type`, `number`, `operator`, `punctuation`, `comment`, `regexp`, `constant` | -| `blockquote` | Blockquotes | `foreground`, `border` | -| `list` | Lists | `bullet`, `ordered`, `marker` | -| `task` | Task lists | `checked`, `unchecked` | -| `table` | Tables | `border`, `headerForeground`, `headerBackground`, `cellForeground` | -| `hr` | Horizontal rules | `foreground` | -| `admonition` | Admonitions | `note`, `tip`, `warning`, `important`, `caution` | -| `diff` | Diff | `added`, `removed`, `modified`, `addedBackground`, `removedBackground`, `modifiedBackground` | -| `agent` | Agent states | `thinking`, `reasoning`, `toolCall`, `toolResult`, `streaming`, `completed` | -| `approval` | Approval | `allow`, `deny`, `review` | -| `gradients` | Gradients | `banner`, `logo`, `thinking` (each is a color array) | - -Color values support the following formats: -- Hex format: `"#ff6600"`, `"#ff6600cc"` (with alpha) -- Chalk named colors: `"greenBright"`, `"cyanBright"`, `"red"`, etc. - **Runtime Theme Switching** -Use the `/theme` command in the CLI to open the theme picker. Browse with arrow keys and confirm with Space or Enter: +In the CLI, use the `/theme` command to open the theme picker. Browse with arrow keys, confirm with Space or Enter: ``` /theme # Open theme picker ``` -Available themes in the picker match the table above. The theme is previewed in real-time as you browse. Press Esc to cancel and revert to the original theme. Once confirmed, the selection is automatically saved to `settings.json` and will take effect on the next launch. +As you browse in the picker, the theme is previewed in real-time. Press Esc to cancel and revert to the original theme. Once confirmed, the selection is automatically saved to `settings.json` and takes effect immediately. #### `debugLogEnabled` — Debug Log diff --git a/src/common/update-check.ts b/src/common/update-check.ts index 4c97d8e..09c0273 100644 --- a/src/common/update-check.ts +++ b/src/common/update-check.ts @@ -6,7 +6,6 @@ import * as path from "path"; import { render, type Instance } from "ink"; import chalk from "chalk"; import { UpdatePrompt, type UpdatePromptChoice } from "../ui"; -import { LIGHT_THEME } from "../ui/theme/presets"; import { killProcessTree } from "./process-tree"; export type PackageInfo = { @@ -59,7 +58,7 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< if (ok) { writeUpdateState({ ...state, pending: null }); process.stdout.write( - `\n${chalk.hex(LIGHT_THEME.status.danger)("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n` + `\n${chalk.red("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n` ); } return { installed: ok }; diff --git a/src/tests/theme-manager.test.ts b/src/tests/theme-manager.test.ts new file mode 100644 index 0000000..596bf5f --- /dev/null +++ b/src/tests/theme-manager.test.ts @@ -0,0 +1,357 @@ +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import { ThemeManager } from "../ui/theme/ThemeManager"; +import { LIGHT_THEME, DARK_THEME, PRESETS, setCurrentTheme, getCurrentThemeTokens } from "../ui/theme"; +import type { ThemeTokens, ThemePreset } from "../ui/theme"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a ThemeManager with mocked settings */ +function createManager(overrides?: { preset?: ThemePreset; themeSettings?: Record }): ThemeManager { + // The manager reads settings from disk on construction. + // For unit tests we rely on the default settings (no custom theme file). + return new ThemeManager(process.cwd()); +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +test("ThemeManager constructor initializes with default theme", () => { + const manager = createManager(); + const theme = manager.getTheme(); + assert.ok(theme, "getTheme() should return a theme"); + assert.ok(theme.mode === "light" || theme.mode === "dark", "theme should have a valid mode"); + assert.ok(typeof theme.text.primary === "string", "theme should have text.primary"); + manager.dispose(); +}); + +test("ThemeManager constructor initializes preset from settings", () => { + const manager = createManager(); + const preset = manager.getPreset(); + assert.ok(typeof preset === "string", "getPreset() should return a string"); + assert.ok( + [ + "light", + "dark", + "monokai", + "dracula", + "github-light", + "github-dark", + "ansi-light", + "ansi-dark", + "custom", + ].includes(preset), + `preset should be a valid ThemePreset, got: ${preset}` + ); + manager.dispose(); +}); + +test("ThemeManager constructor initializes terminalBg as null before init()", () => { + const manager = createManager(); + assert.equal(manager.getTerminalBackground(), null); + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// Lifecycle: init +// --------------------------------------------------------------------------- + +test("ThemeManager.init() detects terminal background", async () => { + const manager = createManager(); + await manager.init(); + const bg = manager.getTerminalBackground(); + assert.ok(bg === "light" || bg === "dark", `terminalBg should be 'light' or 'dark', got: ${bg}`); + manager.dispose(); +}); + +test("ThemeManager.init() refreshes theme after detection", async () => { + const manager = createManager(); + const themeBefore = manager.getTheme(); + await manager.init(); + const themeAfter = manager.getTheme(); + // Theme may or may not change depending on terminal background, but it should be valid + assert.ok(themeAfter, "theme should exist after init"); + assert.ok(typeof themeAfter.text.primary === "string", "theme should have valid text.primary after init"); + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// Lifecycle: polling +// --------------------------------------------------------------------------- + +test("ThemeManager.startPolling creates an interval", () => { + const manager = createManager(); + // Should not throw + manager.startPolling(100); + manager.stopPolling(); + manager.dispose(); +}); + +test("ThemeManager.stopPolling is idempotent", () => { + const manager = createManager(); + manager.stopPolling(); // no-op + manager.startPolling(100); + manager.stopPolling(); + manager.stopPolling(); // no-op + manager.dispose(); +}); + +test("ThemeManager.dispose stops polling and clears listeners", () => { + const manager = createManager(); + let called = false; + manager.onChange(() => { + called = true; + }); + manager.startPolling(100); + manager.dispose(); + // After dispose, listeners should be cleared + // We can't directly test this, but dispose should not throw + assert.ok(true, "dispose should not throw"); +}); + +// --------------------------------------------------------------------------- +// Query methods +// --------------------------------------------------------------------------- + +test("ThemeManager.getTheme returns valid ThemeTokens", () => { + const manager = createManager(); + const theme = manager.getTheme(); + assert.ok(theme.name, "theme should have name"); + assert.ok(theme.mode, "theme should have mode"); + assert.ok(theme.text, "theme should have text group"); + assert.ok(theme.brand, "theme should have brand group"); + assert.ok(theme.status, "theme should have status group"); + assert.ok(theme.gradients, "theme should have gradients group"); + manager.dispose(); +}); + +test("ThemeManager.getPreset returns a valid preset name", () => { + const manager = createManager(); + const preset = manager.getPreset(); + assert.ok(preset in PRESETS || preset === "custom", `invalid preset: ${preset}`); + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// previewTheme +// --------------------------------------------------------------------------- + +test("ThemeManager.previewTheme changes theme but does not change preset", () => { + const manager = createManager(); + const originalPreset = manager.getPreset(); + const originalTheme = manager.getTheme(); + + // Preview a different preset + const targetPreset: ThemePreset = originalPreset === "dark" ? "light" : "dark"; + manager.previewTheme(targetPreset); + + // Theme should change + const previewedTheme = manager.getTheme(); + assert.notEqual( + previewedTheme.text.primary, + originalTheme.text.primary, + "theme text.primary should change on preview" + ); + + // Preset should NOT change + assert.equal(manager.getPreset(), originalPreset, "preset should not change on preview"); + + manager.dispose(); +}); + +test("ThemeManager.previewTheme notifies listeners", () => { + const manager = createManager(); + let notified = false; + let notifiedTheme: ThemeTokens | null = null; + manager.onChange((theme) => { + notified = true; + notifiedTheme = theme; + }); + + manager.previewTheme("dark"); + assert.ok(notified, "listener should be called on preview"); + assert.ok(notifiedTheme, "listener should receive theme"); + + manager.dispose(); +}); + +test("ThemeManager.previewTheme with partial tokens works", () => { + const manager = createManager(); + const originalTheme = manager.getTheme(); + + manager.previewTheme({ brand: { primary: "#ff0000", secondary: "#ff0000", accent: "#ff0000" } }); + + const previewed = manager.getTheme(); + // The previewed theme should have the custom brand color + // (or it may be overridden by terminal contrast, but brand.primary is not affected by contrast) + assert.ok(previewed, "theme should exist after partial preview"); + + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// switchTheme +// --------------------------------------------------------------------------- + +test("ThemeManager.switchTheme changes theme and preset", () => { + const manager = createManager(); + const originalPreset = manager.getPreset(); + + const targetPreset: ThemePreset = originalPreset === "dark" ? "light" : "dark"; + manager.switchTheme(targetPreset); + + assert.equal(manager.getPreset(), targetPreset, "preset should change on switch"); + assert.ok(manager.getTheme(), "theme should exist after switch"); + + manager.dispose(); +}); + +test("ThemeManager.switchTheme notifies listeners", () => { + const manager = createManager(); + let notified = false; + manager.onChange(() => { + notified = true; + }); + + manager.switchTheme("dark"); + assert.ok(notified, "listener should be called on switch"); + + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// revertTheme +// --------------------------------------------------------------------------- + +test("ThemeManager.revertTheme restores saved preset after preview", () => { + const manager = createManager(); + const originalPreset = manager.getPreset(); + const originalTheme = manager.getTheme(); + + // Preview a different theme (not saved to settings) + const targetPreset: ThemePreset = originalPreset === "dark" ? "light" : "dark"; + manager.previewTheme(targetPreset); + assert.notEqual(manager.getTheme().text.primary, originalTheme.text.primary, "theme should change on preview"); + + // Revert should restore the saved theme + manager.revertTheme(); + assert.equal(manager.getPreset(), originalPreset, "preset should revert to original"); + + manager.dispose(); +}); + +test("ThemeManager.revertTheme notifies listeners", () => { + const manager = createManager(); + let notified = false; + manager.onChange(() => { + notified = true; + }); + + manager.switchTheme("dark"); + notified = false; + manager.revertTheme(); + assert.ok(notified, "listener should be called on revert"); + + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// onChange +// --------------------------------------------------------------------------- + +test("ThemeManager.onChange returns unsubscribe function", () => { + const manager = createManager(); + let callCount = 0; + const unsubscribe = manager.onChange(() => { + callCount++; + }); + + manager.previewTheme("dark"); + assert.equal(callCount, 1); + + unsubscribe(); + manager.previewTheme("light"); + assert.equal(callCount, 1, "listener should not be called after unsubscribe"); + + manager.dispose(); +}); + +test("ThemeManager.onChange receives correct theme and preset", () => { + const manager = createManager(); + let receivedTheme: ThemeTokens | null = null; + let receivedPreset: ThemePreset | null = null; + + manager.onChange((theme, preset) => { + receivedTheme = theme; + receivedPreset = preset; + }); + + manager.switchTheme("dark"); + + assert.ok(receivedTheme, "should receive theme"); + assert.equal(receivedPreset, "dark", "should receive preset 'dark'"); + + manager.dispose(); +}); + +test("Multiple listeners are all called", () => { + const manager = createManager(); + let count1 = 0; + let count2 = 0; + + manager.onChange(() => { + count1++; + }); + manager.onChange(() => { + count2++; + }); + + manager.previewTheme("dark"); + assert.equal(count1, 1, "first listener should be called"); + assert.equal(count2, 1, "second listener should be called"); + + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// refreshFromSettings +// --------------------------------------------------------------------------- + +test("ThemeManager.refreshFromSettings updates theme", () => { + const manager = createManager(); + const themeBefore = manager.getTheme(); + manager.refreshFromSettings(); + const themeAfter = manager.getTheme(); + // Should return a valid theme (may be same if settings unchanged) + assert.ok(themeAfter, "theme should exist after refresh"); + assert.ok(typeof themeAfter.text.primary === "string", "theme should have valid text.primary"); + manager.dispose(); +}); + +test("ThemeManager.refreshFromSettings notifies listeners", () => { + const manager = createManager(); + let notified = false; + manager.onChange(() => { + notified = true; + }); + + manager.refreshFromSettings(); + assert.ok(notified, "listener should be called on refresh"); + + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// setCurrentTheme integration +// --------------------------------------------------------------------------- + +test("ThemeManager.switchTheme updates global setCurrentTheme", () => { + const manager = createManager(); + manager.switchTheme("dark"); + const globalTheme = getCurrentThemeTokens(); + assert.equal(globalTheme.brand.primary, manager.getTheme().brand.primary, "global theme should match manager theme"); + manager.dispose(); +}); diff --git a/src/tests/theme.test.ts b/src/tests/theme.test.ts index 66cb41d..b0ea99e 100644 --- a/src/tests/theme.test.ts +++ b/src/tests/theme.test.ts @@ -82,7 +82,8 @@ test("LIGHT_THEME text colors match expected values", () => { assert.equal(LIGHT_THEME.text.primary, "#1F2328"); assert.equal(LIGHT_THEME.text.secondary, "#46484b"); assert.equal(LIGHT_THEME.text.muted, "#8b949e"); - assert.equal(LIGHT_THEME.text.inverse, "#1F2328"); + // inverse = 背景色,用于深色背景上的反色文字 + assert.equal(LIGHT_THEME.text.inverse, "#ffffff"); }); test("PRESETS map contains all presets", () => { @@ -119,19 +120,22 @@ test("resolveTheme returns LIGHT_THEME for explicit 'light' preset", () => { test("resolveTheme returns DARK_THEME for 'dark' preset", () => { const result = resolveTheme({ preset: "dark" }); assert.equal(result.brand.primary, DARK_THEME.brand.primary); - assert.equal(result.text.primary, DARK_THEME.text.primary); + assert.equal(result.status.success, DARK_THEME.status.success); + assert.equal(result.mode, "dark"); }); test("resolveTheme returns MONOKAI_THEME for 'monokai' preset", () => { const result = resolveTheme({ preset: "monokai" }); assert.equal(result.brand.primary, MONOKAI_THEME.brand.primary); - assert.equal(result.text.primary, MONOKAI_THEME.text.primary); + assert.equal(result.status.success, MONOKAI_THEME.status.success); + assert.equal(result.mode, "dark"); }); test("resolveTheme returns DRACULA_THEME for 'dracula' preset", () => { const result = resolveTheme({ preset: "dracula" }); assert.equal(result.brand.primary, DRACULA_THEME.brand.primary); - assert.equal(result.text.primary, DRACULA_THEME.text.primary); + assert.equal(result.status.success, DRACULA_THEME.status.success); + assert.equal(result.mode, "dark"); }); test("resolveTheme applies overrides when preset is 'custom'", () => { @@ -178,9 +182,8 @@ test("resolveTheme custom with base='dark' merges overrides onto DARK_THEME", () }); // brand.primary should be overridden assert.equal(result.brand.primary, "#ff0000"); - // other tokens should come from DARK_THEME + // mode and status should come from DARK_THEME (not affected by terminal contrast) assert.equal(result.mode, "dark"); - assert.equal(result.text.primary, DARK_THEME.text.primary); assert.equal(result.status.success, DARK_THEME.status.success); }); diff --git a/src/ui/theme/ThemeManager.ts b/src/ui/theme/ThemeManager.ts new file mode 100644 index 0000000..e0169eb --- /dev/null +++ b/src/ui/theme/ThemeManager.ts @@ -0,0 +1,185 @@ +import { resolveCurrentSettings, readSettings, readProjectSettings, writeSettings } from "../../settings"; +import type { ThemeTokens, ThemePreset, ThemeSettings } from "./types"; +import { detectSystemTheme, detectTerminalThemeAsync } from "./detect-system-theme"; +import { resolveTheme } from "./resolver"; +import { setCurrentTheme } from "./current-theme"; + +/** 主题变更回调 */ +type ThemeChangeListener = (theme: ThemeTokens, preset: ThemePreset) => void; + +/** + * 主题管理器。 + * 统一管理终端背景检测、主题解析、预览/切换/回退、运行时轮询。 + */ +export class ThemeManager { + private projectRoot: string; + private terminalBg: "light" | "dark" | null = null; + private currentTheme: ThemeTokens; + private currentPreset: ThemePreset; + private pollTimer: ReturnType | null = null; + private listeners = new Set(); + + constructor(projectRoot: string) { + this.projectRoot = projectRoot; + const settings = resolveCurrentSettings(projectRoot); + this.currentTheme = settings.theme; + this.currentPreset = this.loadPresetFromSettings(); + } + + // ——— 生命周期 ——— + + /** + * 异步初始化(含 OSC 11 终端背景查询)。 + * 应在 App 启动时调用一次。 + */ + async init(): Promise { + this.terminalBg = await detectTerminalThemeAsync(); + this.refreshFromSettings(); + } + + /** + * 启动运行时终端背景轮询。 + * 检测到变化时自动刷新主题。 + */ + startPolling(intervalMs = 3000): void { + this.stopPolling(); + this.pollTimer = setInterval(() => { + const detected = detectSystemTheme(); + if (this.terminalBg !== null && detected !== this.terminalBg) { + this.terminalBg = detected; + this.refreshFromSettings(); + } + }, intervalMs); + } + + /** 停止轮询 */ + stopPolling(): void { + if (this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + /** 注销所有资源 */ + dispose(): void { + this.stopPolling(); + this.listeners.clear(); + } + + // ——— 查询 ——— + + /** 获取当前主题 */ + getTheme(): ThemeTokens { + return this.currentTheme; + } + + /** 获取当前预设名称 */ + getPreset(): ThemePreset { + return this.currentPreset; + } + + /** 获取终端背景色 */ + getTerminalBackground(): "light" | "dark" | null { + return this.terminalBg; + } + + // ——— 操作 ——— + + /** + * 预览主题:仅切换 UI,不保存到 settings,不更新 currentPreset。 + * 用于 /theme 选择器中上下键浏览。 + */ + previewTheme(presetOrTokens: string | Partial): void { + const themeSettings = this.buildThemeSettings(presetOrTokens); + const newTheme = this.resolveWithContrast(themeSettings); + this.applyTheme(newTheme, this.currentPreset); + } + + /** + * 切换主题并持久化到 settings.json。 + * 用于 /theme 选择器中确认选择。 + */ + switchTheme(presetOrTokens: string | Partial): void { + const preset: ThemePreset = typeof presetOrTokens === "string" ? (presetOrTokens as ThemePreset) : "custom"; + const themeSettings = this.buildThemeSettings(presetOrTokens); + const newTheme = this.resolveWithContrast(themeSettings); + + this.currentPreset = preset; + this.applyTheme(newTheme, preset); + this.persistToSettings(preset, presetOrTokens); + } + + /** + * 回退到 settings 中已保存的主题。 + * 用于 /theme 选择器中按 Esc 取消。 + */ + revertTheme(): void { + this.currentPreset = this.loadPresetFromSettings(); + const savedSettings = resolveCurrentSettings(this.projectRoot); + this.applyTheme(savedSettings.theme, this.currentPreset); + } + + /** + * 从 settings 重新解析主题(终端背景变化时调用)。 + */ + refreshFromSettings(): void { + const themeSettings = this.loadThemeSettings(); + const newTheme = this.resolveWithContrast(themeSettings); + this.currentPreset = this.loadPresetFromSettings(); + this.applyTheme(newTheme, this.currentPreset); + } + + // ——— 监听 ——— + + /** 注册主题变更回调 */ + onChange(listener: ThemeChangeListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + // ——— 内部方法 ——— + + private applyTheme(theme: ThemeTokens, preset: ThemePreset): void { + this.currentTheme = theme; + setCurrentTheme(theme); + for (const listener of this.listeners) { + listener(theme, preset); + } + } + + private resolveWithContrast(themeSettings?: ThemeSettings): ThemeTokens { + // 先用缓存的终端背景解析,如果还没有缓存则同步检测 + if (this.terminalBg === null) { + this.terminalBg = detectSystemTheme(); + } + return resolveTheme(themeSettings, this.terminalBg); + } + + private buildThemeSettings(presetOrTokens: string | Partial): ThemeSettings { + if (typeof presetOrTokens === "string") { + return { preset: presetOrTokens as ThemePreset }; + } + return { preset: "custom", overrides: presetOrTokens }; + } + + private loadThemeSettings(): ThemeSettings | undefined { + const userSettings = readSettings(); + const projectSettings = readProjectSettings(this.projectRoot); + return userSettings?.theme ?? projectSettings?.theme; + } + + private loadPresetFromSettings(): ThemePreset { + const userSettings = readSettings(); + const projectSettings = readProjectSettings(this.projectRoot); + return (userSettings?.theme?.preset ?? projectSettings?.theme?.preset ?? "light") as ThemePreset; + } + + private persistToSettings(preset: ThemePreset, presetOrTokens: string | Partial): void { + const currentSettings = readSettings() ?? {}; + const newThemeSettings: ThemeSettings = { + preset, + ...(typeof presetOrTokens !== "string" ? { overrides: presetOrTokens } : {}), + }; + writeSettings({ ...currentSettings, theme: newThemeSettings }); + } +} diff --git a/src/ui/theme/colors-theme.ts b/src/ui/theme/colors-theme.ts index 9dce676..04bef3a 100644 --- a/src/ui/theme/colors-theme.ts +++ b/src/ui/theme/colors-theme.ts @@ -1,4 +1,5 @@ import type { ThemeTokens } from "./types"; +import type { DetectedTheme } from "./detect-system-theme"; /** * 用户配置的简化主题色板。 @@ -56,23 +57,45 @@ function dimHex(hex: string, ratio: number): string { /** * 从 ColorsTheme 推导完整的 ThemeTokens。 + * + * @param c 自定义主题色板 + * @param mode 主题模式 + * @param name 主题显示名称 + * @param terminalBackground - 终端实际背景色(可选)。当与 mode 不匹配时, + * 自动反转文字色以确保对比度。例如 light 主题 + 深色终端 → 文字变亮。 */ -export function buildThemeTokens(c: ColorsTheme, mode: "light" | "dark", name: string): ThemeTokens { +export function buildThemeTokens( + c: ColorsTheme, + mode: DetectedTheme, + name: string, + terminalBackground?: DetectedTheme +): ThemeTokens { const gradient = c.GradientColors ?? [c.AccentBlue, c.AccentPurple]; + // 判断是否需要反转文字色 + const bgMismatch = terminalBackground && terminalBackground !== mode; + const needInvert = bgMismatch ?? false; + + // 文字色:当终端背景与主题模式不匹配时反转,确保对比度 + // fg = 当前终端上实际使用的前景色 + // inverse = fg 的反色(用于 badge、高亮等需要反差的场景) + // muted/disabled = 中性色,深浅背景上都可读 + const fg = needInvert ? c.Background : c.Foreground; + const inv = needInvert ? c.Foreground : c.Background; + return { name, mode, text: { - primary: c.Foreground, - secondary: dimHex(c.Foreground, 0.4), + primary: fg, + secondary: dimHex(fg, 0.4), muted: c.Gray, disabled: dimHex(c.Gray, 0.5), - inverse: mode === "dark" ? c.Background : c.Foreground, + inverse: inv, }, border: { - default: dimHex(c.Foreground, 0.7), - subtle: dimHex(c.Foreground, 0.85), + default: dimHex(fg, 0.7), + subtle: dimHex(fg, 0.85), active: c.AccentBlue, focus: c.AccentBlue, }, @@ -109,9 +132,9 @@ export function buildThemeTokens(c: ColorsTheme, mode: "light" | "dark", name: s h4: c.AccentBlue, h5: c.AccentBlue, h6: c.AccentBlue, - paragraph: c.Foreground, - strong: c.Foreground, - emphasis: c.Foreground, + paragraph: fg, + strong: fg, + emphasis: fg, delete: c.AccentRed, }, link: { @@ -122,13 +145,13 @@ export function buildThemeTokens(c: ColorsTheme, mode: "light" | "dark", name: s inlineCode: { foreground: c.AccentBlue, background: dimHex(c.Background, 0.08), - border: dimHex(c.Foreground, 0.7), + border: dimHex(fg, 0.7), }, codeBlock: { - foreground: c.Foreground, + foreground: fg, background: mode === "dark" ? dimHex(c.Background, 0.15) : dimHex(c.Background, 0.05), - border: dimHex(c.Foreground, 0.7), - title: c.Foreground, + border: dimHex(fg, 0.7), + title: fg, lineNumber: c.Gray, highlight: mode === "dark" ? "#2d333b" : "#fff8c5", }, @@ -141,14 +164,14 @@ export function buildThemeTokens(c: ColorsTheme, mode: "light" | "dark", name: s type: mode === "dark" ? "#ffa657" : "#953800", number: c.AccentCyan, operator: c.AccentRed, - punctuation: c.Foreground, + punctuation: fg, comment: c.Comment, regexp: c.AccentGreen, constant: c.AccentCyan, }, blockquote: { foreground: c.Gray, - border: dimHex(c.Foreground, 0.7), + border: dimHex(fg, 0.7), }, list: { bullet: c.AccentYellowDim, @@ -157,15 +180,15 @@ export function buildThemeTokens(c: ColorsTheme, mode: "light" | "dark", name: s }, task: { checked: c.AccentGreen, - unchecked: dimHex(c.Foreground, 0.7), + unchecked: dimHex(fg, 0.7), }, table: { - border: dimHex(c.Foreground, 0.7), - headerForeground: c.Foreground, + border: dimHex(fg, 0.7), + headerForeground: fg, headerBackground: dimHex(c.Background, 0.08), - cellForeground: c.Foreground, + cellForeground: fg, }, - hr: { foreground: dimHex(c.Foreground, 0.7) }, + hr: { foreground: dimHex(fg, 0.7) }, admonition: { note: c.LightBlue, tip: c.AccentGreen, diff --git a/src/ui/theme/detect-system-theme.ts b/src/ui/theme/detect-system-theme.ts new file mode 100644 index 0000000..0c958e9 --- /dev/null +++ b/src/ui/theme/detect-system-theme.ts @@ -0,0 +1,204 @@ +import { execSync } from "child_process"; + +export type DetectedTheme = "dark" | "light"; + +// --------------------------------------------------------------------------- +// OSC 11 – query terminal background color +// --------------------------------------------------------------------------- + +const OSC11_TIMEOUT_MS = 200; + +interface Rgb { + r: number; + g: number; + b: number; +} + +/** + * Normalises a variable-length hex colour component (1–4 hex digits) to + * the [0, 1] range. + */ +function hexComponent(hex: string): number { + const max = 16 ** hex.length - 1; + return parseInt(hex, 16) / max; +} + +/** + * Parses an XParseColor RGB string returned by OSC 11. + * + * Accepted formats: + * - `rgb:RRRR/GGGG/BBBB` (1–4 hex digits per component) + * - `#RRGGBB` or `#RRRRGGGGBBBB` (equal-length triplets) + */ +export function parseOscRgb(data: string): Rgb | undefined { + const rgbMatch = /^rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i.exec(data); + if (rgbMatch) { + return { + r: hexComponent(rgbMatch[1]!), + g: hexComponent(rgbMatch[2]!), + b: hexComponent(rgbMatch[3]!), + }; + } + + const hashMatch = /^#([0-9a-f]+)$/i.exec(data); + if (hashMatch && hashMatch[1]!.length % 3 === 0) { + const hex = hashMatch[1]!; + const n = hex.length / 3; + return { + r: hexComponent(hex.slice(0, n)), + g: hexComponent(hex.slice(n, 2 * n)), + b: hexComponent(hex.slice(2 * n)), + }; + } + + return undefined; +} + +/** + * Converts an OSC 11 colour response into a dark/light theme decision + * using ITU-R BT.709 relative luminance. + */ +export function themeFromOscColor(data: string): DetectedTheme | undefined { + const rgb = parseOscRgb(data); + if (!rgb) return undefined; + const luminance = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b; + return luminance > 0.5 ? "light" : "dark"; +} + +/** + * Sends an OSC 11 query (`ESC ] 11 ; ? BEL`) to the terminal and waits + * for the response containing the background colour. + * + * Returns `undefined` when stdin/stdout is not a TTY or when no response + * arrives within OSC11_TIMEOUT_MS. + */ +export function detectOsc11Theme(): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return Promise.resolve(undefined); + } + + return new Promise((resolve) => { + const stdin = process.stdin; + let resolved = false; + let buffer = ""; + + const finish = (result: DetectedTheme | undefined) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + stdin.removeListener("data", onData); + resolve(result); + }; + + const timer = setTimeout(() => finish(undefined), OSC11_TIMEOUT_MS); + + const onData = (data: Buffer) => { + buffer += data.toString(); + // OSC response: ESC ] 11 ; BEL or ESC ] 11 ; ST + const match = /\x1b\]11;(.*?)(?:\x07|\x1b\\)/.exec(buffer); + if (match) { + finish(themeFromOscColor(match[1]!)); + } + }; + + stdin.on("data", onData); + process.stdout.write("\x1b]11;?\x07"); + }); +} + +// --------------------------------------------------------------------------- +// Synchronous detection helpers +// --------------------------------------------------------------------------- + +/** + * Detects the macOS system appearance using `defaults read -g AppleInterfaceStyle`. + * Returns 'dark' if Dark Mode is active, 'light' when the key is missing + * (the canonical macOS Light Mode signal), and undefined for any other failure + * so the caller can continue its fallback chain. + * Returns undefined on non-macOS platforms. + */ +export function detectMacOSTheme(): DetectedTheme | undefined { + if (process.platform !== "darwin") { + return undefined; + } + + try { + const result = execSync("defaults read -g AppleInterfaceStyle", { + encoding: "utf-8", + timeout: 3000, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + return result.toLowerCase() === "dark" ? "dark" : "light"; + } catch (error) { + const err = error as { stderr?: string | Buffer; message?: string }; + const stderr = typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString?.() ?? ""); + const message = err.message ?? ""; + // Only the explicit "… does not exist" error confirms Light Mode. + if (/does not exist/i.test(stderr) || /does not exist/i.test(message)) { + return "light"; + } + return undefined; + } +} + +/** + * Detects theme from the COLORFGBG environment variable. + * + * COLORFGBG format: "foreground;background" where values are ANSI color indices (0-15). + * Index 7 (light gray) and 9-15 → light. 0-6, 8 → dark. + */ +export function detectFromColorFgBg(): DetectedTheme | undefined { + const colorFgBg = process.env["COLORFGBG"]; + if (!colorFgBg) { + return undefined; + } + + const parts = colorFgBg.split(";"); + const bgStr = parts[parts.length - 1]; + if (bgStr === undefined) { + return undefined; + } + + const bg = parseInt(bgStr, 10); + if (isNaN(bg)) { + return undefined; + } + + if (bg === 7 || (bg >= 9 && bg <= 15)) { + return "light"; + } + + return "dark"; +} + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +/** + * Synchronous theme detection (for theme dialog live-preview). + * + * Order: COLORFGBG → macOS system appearance → default dark. + */ +export function detectSystemTheme(): DetectedTheme { + return detectFromColorFgBg() ?? detectMacOSTheme() ?? "dark"; +} + +/** + * Asynchronous theme detection (for startup). + * + * Checks cheap synchronous sources first (COLORFGBG) so we never pay the + * ~200 ms OSC 11 timeout when a fast answer is already available. + * + * Order: COLORFGBG → OSC 11 → macOS system appearance → default dark. + */ +export async function detectTerminalThemeAsync(): Promise { + const colorFgBgResult = detectFromColorFgBg(); + if (colorFgBgResult) return colorFgBgResult; + + const osc11Result = await detectOsc11Theme(); + if (osc11Result) return osc11Result; + + return detectMacOSTheme() ?? "dark"; +} diff --git a/src/ui/theme/index.ts b/src/ui/theme/index.ts index 29d2258..c7f5fc9 100644 --- a/src/ui/theme/index.ts +++ b/src/ui/theme/index.ts @@ -13,6 +13,7 @@ export { PRESETS, } from "./presets"; export { resolveTheme } from "./resolver"; +export { ThemeManager } from "./ThemeManager"; export { ThemeProvider, useTheme } from "./ThemeContext"; export { createThemedChalk } from "./chalk-theme"; export type { ThemedChalk } from "./chalk-theme"; diff --git a/src/ui/theme/resolver.ts b/src/ui/theme/resolver.ts index e361e6e..dadd175 100644 --- a/src/ui/theme/resolver.ts +++ b/src/ui/theme/resolver.ts @@ -29,17 +29,18 @@ function deepMerge(left: T, right: object): T { * - 未配置 / preset="light":使用浅色主题 LIGHT_THEME * - preset 为预设名称(如 "dark", "monokai", "dracula"):使用对应预设 * - preset="custom":使用用户自定义 tokens 或 overrides 合并到 LIGHT_THEME + * - 当 terminalBg 与主题 mode 不匹配时,自动反转文字色 */ -export function resolveTheme(themeSettings: ThemeSettings | undefined): ThemeTokens { +export function resolveTheme(themeSettings: ThemeSettings | undefined, terminalBg?: "light" | "dark"): ThemeTokens { if (!themeSettings) { - return LIGHT_THEME; + return applyTerminalContrast(LIGHT_THEME, terminalBg); } const { preset } = themeSettings; // preset 为预设名称时使用对应预设 if (preset && preset !== "custom" && preset in PRESETS) { - return PRESETS[preset]; + return applyTerminalContrast(PRESETS[preset], terminalBg); } // preset="custom":基于 base 预设应用用户自定义 @@ -49,19 +50,77 @@ export function resolveTheme(themeSettings: ThemeSettings | undefined): ThemeTok // 优先级:tokens > colors + overrides > overrides > colors if (themeSettings.tokens) { - return deepMerge(baseTheme, themeSettings.tokens); + return deepMerge(applyTerminalContrast(baseTheme, terminalBg), themeSettings.tokens); } if (themeSettings.colors && themeSettings.overrides) { - return deepMerge(buildThemeTokens(themeSettings.colors, baseTheme.mode, "Custom"), themeSettings.overrides); + return deepMerge( + buildThemeTokens(themeSettings.colors, baseTheme.mode, "Custom", terminalBg), + themeSettings.overrides + ); } if (themeSettings.colors) { - return buildThemeTokens(themeSettings.colors, baseTheme.mode, "Custom"); + return buildThemeTokens(themeSettings.colors, baseTheme.mode, "Custom", terminalBg); } if (themeSettings.overrides) { - return deepMerge(baseTheme, themeSettings.overrides); + return deepMerge(applyTerminalContrast(baseTheme, terminalBg), themeSettings.overrides); } } // 未配置或无效 preset,回退默认 - return LIGHT_THEME; + return applyTerminalContrast(LIGHT_THEME, terminalBg); +} + +/** + * 当终端背景与主题模式不匹配时,反转文字相关 token 以确保对比度。 + * 只反转 primary ↔ inverse,muted/disabled(中性色)保持不变。 + */ +export function applyTerminalContrast(base: ThemeTokens, terminalBg?: "light" | "dark"): ThemeTokens { + if (!terminalBg || terminalBg === base.mode) return base; + + return { + ...base, + text: { + primary: base.text.inverse, + secondary: base.text.inverse, + muted: base.text.muted, + disabled: base.text.disabled, + inverse: base.text.primary, + }, + typography: { + ...base.typography, + paragraph: base.text.inverse, + strong: base.text.inverse, + emphasis: base.text.inverse, + }, + inlineCode: { + ...base.inlineCode, + border: base.text.inverse, + }, + codeBlock: { + ...base.codeBlock, + foreground: base.text.inverse, + border: base.text.inverse, + title: base.text.inverse, + lineNumber: base.text.disabled, + }, + syntax: { + ...base.syntax, + punctuation: base.text.inverse, + }, + blockquote: { + ...base.blockquote, + border: base.text.inverse, + }, + task: { + ...base.task, + unchecked: base.text.inverse, + }, + table: { + ...base.table, + border: base.text.inverse, + headerForeground: base.text.inverse, + cellForeground: base.text.inverse, + }, + hr: { foreground: base.text.inverse }, + }; } diff --git a/src/ui/views/AppContainer.tsx b/src/ui/views/AppContainer.tsx index c3d7197..903b6b2 100644 --- a/src/ui/views/AppContainer.tsx +++ b/src/ui/views/AppContainer.tsx @@ -1,10 +1,9 @@ -import React, { useEffect, useState, useCallback, useMemo } from "react"; -import { AppContext } from "../contexts"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AppContext, RawModeProvider } from "../contexts"; import App from "./App"; -import { RawModeProvider } from "../contexts"; -import { ThemeProvider, setCurrentTheme, resolveTheme } from "../theme"; -import { resolveCurrentSettings, readSettings, readProjectSettings, writeSettings } from "../../settings"; -import type { ThemeTokens, ThemePreset, ThemeSettings } from "../theme"; +import type { ThemePreset, ThemeTokens } from "../theme"; +import { ThemeManager, ThemeProvider } from "../theme"; +import { readProjectSettings, readSettings, resolveCurrentSettings } from "../../settings"; const AppContainer: React.FC<{ projectRoot: string; @@ -21,6 +20,43 @@ const AppContainer: React.FC<{ }); const [themeVersion, setThemeVersion] = useState(0); + // ThemeManager 实例(随 projectRoot 变化重建) + const managerRef = useRef(null); + const getManager = useCallback(() => { + if (!managerRef.current) { + managerRef.current = new ThemeManager(projectRoot); + } + return managerRef.current; + }, [projectRoot]); + + // 监听主题变更,同步到 React 状态 + useEffect(() => { + const manager = getManager(); + return manager.onChange((newTheme, preset) => { + setTheme(newTheme); + setCurrentPreset(preset); + setThemeVersion((v) => v + 1); + }); + }, [getManager]); + + // 同步全局 chalk 主题 + useEffect(() => { + const manager = getManager(); + setTheme(manager.getTheme()); + }, [getManager]); + + // 启动:异步检测终端背景 → 刷新主题 → 开始轮询 + useEffect(() => { + const manager = getManager(); + void manager.init().then(() => { + manager.startPolling(); + }); + return () => { + manager.dispose(); + managerRef.current = null; + }; + }, [projectRoot, getManager]); + // 检查是否有 custom 主题配置 const hasCustomThemeConfig = useMemo(() => { const userSettings = readSettings(); @@ -29,63 +65,23 @@ const AppContainer: React.FC<{ return themeSettings?.preset === "custom" && !!(themeSettings?.overrides || themeSettings?.tokens); }, [projectRoot]); - useEffect(() => { - // 初始设置全局 chalk 主题 - setCurrentTheme(theme); - }, [theme]); - - /** 应用主题到 UI(不持久化) */ - const applyThemeToUI = useCallback((newTheme: ThemeTokens) => { - setTheme(newTheme); - setCurrentTheme(newTheme); - setThemeVersion((v) => v + 1); - }, []); - - /** 预览主题:仅切换 UI,不保存到 settings,不更新 currentPreset */ const previewTheme = useCallback( (presetOrTokens: string | Partial) => { - const newTheme = resolveTheme( - typeof presetOrTokens === "string" - ? { preset: presetOrTokens as ThemePreset } - : { preset: "custom", overrides: presetOrTokens } - ); - applyThemeToUI(newTheme); + getManager().previewTheme(presetOrTokens); }, - [applyThemeToUI] + [getManager] ); - /** 切换主题并持久化到 settings.json */ const switchTheme = useCallback( (presetOrTokens: string | Partial) => { - const preset: ThemePreset = typeof presetOrTokens === "string" ? (presetOrTokens as ThemePreset) : "custom"; - const newTheme = resolveTheme( - typeof presetOrTokens === "string" - ? { preset: presetOrTokens as ThemePreset } - : { preset: "custom", overrides: presetOrTokens } - ); - - setCurrentPreset(preset); - applyThemeToUI(newTheme); - - // 持久化到 settings.json - const currentSettings = readSettings() ?? {}; - const newThemeSettings: ThemeSettings = { - preset, - ...(typeof presetOrTokens !== "string" ? { overrides: presetOrTokens } : {}), - }; - writeSettings({ ...currentSettings, theme: newThemeSettings }); + getManager().switchTheme(presetOrTokens); }, - [applyThemeToUI] + [getManager] ); - /** 回退到 settings 中已保存的主题 */ const revertTheme = useCallback(() => { - const savedSettings = resolveCurrentSettings(projectRoot); - const userSettings = readSettings(); - const projectSettings = readProjectSettings(projectRoot); - setCurrentPreset((userSettings?.theme?.preset ?? projectSettings?.theme?.preset ?? "light") as ThemePreset); - applyThemeToUI(savedSettings.theme); - }, [projectRoot, applyThemeToUI]); + getManager().revertTheme(); + }, [getManager]); return ( - + Deep Code latest version has been released: {currentVersion} -> {latestVersion} {options.map((option, index) => { const selected = index === selectedIndex; return ( - + {selected ? "> " : " "} {index + 1}. {option.label} From bc475e1268c9606c50cc2acd49c842ebad6e7de5 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 2 Jun 2026 12:02:11 +0800 Subject: [PATCH 15/19] =?UTF-8?q?test(theme):=20=E4=B8=BA=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=B8=BB=E9=A2=98=E6=A3=80=E6=B5=8B=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E6=B7=BB=E5=8A=A0=E8=AF=A6=E7=BB=86=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 parseOscRgb 函数各种格式输入的测试用例,包括 rgb、rgba、短十六进制和无效输入 - 测试 themeFromOscColor 函数对于不同背景颜色的亮暗主题判断 - 验证 detectFromColorFgBg 函数对环境变量 COLORFGBG 不同值的正确处理 - 编写 detectSystemTheme 函数的主题检测结果有效性及优先级测试 - 覆盖异常及边界情况,确保函数在无环境变量或无效输入时表现稳定 --- src/tests/detect-system-theme.test.ts | 318 ++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 src/tests/detect-system-theme.test.ts diff --git a/src/tests/detect-system-theme.test.ts b/src/tests/detect-system-theme.test.ts new file mode 100644 index 0000000..f54c1e7 --- /dev/null +++ b/src/tests/detect-system-theme.test.ts @@ -0,0 +1,318 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + parseOscRgb, + themeFromOscColor, + detectFromColorFgBg, + detectSystemTheme, +} from "../ui/theme/detect-system-theme"; + +// --------------------------------------------------------------------------- +// parseOscRgb +// --------------------------------------------------------------------------- + +test("parseOscRgb parses rgb:RR/GG/BB format", () => { + const result = parseOscRgb("rgb:0000/0000/0000"); + assert.ok(result); + assert.equal(result.r, 0); + assert.equal(result.g, 0); + assert.equal(result.b, 0); +}); + +test("parseOscRgb parses rgb:RRRR/GGGG/BBBB format (bright white)", () => { + const result = parseOscRgb("rgb:ffff/ffff/ffff"); + assert.ok(result); + assert.equal(result.r, 1); + assert.equal(result.g, 1); + assert.equal(result.b, 1); +}); + +test("parseOscRgb parses rgb:RR/GG/BB with mid values", () => { + const result = parseOscRgb("rgb:8080/8080/8080"); + assert.ok(result); + assert.ok(Math.abs(result.r - 0.50196) < 0.001); + assert.ok(Math.abs(result.g - 0.50196) < 0.001); + assert.ok(Math.abs(result.b - 0.50196) < 0.001); +}); + +test("parseOscRgb parses rgb with short hex (1 digit per component)", () => { + const result = parseOscRgb("rgb:f/f/f"); + assert.ok(result); + assert.equal(result.r, 1); + assert.equal(result.g, 1); + assert.equal(result.b, 1); +}); + +test("parseOscRgb parses rgb with 2-digit hex", () => { + const result = parseOscRgb("rgb:ff/80/00"); + assert.ok(result); + assert.equal(result.r, 1); + assert.ok(Math.abs(result.g - 0.50196) < 0.001); + assert.equal(result.b, 0); +}); + +test("parseOscRgb parses rgba format", () => { + const result = parseOscRgb("rgba:ffff/ffff/ffff/ffff"); + assert.ok(result); + assert.equal(result.r, 1); + assert.equal(result.g, 1); + assert.equal(result.b, 1); +}); + +test("parseOscRgb parses #RRGGBB format", () => { + const result = parseOscRgb("#000000"); + assert.ok(result); + assert.equal(result.r, 0); + assert.equal(result.g, 0); + assert.equal(result.b, 0); +}); + +test("parseOscRgb parses #RRGGBB format (white)", () => { + const result = parseOscRgb("#ffffff"); + assert.ok(result); + assert.equal(result.r, 1); + assert.equal(result.g, 1); + assert.equal(result.b, 1); +}); + +test("parseOscRgb parses #RRRRGGGGBBBB format", () => { + const result = parseOscRgb("#ffff80800000"); + assert.ok(result); + assert.equal(result.r, 1); + assert.ok(Math.abs(result.g - 0.50196) < 0.001); + assert.equal(result.b, 0); +}); + +test("parseOscRgb returns undefined for invalid format", () => { + assert.equal(parseOscRgb("invalid"), undefined); + assert.equal(parseOscRgb(""), undefined); + assert.equal(parseOscRgb("rgb:zz/zz/zz"), undefined); + assert.equal(parseOscRgb("#gggggg"), undefined); +}); + +test("parseOscRgb returns undefined for #RRGGBB with non-multiple-of-3 length", () => { + assert.equal(parseOscRgb("#ffff"), undefined); + assert.equal(parseOscRgb("#fffff"), undefined); +}); + +test("parseOscRgb is case-insensitive", () => { + const lower = parseOscRgb("rgb:ffff/0000/8080"); + const upper = parseOscRgb("RGB:FFFF/0000/8080"); + assert.ok(lower); + assert.ok(upper); + assert.equal(lower.r, upper.r); + assert.equal(lower.g, upper.g); + assert.equal(lower.b, upper.b); +}); + +// --------------------------------------------------------------------------- +// themeFromOscColor +// --------------------------------------------------------------------------- + +test("themeFromOscColor returns 'light' for white background", () => { + assert.equal(themeFromOscColor("rgb:ffff/ffff/ffff"), "light"); + assert.equal(themeFromOscColor("#ffffff"), "light"); +}); + +test("themeFromOscColor returns 'dark' for black background", () => { + assert.equal(themeFromOscColor("rgb:0000/0000/0000"), "dark"); + assert.equal(themeFromOscColor("#000000"), "dark"); +}); + +test("themeFromOscColor returns 'light' for bright background", () => { + // Luminance of #c0c0c0 (silver) ≈ 0.53 > 0.5 + assert.equal(themeFromOscColor("#c0c0c0"), "light"); +}); + +test("themeFromOscColor returns 'dark' for dim background", () => { + // Luminance of #404040 ≈ 0.04 < 0.5 + assert.equal(themeFromOscColor("#404040"), "dark"); +}); + +test("themeFromOscColor uses ITU-R BT.709 luminance weights", () => { + // Pure green has highest luminance weight (0.7152) + // rgb:0000/8080/0000 → luminance ≈ 0.7152 * 0.5 ≈ 0.358 → dark + assert.equal(themeFromOscColor("rgb:0000/8080/0000"), "dark"); + + // Pure green bright → luminance > 0.5 → light + assert.equal(themeFromOscColor("rgb:0000/ffff/0000"), "light"); + + // Pure blue has lowest weight (0.0722) + // rgb:0000/0000/ffff → luminance ≈ 0.0722 → dark + assert.equal(themeFromOscColor("rgb:0000/0000/ffff"), "dark"); +}); + +test("themeFromOscColor returns undefined for invalid input", () => { + assert.equal(themeFromOscColor("invalid"), undefined); + assert.equal(themeFromOscColor(""), undefined); +}); + +// --------------------------------------------------------------------------- +// detectFromColorFgBg +// --------------------------------------------------------------------------- + +test("detectFromColorFgBg returns 'dark' for dark background indices", () => { + const original = process.env["COLORFGBG"]; + try { + // Index 0 = black + process.env["COLORFGBG"] = "15;0"; + assert.equal(detectFromColorFgBg(), "dark"); + + // Index 1 = red + process.env["COLORFGBG"] = "15;1"; + assert.equal(detectFromColorFgBg(), "dark"); + + // Index 6 = cyan + process.env["COLORFGBG"] = "15;6"; + assert.equal(detectFromColorFgBg(), "dark"); + + // Index 8 = bright black (dark gray) + process.env["COLORFGBG"] = "15;8"; + assert.equal(detectFromColorFgBg(), "dark"); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg returns 'light' for light background indices", () => { + const original = process.env["COLORFGBG"]; + try { + // Index 7 = light gray + process.env["COLORFGBG"] = "0;7"; + assert.equal(detectFromColorFgBg(), "light"); + + // Index 9 = bright red + process.env["COLORFGBG"] = "0;9"; + assert.equal(detectFromColorFgBg(), "light"); + + // Index 15 = bright white + process.env["COLORFGBG"] = "0;15"; + assert.equal(detectFromColorFgBg(), "light"); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg handles foreground;background format", () => { + const original = process.env["COLORFGBG"]; + try { + // Only the last segment (background) matters + process.env["COLORFGBG"] = "0;15"; + assert.equal(detectFromColorFgBg(), "light"); + + process.env["COLORFGBG"] = "15;0"; + assert.equal(detectFromColorFgBg(), "dark"); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg handles single value (background only)", () => { + const original = process.env["COLORFGBG"]; + try { + process.env["COLORFGBG"] = "0"; + assert.equal(detectFromColorFgBg(), "dark"); + + process.env["COLORFGBG"] = "15"; + assert.equal(detectFromColorFgBg(), "light"); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg returns undefined when COLORFGBG is not set", () => { + const original = process.env["COLORFGBG"]; + try { + delete process.env["COLORFGBG"]; + assert.equal(detectFromColorFgBg(), undefined); + } finally { + if (original !== undefined) { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg returns undefined for non-numeric value", () => { + const original = process.env["COLORFGBG"]; + try { + process.env["COLORFGBG"] = "abc"; + assert.equal(detectFromColorFgBg(), undefined); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg returns undefined for empty string", () => { + const original = process.env["COLORFGBG"]; + try { + process.env["COLORFGBG"] = ""; + assert.equal(detectFromColorFgBg(), undefined); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +// --------------------------------------------------------------------------- +// detectSystemTheme (sync entry point) +// --------------------------------------------------------------------------- + +test("detectSystemTheme returns a valid theme", () => { + const result = detectSystemTheme(); + assert.ok(result === "light" || result === "dark"); +}); + +test("detectSystemTheme prefers COLORFGBG over other sources", () => { + const original = process.env["COLORFGBG"]; + try { + process.env["COLORFGBG"] = "0;15"; // light background + assert.equal(detectSystemTheme(), "light"); + + process.env["COLORFGBG"] = "15;0"; // dark background + assert.equal(detectSystemTheme(), "dark"); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectSystemTheme falls back to 'dark' when no sources available", () => { + const original = process.env["COLORFGBG"]; + try { + delete process.env["COLORFGBG"]; + // On non-macOS, detectMacOSTheme returns undefined → falls back to "dark" + // On macOS, it depends on system settings + const result = detectSystemTheme(); + assert.ok(result === "light" || result === "dark"); + } finally { + if (original !== undefined) { + process.env["COLORFGBG"] = original; + } + } +}); From a52d6844ba50d34a610a7592f3a3e315801b910b Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 2 Jun 2026 12:09:40 +0800 Subject: [PATCH 16/19] =?UTF-8?q?docs(readme-en):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E7=89=88=E6=96=87=E6=A1=A3=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E4=BB=A5=E6=8C=87=E5=90=91=E6=AD=A3=E7=A1=AE=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正多个文档链接,指向_en结尾的英文版文档 - 保持链接路径统一,避免指向中文或错误文件 - 优化文档引用以提升阅读准确性与一致性 - 涉及配置、多功能支持和权限说明部分链接调整 - 保证英文用户访问正确的参考资料与帮助文档 --- README-en.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README-en.md b/README-en.md index 98296e2..ed3a0e1 100644 --- a/README-en.md +++ b/README-en.md @@ -47,7 +47,7 @@ Create `~/.deepcode/settings.json`: The configuration file is shared with the [Deep Code VSCode extension](https://github.com/lessweb/deepcode) — configure once, use everywhere. -For complete configuration details (multi-level priority, environment variables, etc.), see [docs/configuration.md](docs/configuration.md). +For complete configuration details (multi-level priority, environment variables, etc.), see [docs/configuration_en.md](docs/configuration_en.md). ## Key Features @@ -117,7 +117,7 @@ Deep Code comes with a built-in, free Web Search tool that works well for most u Deep Code supports MCP (Model Context Protocol) to connect external services such as GitHub, browsers, databases, and more. Configure the `mcpServers` field in `settings.json` to enable it, then use the `/mcp` command to view MCP server status and available tools. -> 📖 See [docs/mcp.md](docs/mcp.md) for details. +> 📖 See [docs/mcp_en.md](docs/mcp.md) for details. ### How to configure notifications after a task completes? @@ -127,7 +127,7 @@ When the AI assistant completes a task, Deep Code can automatically execute a no ### Does Deep Code only support YOLO mode? -No. Deep Code has a built-in fine-grained permission control mechanism that lets you confirm operations before the AI assistant executes shell commands, reads/writes files, accesses the network, and more. You can configure each permission scope's policy — always allow, always ask, or deny — via the `permissions` field in `settings.json`. See [docs/permission.md](docs/permission.md) for details. +No. Deep Code has a built-in fine-grained permission control mechanism that lets you confirm operations before the AI assistant executes shell commands, reads/writes files, accesses the network, and more. You can configure each permission scope's policy — always allow, always ask, or deny — via the `permissions` field in `settings.json`. See [docs/permission_en.md](docs/permission.md) for details. ### Does it support Coding Plan? @@ -154,7 +154,7 @@ Deep Code CLI includes 8 built-in preset themes, supports the `/theme` command f **Custom themes:** Supports simplified color palette (`colors`), partial overrides (`overrides`), and full customization (`tokens`). -> 📖 See [docs/configuration.md](docs/configuration.md) for the full configuration guide. +> 📖 See [docs/configuration_en.md](docs/configuration_en.md) for the full configuration guide. ## Contributing From a5da21fba363fcef1c440ba07910c37a4b7cdc79 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 2 Jun 2026 15:38:29 +0800 Subject: [PATCH 17/19] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E7=AE=A1=E7=90=86=E5=92=8C=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 ExitSummaryView 中对宽度的传参,简化组件接口 - 在颜色淡化函数中添加非 hex 色值直接返回的处理,避免错误 - 修改 inlineCode 颜色为更合适的透明紫色 - 修正 markdown 代码块渲染,使用正确的颜色函数 - 精简 slash-commands 中 /new 命令描述文本 - 重构 ThemeManager 中的 revertTheme 方法,改用加载主题设置并解析对比度后应用新主题 --- src/ui/components/MessageView/markdown.ts | 2 +- src/ui/core/slash-commands.ts | 2 +- src/ui/theme/ThemeManager.ts | 5 +++-- src/ui/theme/colors-theme.ts | 8 ++++++-- src/ui/views/App.tsx | 2 +- src/ui/views/ExitSummaryView.tsx | 1 - 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index c7f4a40..194881e 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -39,7 +39,7 @@ export function renderMarkdownSegments(text: string, maxWidth?: number): Markdow for (const seg of fenceSegments) { if (seg.kind === "code") { const langTag = seg.lang ? tc.dim(`[${seg.lang}]`) + "\n" : ""; - segments.push({ kind: "code", body: langTag + tc.dim(seg.body), lang: seg.lang }); + segments.push({ kind: "code", body: langTag + tc.code(seg.body), lang: seg.lang }); continue; } const blocks = splitTableBlocks(seg.body); diff --git a/src/ui/core/slash-commands.ts b/src/ui/core/slash-commands.ts index 41010ed..2c98e4a 100644 --- a/src/ui/core/slash-commands.ts +++ b/src/ui/core/slash-commands.ts @@ -46,7 +46,7 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ kind: "new", name: "new", label: "/new", - description: "Start a new session with empty context; previous session stays on disk (resumable with /resume)", + description: "Start a new session (previous session resumable with /resume)", }, { kind: "init", diff --git a/src/ui/theme/ThemeManager.ts b/src/ui/theme/ThemeManager.ts index e0169eb..458c376 100644 --- a/src/ui/theme/ThemeManager.ts +++ b/src/ui/theme/ThemeManager.ts @@ -115,8 +115,9 @@ export class ThemeManager { */ revertTheme(): void { this.currentPreset = this.loadPresetFromSettings(); - const savedSettings = resolveCurrentSettings(this.projectRoot); - this.applyTheme(savedSettings.theme, this.currentPreset); + const themeSettings = this.loadThemeSettings(); + const newTheme = this.resolveWithContrast(themeSettings); + this.applyTheme(newTheme, this.currentPreset); } /** diff --git a/src/ui/theme/colors-theme.ts b/src/ui/theme/colors-theme.ts index 04bef3a..b655f4b 100644 --- a/src/ui/theme/colors-theme.ts +++ b/src/ui/theme/colors-theme.ts @@ -42,9 +42,13 @@ export interface ColorsTheme { /** * 将 hex 颜色淡化(混合灰色)。 + * 对非 hex 命名色(如 ANSI 的 "white"、"black")返回原色,不做淡化。 */ function dimHex(hex: string, ratio: number): string { - const h = hex.replace("#", ""); + if (!hex.startsWith("#")) { + return hex; + } + const h = hex.slice(1); const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); @@ -143,7 +147,7 @@ export function buildThemeTokens( hover: c.LightBlue, }, inlineCode: { - foreground: c.AccentBlue, + foreground: `${c.AccentPurple}cc`, background: dimHex(c.Background, 0.08), border: dimHex(fg, 0.7), }, diff --git a/src/ui/views/App.tsx b/src/ui/views/App.tsx index 2a3f8ff..d2a2386 100644 --- a/src/ui/views/App.tsx +++ b/src/ui/views/App.tsx @@ -795,7 +795,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl onCancel={handlePermissionCancel} /> ) : isExiting ? ( - + ) : ( Date: Tue, 2 Jun 2026 16:25:20 +0800 Subject: [PATCH 18/19] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=BB=E7=95=8C=E9=9D=A2=E6=8A=96=E5=8A=A8=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/views/App.tsx | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/ui/views/App.tsx b/src/ui/views/App.tsx index d2a2386..ccbda26 100644 --- a/src/ui/views/App.tsx +++ b/src/ui/views/App.tsx @@ -584,7 +584,6 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl if (mode === RawMode.Raw) { // In raw mode, re-render all messages directly to stdout at the new width. - // Use process.stdout.write instead of writeRef to avoid Ink interference. process.stdout.write(ANSI_CLEAR_SCREEN); const activeSessionId = sessionManager.getActiveSessionId(); const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; @@ -592,21 +591,12 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl return; } - // Force full redraw on terminal resize to avoid stale wrapped rows. - writeRef.current("\u001B[2J\u001B[H"); - - setMessages([]); - setShowWelcome(false); + // Don't clear the screen on resize — Ink handles re-layout naturally. + // Clearing causes scroll-to-top and flash, especially on tab switch in iTerm2. + // Just force ThemeableStatic to remount so Ink recalculates row heights. setWelcomeNonce((n) => n + 1); - - const activeSessionId = sessionManager.getActiveSessionId(); - const nextMessages = - activeSessionId && !busy ? loadVisibleMessages(sessionManager, activeSessionId) : messagesRef.current; - setTimeout(() => { - setMessages(nextMessages); - setShowWelcome(true); - }, 0); - }, [busy, mode, sessionManager, columns, stdout]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [busy, mode, sessionManager, columns]); const screenWidth = useMemo(() => columns ?? stdout?.columns ?? 80, [columns, stdout]); const screenHeight = useMemo(() => rows ?? stdout?.rows ?? 24, [rows, stdout]); From e4c3584a3295198e326cf20ef660d2b11e427f95 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 2 Jun 2026 16:33:58 +0800 Subject: [PATCH 19/19] =?UTF-8?q?refactor(ui):=20=E4=BC=98=E5=8C=96=20Prom?= =?UTF-8?q?ptInput=20=E7=BB=84=E4=BB=B6=E4=B8=AD=20showFooterText=20?= =?UTF-8?q?=E7=9A=84=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 showFooterText 的 useMemo 提取到统一位置 - 添加 showThemeDropdown 到依赖列表和条件判断 - 移除重复声明的 showFooterText 常量 - 保持相关依赖和逻辑清晰易维护 --- src/ui/views/PromptInput.tsx | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/ui/views/PromptInput.tsx b/src/ui/views/PromptInput.tsx index b0b7fcd..063183e 100644 --- a/src/ui/views/PromptInput.tsx +++ b/src/ui/views/PromptInput.tsx @@ -214,9 +214,16 @@ export const PromptInput = React.memo(function PromptInput({ ? `${loadingText}${processOrPasteHint}` : `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}` : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processOrPasteHint}`; + const showFooterText = useMemo( - () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu, - [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu] + () => + showMenu || + showSkillsDropdown || + openRawModelDropdown || + showModelDropdown || + showThemeDropdown || + showFileMentionMenu, + [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showThemeDropdown, showFileMentionMenu] ); const cursorPlacement = useMemo( () => getPromptCursorPlacement(buffer, screenWidth, 2, footerText), @@ -746,17 +753,6 @@ export const PromptInput = React.memo(function PromptInput({ clearUndoRedoStacks(); } - const showFooterText = useMemo( - () => - showMenu || - showSkillsDropdown || - openRawModelDropdown || - showModelDropdown || - showThemeDropdown || - showFileMentionMenu, - [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showThemeDropdown, showFileMentionMenu] - ); - const isFocused = useMemo(() => !disabled && hasTerminalFocus, [disabled, hasTerminalFocus]); const matchedCommand = slashToken ? findExactSlashCommand(slashItems, slashToken) : null;