diff --git a/README-en.md b/README-en.md index c1d4acb..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 | @@ -141,6 +142,91 @@ 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 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`, `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)** + +Override only the colors you want to change; the rest keep their defaults: + +```json +{ + "theme": { + "preset": "custom", + "overrides": { + "primary": "#ff6600", + "success": "greenBright" + } + } +} +``` + +**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"] + } + } +} +``` + +> 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 light theme (`light`) color values: + +| 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 | + +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 2643756..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 | @@ -141,6 +142,91 @@ 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`、`gitlab-light`、`gitlab-dark`、`monokai`、`dracula`。 + +也可在运行时使用 `/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` 主题。 + +默认浅色主题(`light`)色值: + +| 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 与退出面板的渐变色数组 | + +颜色值支持 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 2643756..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 | @@ -141,6 +142,91 @@ 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`、`gitlab-light`、`gitlab-dark`、`monokai`、`dracula`。 + +也可在运行时使用 `/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` 主题。 + +默认浅色主题(`light`)色值: + +| 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 与退出面板的渐变色数组 | + +颜色值支持 hex(`"#ff6600"`)、hex 含透明度(`"#229ac3e6"`)、chalk 命名色(`"cyanBright"`、`"green"`)。 + +> 注意:`tokens` 优先级高于 `overrides`——如果同时指定两者,仅 `tokens` 生效。主题配置可放在全局 `~/.deepcode/settings.json` 或项目根 `.deepcode/settings.json` 中。 + ## 贡献 欢迎贡献代码!以下是参与方式: diff --git a/docs/configuration.md b/docs/configuration.md index 922f39e..bece68f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -128,6 +128,80 @@ 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` 命令打开主题选择器,使用方向键浏览主题,按 Space 或 Enter 确认选择: + +``` +/theme # 打开主题选择器 +``` + +选择器中可用的主题与上表一致。在浏览过程中会实时预览主题效果,按 Esc 可取消并恢复原主题。确认后会自动保存到 `settings.json`,下次启动时生效。 + #### `debugLogEnabled` — 调试日志 设为 `true` 可让程序输出详细的调试日志(默认 `false`),用于排查 API 调用和工具执行的问题。 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index f53fb11..77f219f 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -127,6 +127,80 @@ 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 open the theme picker. Browse with arrow keys and 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. + #### `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/cli.tsx b/src/cli.tsx index 87fb9fb..65e8843 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -42,8 +42,9 @@ 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", + " /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", @@ -97,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/common/update-check.ts b/src/common/update-check.ts index 09c0273..5ab3efc 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 { LIGHT_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(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/settings.ts b/src/settings.ts index 14755dd..f056b5f 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"; @@ -53,6 +55,7 @@ export type DeepcodingSettings = { webSearchTool?: string; mcpServers?: Record; permissions?: PermissionSettings; + theme?: ThemeSettings; }; export type ResolvedDeepcodingSettings = { @@ -68,6 +71,7 @@ export type ResolvedDeepcodingSettings = { webSearchTool?: string; mcpServers?: Record; permissions: Required; + theme: ThemeTokens; }; export type ModelConfigSelection = { @@ -345,6 +349,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/permission-prompt.test.ts b/src/tests/permission-prompt.test.ts index 4f1d87e..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"), "#22c55e"); - assert.equal(getScopeRiskColor("query-git-log"), "#22c55e"); + assert.equal(getScopeRiskColor("read-in-cwd"), LIGHT_THEME.success); + assert.equal(getScopeRiskColor("query-git-log"), LIGHT_THEME.success); - 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"), 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"), "#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"), 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 new file mode 100644 index 0000000..eeccd0c --- /dev/null +++ b/src/tests/theme.test.ts @@ -0,0 +1,350 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import chalk from "chalk"; + +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"; +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 = [ + "primary", + "secondary", + "success", + "error", + "warning", + "info", + "text", + "textDim", + "textBright", + "code", + "border", + "gradients", +]; + +const DEFAULTS = { + model: "test-model", + baseURL: "https://test.example.com", +}; + +// --------------------------------------------------------------------------- +// 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 primary matches expected brand color", () => { + assert.equal(LIGHT_THEME.primary, "#229ac3"); + assert.equal(LIGHT_THEME.secondary, "#229ac3e6"); +}); + +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("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 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 LIGHT_THEME when settings is undefined", () => { + const result = resolveTheme(undefined); + assert.equal(result.primary, LIGHT_THEME.primary); + assert.equal(result.success, LIGHT_THEME.success); +}); + +test("resolveTheme returns LIGHT_THEME for explicit 'light' preset", () => { + const result = resolveTheme({ preset: "light" }); + assert.equal(result.primary, LIGHT_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'", () => { + const result = resolveTheme({ + preset: "custom", + overrides: { primary: "#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); +}); + +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 result = resolveTheme({ preset: "custom", tokens: customTokens }); + assert.equal(result.primary, "#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: { primary: undefined, success: undefined } as Partial, + }); + 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: "light", + overrides: { primary: "#ff0000" }, + }); + 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, LIGHT_THEME.primary); +}); + +test("resolveTheme returns LIGHT_THEME for custom preset without token/overrides", () => { + const result = resolveTheme({ preset: "custom" }); + assert.equal(result.primary, LIGHT_THEME.primary); +}); + +// --------------------------------------------------------------------------- +// createThemedChalk — markdown 方法直接复用顶层 token +// --------------------------------------------------------------------------- + +test("createThemedChalk heading1 produces styled output via primary", () => { + 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" }; + 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" }; + 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" }; + 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" }; + assert.notEqual(createThemedChalk(LIGHT_THEME).quote("test"), createThemedChalk(custom).quote("test")); +}); + +test("createThemedChalk bold / italic / dim produce styled output", () => { + 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 = { ...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"); +}); + +// --------------------------------------------------------------------------- +// current-theme (module-level state) +// --------------------------------------------------------------------------- + +test("getCurrentThemedChalk returns LIGHT_THEME chalk by default", () => { + setCurrentTheme(LIGHT_THEME); + assert.notEqual(getCurrentThemedChalk().primary("test"), "test"); +}); + +test("setCurrentTheme changes getCurrentThemedChalk output", () => { + setCurrentTheme(LIGHT_THEME); + const first = getCurrentThemedChalk().primary("test"); + + const custom: ThemeTokens = { ...LIGHT_THEME, primary: "#ff0000" }; + setCurrentTheme(custom); + const second = getCurrentThemedChalk().primary("test"); + + assert.notEqual(first, second); + + setCurrentTheme(LIGHT_THEME); +}); + +test("setCurrentTheme changes getCurrentThemeTokens output", () => { + setCurrentTheme(LIGHT_THEME); + assert.equal(getCurrentThemeTokens().primary, LIGHT_THEME.primary); + + const custom: ThemeTokens = { ...LIGHT_THEME, primary: "#ff0000" }; + setCurrentTheme(custom); + assert.equal(getCurrentThemeTokens().primary, "#ff0000"); + + setCurrentTheme(LIGHT_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.primary, LIGHT_THEME.primary); +}); + +test("resolveSettingsSources resolves custom theme from user settings", () => { + const result = resolveSettingsSources( + { theme: { preset: "custom", overrides: { primary: "#abcdef" } } }, + null, + DEFAULTS, + {} + ); + assert.equal(result.theme.primary, "#abcdef"); +}); + +test("resolveSettingsSources resolves custom theme from project settings", () => { + const result = resolveSettingsSources( + null, + { theme: { preset: "custom", overrides: { primary: "#123456" } } }, + DEFAULTS, + {} + ); + assert.equal(result.theme.primary, "#123456"); +}); + +test("resolveSettingsSources uses default theme when preset is not custom", () => { + const result = resolveSettingsSources( + { theme: { preset: "light", overrides: { primary: "#abcdef" } } }, + null, + DEFAULTS, + {} + ); + assert.equal(result.theme.primary, LIGHT_THEME.primary); +}); + +// --------------------------------------------------------------------------- +// getScopeRiskColor with theme parameter +// --------------------------------------------------------------------------- + +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); +}); + +test("getScopeRiskColor uses theme semantic colors when theme is provided", () => { + const custom: Partial = { + success: "#aaaaaa", + warning: "#bbbbbb", + error: "#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..6ad43d0 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 @@ -9,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; @@ -64,12 +69,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.primary; + const effectiveActiveColor = activeColor ?? theme.primary; // Calculate visible window const visibleStart = calculateVisibleStart(activeIndex, items?.length, maxVisible); const visibleItems = items?.slice(visibleStart, visibleStart + maxVisible); @@ -102,7 +110,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ return ( {title ? ( - + {title} ) : null} @@ -113,19 +121,21 @@ const DropdownMenu = React.memo(function DropdownMenu({ } return ( - + {/* Title */} {title ? ( - - + + {title} @@ -150,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} + ); })} @@ -176,15 +196,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 f00b367..96da8db 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..bf7e877 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,19 +151,21 @@ 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 ( - ✧ + ✦ @@ -170,7 +174,7 @@ 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..82ebc97 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.textBright(`[${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..bc1d730 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.primary(`> ${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.primary(`> ${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/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 c55d9ce..60774a8 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.secondary); + 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); } @@ -142,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 new file mode 100644 index 0000000..2b44d08 --- /dev/null +++ b/src/ui/theme/ThemeContext.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext } from "react"; +import type { ThemeTokens } from "./types"; +import { LIGHT_THEME } from "./presets"; + +/** 主题 React Context */ +const ThemeContext = createContext(LIGHT_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..e0fa043 --- /dev/null +++ b/src/ui/theme/chalk-theme.ts @@ -0,0 +1,85 @@ +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; + 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; +} + +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); + + 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), + }; +} diff --git a/src/ui/theme/current-theme.ts b/src/ui/theme/current-theme.ts new file mode 100644 index 0000000..a8a9396 --- /dev/null +++ b/src/ui/theme/current-theme.ts @@ -0,0 +1,22 @@ +import { LIGHT_THEME } from "./presets"; +import { createThemedChalk, type ThemedChalk } from "./chalk-theme"; +import type { ThemeTokens } from "./types"; + +let currentThemedChalk: ThemedChalk = createThemedChalk(LIGHT_THEME); +let currentThemeTokens: ThemeTokens = LIGHT_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..e09feda --- /dev/null +++ b/src/ui/theme/index.ts @@ -0,0 +1,17 @@ +export type { ThemeTokens, ThemePreset, ThemeSettings } from "./types"; +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"; +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..ff5acc3 --- /dev/null +++ b/src/ui/theme/presets.ts @@ -0,0 +1,141 @@ +import type { ThemeTokens } from "./types"; + +/** 浅色主题(默认主题) */ +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"], +}; + +/** 暗色主题 */ +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 = { + 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 new file mode 100644 index 0000000..7166f50 --- /dev/null +++ b/src/ui/theme/resolver.ts @@ -0,0 +1,56 @@ +import { type ThemeTokens, type ThemeSettings } from "./types"; +import { LIGHT_THEME, PRESETS } 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="light":使用浅色主题 LIGHT_THEME + * - preset 为预设名称(如 "dark", "monokai", "dracula"):使用对应预设 + * - preset="custom":使用用户自定义 tokens 或 overrides 合并到 LIGHT_THEME + */ +export function resolveTheme(themeSettings: ThemeSettings | undefined): ThemeTokens { + if (!themeSettings) { + return LIGHT_THEME; + } + + const { preset } = themeSettings; + + // preset 为预设名称时使用对应预设 + if (preset && preset !== "custom" && preset in PRESETS) { + return PRESETS[preset]; + } + + // preset="custom":应用用户自定义 + if (preset === "custom") { + if (themeSettings.tokens) { + return deepMerge(LIGHT_THEME, themeSettings.tokens); + } + if (themeSettings.overrides) { + return deepMerge(LIGHT_THEME, themeSettings.overrides); + } + } + + // 未配置或无效 preset,回退默认 + return LIGHT_THEME; +} diff --git a/src/ui/theme/types.ts b/src/ui/theme/types.ts new file mode 100644 index 0000000..0fbaa7e --- /dev/null +++ b/src/ui/theme/types.ts @@ -0,0 +1,56 @@ +/** 主题颜色 Token 定义 */ +export interface ThemeTokens { + // ——— 品牌色 ——— + /** 主品牌色: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; + + // ——— 渐变 ——— + /** Logo 渐变色数组 */ + gradients: string[]; +} + +/** 预设主题名称 */ +export type ThemePreset = + | "light" + | "dark" + | "monokai" + | "dracula" + | "github-light" + | "github-dark" + | "gitlab-light" + | "gitlab-dark" + | "custom"; + +/** 主题配置(用户可配置部分) */ +export type ThemeSettings = { + /** 选择预设主题,如 "light"、"dark" 等;"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..b028264 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 chalk from "chalk"; +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 { @@ -59,6 +60,8 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl 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); @@ -85,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)); @@ -153,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"); @@ -176,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); } @@ -198,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); @@ -209,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. @@ -249,25 +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 }); - process.stdout.write("\n"); - process.stdout.write(chalk.rgb(34, 154, 195)("> /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) { + // 生产环境:完全销毁重建 Ink 实例,清屏最可靠 onRestart(); } else { + // 测试环境:在同一实例内重置状态 await resetToWelcome(); refreshSessionsList(); } @@ -350,7 +364,6 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl [ sessionManager, pendingPermissionReply, - exit, onRestart, refreshSkills, refreshSessionsList, @@ -429,7 +442,8 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl const reloadActiveSessionView = useCallback( (sessionId: string): void => { - resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); + process.stdout.write(ANSI_CLEAR_SCREEN); + resetStaticView(loadVisibleMessages(sessionManager, sessionId)); }, [resetStaticView, sessionManager] ); @@ -450,8 +464,8 @@ 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 }); + process.stdout.write(ANSI_CLEAR_SCREEN); + resetStaticView(loadVisibleMessages(sessionManager, sessionId)); const session = sessionManager.getSession(sessionId); setStatusLine(session ? buildStatusLine(session) : ""); setRunningProcesses(session?.processes ?? null); @@ -695,7 +709,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl return ( - + {(item) => { if (item.id.startsWith("__welcome__")) { return ( @@ -717,15 +731,15 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl /> ); }} - + {statusLine ? ( - + {statusLine} ) : null} {errorLine ? ( - - Error: {errorLine} + + Error: {errorLine} ) : null} {showProcessStdout ? ( @@ -780,7 +794,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 d5f6363..c3d7197 100644 --- a/src/ui/views/AppContainer.tsx +++ b/src/ui/views/AppContainer.tsx @@ -1,7 +1,10 @@ -import React 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, resolveTheme } from "../theme"; +import { resolveCurrentSettings, readSettings, readProjectSettings, writeSettings } from "../../settings"; +import type { ThemeTokens, ThemePreset, ThemeSettings } from "../theme"; const AppContainer: React.FC<{ projectRoot: string; @@ -9,11 +12,90 @@ const AppContainer: React.FC<{ initialPrompt: string | undefined; onRestart: () => void; }> = ({ version, projectRoot, initialPrompt, onRestart }) => { + const settings = resolveCurrentSettings(projectRoot); + 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/AskUserQuestionPrompt.tsx b/src/ui/views/AskUserQuestionPrompt.tsx index a2f91ad..1eeb31d 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.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/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/McpStatusList.tsx b/src/ui/views/McpStatusList.tsx index 40d2f3f..3834454 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"); @@ -38,9 +40,9 @@ export function McpStatusList({ statuses, onCancel, onReconnect }: Props): React if (statuses.length === 0) { 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 @@ -187,18 +190,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 ) @@ -209,7 +212,7 @@ function ServerListView({ borderLeft={false} borderRight={false} borderStyle="round" - borderDimColor + borderColor={theme.border} flexDirection="column" flexGrow={1} paddingX={1} @@ -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 ( - + {/* Header row */} {statusIcon} - + {server.name} — {server.status === "ready" ? "Details" : "Status"} @@ -461,7 +466,7 @@ function ServerDetailView({ borderLeft={false} borderRight={false} borderStyle="round" - borderDimColor + borderColor={theme.border} flexDirection="column" flexGrow={1} paddingX={1} @@ -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.primary : 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..a16662c 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, LIGHT_THEME } from "../theme"; +import type { ThemeTokens } from "../theme"; 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 ?? LIGHT_THEME; switch (scope) { case "read-in-cwd": case "query-git-log": - return "#22c55e"; + return t.success; case "read-out-cwd": case "write-in-cwd": case "network": case "mcp": - return "#f59e0b"; + return t.warning; case "write-out-cwd": case "delete-in-cwd": case "delete-out-cwd": case "mutate-git-log": case "unknown": - return "#ef4444"; + return t.error; default: - return "#ef4444"; + return t.error; } } 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..48c388e 100644 --- a/src/ui/views/PromptInput.tsx +++ b/src/ui/views/PromptInput.tsx @@ -1,6 +1,8 @@ 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, @@ -53,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; @@ -84,17 +87,21 @@ 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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { const [spinnerIndex, setSpinnerIndex] = useState(0); + const theme = useTheme(); useEffect(() => { if (!busy) { @@ -108,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({ @@ -123,14 +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([]); @@ -140,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); @@ -168,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]); @@ -337,7 +351,7 @@ export const PromptInput = React.memo(function PromptInput({ setPendingExit(false); } - if (openRawModelDropdown || showSkillsDropdown || showModelDropdown) { + if (openRawModelDropdown || showSkillsDropdown || showModelDropdown || showThemeDropdown) { return; } @@ -635,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(); @@ -715,24 +735,32 @@ 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]); + const matchedCommand = slashToken ? findExactSlashCommand(slashItems, slashToken) : null; const inlineHint = matchedCommand?.args ? ` ${matchedCommand.args.join(ARGS_SEPARATOR)}` : ""; return ( {imageUrls.length > 0 ? ( - - {formatImageAttachmentStatus(imageUrls.length)} + + {formatImageAttachmentStatus(imageUrls.length)} {` (${IMAGE_ATTACHMENT_CLEAR_HINT})`} ) : null} {selectedSkills.length > 0 ? ( - - + + {formatSelectedSkillsStatus(selectedSkills)} (use /skills to edit) @@ -745,10 +773,10 @@ export const PromptInput = React.memo(function PromptInput({ borderBottom={true} borderLeft={false} borderRight={false} - borderDimColor + borderColor={isFocused ? theme.primary : theme.border} > - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)} + {renderBufferWithCursor(buffer, isFocused, placeholder, pastesRef.current, theme.warning)} {inlineHint ? {inlineHint} : null} + setShowThemeDropdown(false)} + onThemeChange={(preset: ThemePreset) => switchTheme?.(preset)} + onThemePreview={onThemePreview} + onThemeRevert={onThemeRevert} + onStatusMessage={setStatusMessage} + /> {!showFooterText && ( - + {footerText} )} @@ -866,11 +905,13 @@ export function renderBufferWithCursor( state: PromptBufferState, isFocused: boolean, placeholder?: string, - validPastes?: Map + 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 +925,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 +940,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 +953,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 +971,16 @@ function renderFocusedText(text: string, cursor: number, validIds: Map= end) return ""; @@ -957,12 +1004,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 +1025,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..6c83a56 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. ); @@ -195,22 +197,23 @@ 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 ? `Search: ${searchQuery}` : "Type to search\u2026"} + {searchQuery ? | : null} @@ -222,7 +225,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} @@ -230,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) => { @@ -240,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)}) )} @@ -274,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 d93446d..73c3251 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..c470d53 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 ( @@ -21,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 977bca2..a1385fc 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. ); @@ -97,9 +99,9 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac paddingX={1} marginTop={1} > - + - + Undo restore to the point before a prompt @@ -111,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} @@ -122,9 +124,9 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac const isActive = actualIndex === safeTargetIndex; return ( - {isActive ? "> " : " "} + {isActive ? "> " : " "} - + {formatUndoMessage(target.message.content)} @@ -143,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} @@ -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..4159569 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..f9e437f 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}