diff --git a/README-en.md b/README-en.md index c1d4acb..ed3a0e1 100644 --- a/README-en.md +++ b/README-en.md @@ -47,7 +47,7 @@ Create `~/.deepcode/settings.json`: The configuration file is shared with the [Deep Code VSCode extension](https://github.com/lessweb/deepcode) — configure once, use everywhere. -For complete configuration details (multi-level priority, environment variables, etc.), see [docs/configuration.md](docs/configuration.md). +For complete configuration details (multi-level priority, environment variables, etc.), see [docs/configuration_en.md](docs/configuration_en.md). ## Key Features @@ -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 | @@ -102,13 +103,31 @@ Yes. Deep Code offers a full-featured VSCode extension, available on the [VSCode Deep Code supports multimodal input — you can paste images from the clipboard with `Ctrl+V`. However, `deepseek-v4` does not support multimodal yet. Some models have multimodal capabilities but impose strict limits on multi-turn dialogue requests. For multimodal input, we recommend using the Volcano Ark `Doubao-Seed-2.0-pro` model, which has the best integration. -### How to automatically send a Slack message after a task completes? +### How to send a Slack message after a task completes? -Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. For detailed steps, see [docs/notify_en.md](docs/notify_en.md). +Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. + +> 📖 See [docs/notify_en.md](docs/notify_en.md) for details. ### How do I enable web search? -Deep Code comes with a built-in, free Web Search tool that works well for most use cases. If you prefer to use a custom script for web search, set the `webSearchTool` field in `~/.deepcode/settings.json` to the full path of your script. For detailed steps, refer to: https://github.com/qorzj/web_search_cli +Deep Code comes with a built-in, free Web Search tool that works well for most use cases. If you prefer to use a custom script for web search, set the `webSearchTool` field in `~/.deepcode/settings.json` to the full path of your script. For details, refer to: https://github.com/qorzj/web_search_cli + +### How do I configure MCP? + +Deep Code supports MCP (Model Context Protocol) to connect external services such as GitHub, browsers, databases, and more. Configure the `mcpServers` field in `settings.json` to enable it, then use the `/mcp` command to view MCP server status and available tools. + +> 📖 See [docs/mcp_en.md](docs/mcp.md) for details. + +### How to configure notifications after a task completes? + +When the AI assistant completes a task, Deep Code can automatically execute a notification script to send the results to your specified channel (e.g., Slack, system notifications, etc.). + +> 📖 See [docs/notify_en.md](docs/notify_en.md) for details. + +### Does Deep Code only support YOLO mode? + +No. Deep Code has a built-in fine-grained permission control mechanism that lets you confirm operations before the AI assistant executes shell commands, reads/writes files, accesses the network, and more. You can configure each permission scope's policy — always allow, always ask, or deny — via the `permissions` field in `settings.json`. See [docs/permission_en.md](docs/permission.md) for details. ### Does it support Coding Plan? @@ -125,21 +144,17 @@ Yes. Just set `env.BASE_URL` in `~/.deepcode/settings.json` to an OpenAI-compati } ``` -### How do I configure MCP? - -Deep Code supports MCP (Model Context Protocol) to connect external services such as GitHub, browsers, databases, and more. Configure the `mcpServers` field in `settings.json` to enable it, then use the `/mcp` command to view MCP server status and available tools. - -For detailed setup instructions, see: [docs/mcp.md](docs/mcp.md) +### How to use and customize themes? -### How to configure Deep Code to send notifications after a task completes? +Deep Code CLI includes 8 built-in preset themes, supports the `/theme` command for live preview and switching, and allows full customization via `settings.json`. -When the AI assistant completes a task, Deep Code can automatically execute a notification script to send the task results to the specified channel (e.g., Slack, system notifications, etc.). +**Quick switch:** Run `/theme` to open the picker. Browse with arrow keys, confirm with Enter, cancel with Esc. -For detailed configuration instructions, see: [docs/notify_en.md](docs/notify_en.md) +**Available presets:** `light` (default), `dark`, `github-light`, `github-dark`, `monokai`, `dracula`, `ansi-light`, `ansi-dark`. -### Does Deep Code only support YOLO mode? +**Custom themes:** Supports simplified color palette (`colors`), partial overrides (`overrides`), and full customization (`tokens`). -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. +> 📖 See [docs/configuration_en.md](docs/configuration_en.md) for the full configuration guide. ## Contributing diff --git a/README-zh_CN.md b/README-zh_CN.md index 2643756..31e671a 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 | @@ -104,7 +105,9 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 ### 怎样在任务完成后自动给 Slack 发消息? -编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。 +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。 + +> 📖 详细配置指南 [docs/notify.md](docs/notify.md)。 ### 怎样启用联网搜索功能? @@ -114,13 +117,13 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 -详细配置指南:[docs/mcp.md](docs/mcp.md) +> 📖 详细配置指南:[docs/mcp.md](docs/mcp.md) ### 如何配置 Deep Code 任务完成后发送通知? 当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 -详细配置指南:[docs/notify.md](docs/notify.md) +> 📖 详细配置指南:[docs/notify.md](docs/notify.md) ### Deep Code 只支持 YOLO 模式吗? @@ -141,6 +144,18 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 } ``` +### 如何使用和自定义主题? + +Deep Code CLI 内置 8 套预设主题,支持 `/theme` 命令实时预览切换,也支持通过 `settings.json` 自定义配色。 + +**快速切换主题:** 运行 `/theme` 打开选择器,方向键浏览,Enter 确认,Esc 取消。 + +**可用预设:** `light`(默认)、`dark`、`github-light`、`github-dark`、`monokai`、`dracula`、`ansi-light`、`ansi-dark`。 + +**自定义主题:** 支持简化色板(`colors`)、部分覆盖(`overrides`)、完全自定义(`tokens`)三种方式。 + +> 📖 详细配置指南见 [docs/configuration.md](docs/configuration.md)。 + ## 贡献 欢迎贡献代码!以下是参与方式: diff --git a/README.md b/README.md index 2643756..31e671a 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 | @@ -104,7 +105,9 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 ### 怎样在任务完成后自动给 Slack 发消息? -编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。 +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。 + +> 📖 详细配置指南 [docs/notify.md](docs/notify.md)。 ### 怎样启用联网搜索功能? @@ -114,13 +117,13 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 -详细配置指南:[docs/mcp.md](docs/mcp.md) +> 📖 详细配置指南:[docs/mcp.md](docs/mcp.md) ### 如何配置 Deep Code 任务完成后发送通知? 当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 -详细配置指南:[docs/notify.md](docs/notify.md) +> 📖 详细配置指南:[docs/notify.md](docs/notify.md) ### Deep Code 只支持 YOLO 模式吗? @@ -141,6 +144,18 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 } ``` +### 如何使用和自定义主题? + +Deep Code CLI 内置 8 套预设主题,支持 `/theme` 命令实时预览切换,也支持通过 `settings.json` 自定义配色。 + +**快速切换主题:** 运行 `/theme` 打开选择器,方向键浏览,Enter 确认,Esc 取消。 + +**可用预设:** `light`(默认)、`dark`、`github-light`、`github-dark`、`monokai`、`dracula`、`ansi-light`、`ansi-dark`。 + +**自定义主题:** 支持简化色板(`colors`)、部分覆盖(`overrides`)、完全自定义(`tokens`)三种方式。 + +> 📖 详细配置指南见 [docs/configuration.md](docs/configuration.md)。 + ## 贡献 欢迎贡献代码!以下是参与方式: diff --git a/docs/configuration.md b/docs/configuration.md index 922f39e..66af0b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -128,6 +128,100 @@ 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 风格主题 | +| `monokai` | Monokai 风格主题 | +| `dracula` | Dracula 风格主题 | +| `ansi-light` | ANSI 浅色主题(标准 16 色) | +| `ansi-dark` | ANSI 暗色主题(标准 16 色) | + +**自定义主题颜色** + +推荐使用 `colors` 简化色板配置,只需定义 16 个基础色,系统自动推导完整主题: + +```json +{ + "theme": { + "preset": "custom", + "colors": { + "Background": "#ffffff", + "Foreground": "#1F2328", + "Gray": "#8b949e", + "LightBlue": "#0969da", + "AccentBlue": "#ff6600", + "AccentPurple": "#8250df", + "AccentCyan": "#0550ae", + "AccentGreen": "#1a7f37", + "AccentYellow": "#fa8c16", + "AccentRed": "#d1242f", + "AccentYellowDim": "#9a6700", + "AccentRedDim": "#a40e26", + "DiffAdded": "#dafbe1", + "DiffRemoved": "#ffebe9", + "Comment": "#6e7781" + } + } +} +``` + +也可在 `colors` 基础上用 `overrides` 微调个别 token: + +```json +{ + "theme": { + "preset": "custom", + "colors": { "Background": "#1a1a2e", "Foreground": "#e0e0e0", "..." : "..." }, + "overrides": { + "agent": { "streaming": "#ffcc00" } + } + } +} +``` + +高级用法:通过 `base` 基于其他预设,用 `overrides` 微调: + +```json +{ + "theme": { + "preset": "custom", + "base": "dark", + "overrides": { + "brand": { "primary": "#ff6600" } + } + } +} +``` + +**运行时切换主题** + +在 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..11bcc23 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -127,6 +127,100 @@ 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 | +| `monokai` | Monokai-style theme | +| `dracula` | Dracula-style theme | +| `ansi-light` | ANSI light theme (standard 16 colors) | +| `ansi-dark` | ANSI dark theme (standard 16 colors) | + +**Custom Theme Colors** + +The recommended way is to use `colors` — a simplified palette of 16 base colors. The system automatically derives the full theme: + +```json +{ + "theme": { + "preset": "custom", + "colors": { + "Background": "#ffffff", + "Foreground": "#1F2328", + "Gray": "#8b949e", + "LightBlue": "#0969da", + "AccentBlue": "#ff6600", + "AccentPurple": "#8250df", + "AccentCyan": "#0550ae", + "AccentGreen": "#1a7f37", + "AccentYellow": "#fa8c16", + "AccentRed": "#d1242f", + "AccentYellowDim": "#9a6700", + "AccentRedDim": "#a40e26", + "DiffAdded": "#dafbe1", + "DiffRemoved": "#ffebe9", + "Comment": "#6e7781" + } + } +} +``` + +You can also combine `colors` with `overrides` to fine-tune specific tokens: + +```json +{ + "theme": { + "preset": "custom", + "colors": { "Background": "#1a1a2e", "Foreground": "#e0e0e0", "..." : "..." }, + "overrides": { + "agent": { "streaming": "#ffcc00" } + } + } +} +``` + +Advanced: use `base` to inherit from another preset, with `overrides` to tweak: + +```json +{ + "theme": { + "preset": "custom", + "base": "dark", + "overrides": { + "brand": { "primary": "#ff6600" } + } + } +} +``` + +**Runtime Theme Switching** + +In the CLI, use the `/theme` command to open the theme picker. Browse with arrow keys, confirm with Space or Enter: + +``` +/theme # Open theme picker +``` + +As you browse in the picker, the theme is previewed in real-time. Press Esc to cancel and revert to the original theme. Once confirmed, the selection is automatically saved to `settings.json` and takes effect immediately. + #### `debugLogEnabled` — Debug Log 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/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/detect-system-theme.test.ts b/src/tests/detect-system-theme.test.ts new file mode 100644 index 0000000..f54c1e7 --- /dev/null +++ b/src/tests/detect-system-theme.test.ts @@ -0,0 +1,318 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + parseOscRgb, + themeFromOscColor, + detectFromColorFgBg, + detectSystemTheme, +} from "../ui/theme/detect-system-theme"; + +// --------------------------------------------------------------------------- +// parseOscRgb +// --------------------------------------------------------------------------- + +test("parseOscRgb parses rgb:RR/GG/BB format", () => { + const result = parseOscRgb("rgb:0000/0000/0000"); + assert.ok(result); + assert.equal(result.r, 0); + assert.equal(result.g, 0); + assert.equal(result.b, 0); +}); + +test("parseOscRgb parses rgb:RRRR/GGGG/BBBB format (bright white)", () => { + const result = parseOscRgb("rgb:ffff/ffff/ffff"); + assert.ok(result); + assert.equal(result.r, 1); + assert.equal(result.g, 1); + assert.equal(result.b, 1); +}); + +test("parseOscRgb parses rgb:RR/GG/BB with mid values", () => { + const result = parseOscRgb("rgb:8080/8080/8080"); + assert.ok(result); + assert.ok(Math.abs(result.r - 0.50196) < 0.001); + assert.ok(Math.abs(result.g - 0.50196) < 0.001); + assert.ok(Math.abs(result.b - 0.50196) < 0.001); +}); + +test("parseOscRgb parses rgb with short hex (1 digit per component)", () => { + const result = parseOscRgb("rgb:f/f/f"); + assert.ok(result); + assert.equal(result.r, 1); + assert.equal(result.g, 1); + assert.equal(result.b, 1); +}); + +test("parseOscRgb parses rgb with 2-digit hex", () => { + const result = parseOscRgb("rgb:ff/80/00"); + assert.ok(result); + assert.equal(result.r, 1); + assert.ok(Math.abs(result.g - 0.50196) < 0.001); + assert.equal(result.b, 0); +}); + +test("parseOscRgb parses rgba format", () => { + const result = parseOscRgb("rgba:ffff/ffff/ffff/ffff"); + assert.ok(result); + assert.equal(result.r, 1); + assert.equal(result.g, 1); + assert.equal(result.b, 1); +}); + +test("parseOscRgb parses #RRGGBB format", () => { + const result = parseOscRgb("#000000"); + assert.ok(result); + assert.equal(result.r, 0); + assert.equal(result.g, 0); + assert.equal(result.b, 0); +}); + +test("parseOscRgb parses #RRGGBB format (white)", () => { + const result = parseOscRgb("#ffffff"); + assert.ok(result); + assert.equal(result.r, 1); + assert.equal(result.g, 1); + assert.equal(result.b, 1); +}); + +test("parseOscRgb parses #RRRRGGGGBBBB format", () => { + const result = parseOscRgb("#ffff80800000"); + assert.ok(result); + assert.equal(result.r, 1); + assert.ok(Math.abs(result.g - 0.50196) < 0.001); + assert.equal(result.b, 0); +}); + +test("parseOscRgb returns undefined for invalid format", () => { + assert.equal(parseOscRgb("invalid"), undefined); + assert.equal(parseOscRgb(""), undefined); + assert.equal(parseOscRgb("rgb:zz/zz/zz"), undefined); + assert.equal(parseOscRgb("#gggggg"), undefined); +}); + +test("parseOscRgb returns undefined for #RRGGBB with non-multiple-of-3 length", () => { + assert.equal(parseOscRgb("#ffff"), undefined); + assert.equal(parseOscRgb("#fffff"), undefined); +}); + +test("parseOscRgb is case-insensitive", () => { + const lower = parseOscRgb("rgb:ffff/0000/8080"); + const upper = parseOscRgb("RGB:FFFF/0000/8080"); + assert.ok(lower); + assert.ok(upper); + assert.equal(lower.r, upper.r); + assert.equal(lower.g, upper.g); + assert.equal(lower.b, upper.b); +}); + +// --------------------------------------------------------------------------- +// themeFromOscColor +// --------------------------------------------------------------------------- + +test("themeFromOscColor returns 'light' for white background", () => { + assert.equal(themeFromOscColor("rgb:ffff/ffff/ffff"), "light"); + assert.equal(themeFromOscColor("#ffffff"), "light"); +}); + +test("themeFromOscColor returns 'dark' for black background", () => { + assert.equal(themeFromOscColor("rgb:0000/0000/0000"), "dark"); + assert.equal(themeFromOscColor("#000000"), "dark"); +}); + +test("themeFromOscColor returns 'light' for bright background", () => { + // Luminance of #c0c0c0 (silver) ≈ 0.53 > 0.5 + assert.equal(themeFromOscColor("#c0c0c0"), "light"); +}); + +test("themeFromOscColor returns 'dark' for dim background", () => { + // Luminance of #404040 ≈ 0.04 < 0.5 + assert.equal(themeFromOscColor("#404040"), "dark"); +}); + +test("themeFromOscColor uses ITU-R BT.709 luminance weights", () => { + // Pure green has highest luminance weight (0.7152) + // rgb:0000/8080/0000 → luminance ≈ 0.7152 * 0.5 ≈ 0.358 → dark + assert.equal(themeFromOscColor("rgb:0000/8080/0000"), "dark"); + + // Pure green bright → luminance > 0.5 → light + assert.equal(themeFromOscColor("rgb:0000/ffff/0000"), "light"); + + // Pure blue has lowest weight (0.0722) + // rgb:0000/0000/ffff → luminance ≈ 0.0722 → dark + assert.equal(themeFromOscColor("rgb:0000/0000/ffff"), "dark"); +}); + +test("themeFromOscColor returns undefined for invalid input", () => { + assert.equal(themeFromOscColor("invalid"), undefined); + assert.equal(themeFromOscColor(""), undefined); +}); + +// --------------------------------------------------------------------------- +// detectFromColorFgBg +// --------------------------------------------------------------------------- + +test("detectFromColorFgBg returns 'dark' for dark background indices", () => { + const original = process.env["COLORFGBG"]; + try { + // Index 0 = black + process.env["COLORFGBG"] = "15;0"; + assert.equal(detectFromColorFgBg(), "dark"); + + // Index 1 = red + process.env["COLORFGBG"] = "15;1"; + assert.equal(detectFromColorFgBg(), "dark"); + + // Index 6 = cyan + process.env["COLORFGBG"] = "15;6"; + assert.equal(detectFromColorFgBg(), "dark"); + + // Index 8 = bright black (dark gray) + process.env["COLORFGBG"] = "15;8"; + assert.equal(detectFromColorFgBg(), "dark"); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg returns 'light' for light background indices", () => { + const original = process.env["COLORFGBG"]; + try { + // Index 7 = light gray + process.env["COLORFGBG"] = "0;7"; + assert.equal(detectFromColorFgBg(), "light"); + + // Index 9 = bright red + process.env["COLORFGBG"] = "0;9"; + assert.equal(detectFromColorFgBg(), "light"); + + // Index 15 = bright white + process.env["COLORFGBG"] = "0;15"; + assert.equal(detectFromColorFgBg(), "light"); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg handles foreground;background format", () => { + const original = process.env["COLORFGBG"]; + try { + // Only the last segment (background) matters + process.env["COLORFGBG"] = "0;15"; + assert.equal(detectFromColorFgBg(), "light"); + + process.env["COLORFGBG"] = "15;0"; + assert.equal(detectFromColorFgBg(), "dark"); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg handles single value (background only)", () => { + const original = process.env["COLORFGBG"]; + try { + process.env["COLORFGBG"] = "0"; + assert.equal(detectFromColorFgBg(), "dark"); + + process.env["COLORFGBG"] = "15"; + assert.equal(detectFromColorFgBg(), "light"); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg returns undefined when COLORFGBG is not set", () => { + const original = process.env["COLORFGBG"]; + try { + delete process.env["COLORFGBG"]; + assert.equal(detectFromColorFgBg(), undefined); + } finally { + if (original !== undefined) { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg returns undefined for non-numeric value", () => { + const original = process.env["COLORFGBG"]; + try { + process.env["COLORFGBG"] = "abc"; + assert.equal(detectFromColorFgBg(), undefined); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectFromColorFgBg returns undefined for empty string", () => { + const original = process.env["COLORFGBG"]; + try { + process.env["COLORFGBG"] = ""; + assert.equal(detectFromColorFgBg(), undefined); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +// --------------------------------------------------------------------------- +// detectSystemTheme (sync entry point) +// --------------------------------------------------------------------------- + +test("detectSystemTheme returns a valid theme", () => { + const result = detectSystemTheme(); + assert.ok(result === "light" || result === "dark"); +}); + +test("detectSystemTheme prefers COLORFGBG over other sources", () => { + const original = process.env["COLORFGBG"]; + try { + process.env["COLORFGBG"] = "0;15"; // light background + assert.equal(detectSystemTheme(), "light"); + + process.env["COLORFGBG"] = "15;0"; // dark background + assert.equal(detectSystemTheme(), "dark"); + } finally { + if (original === undefined) { + delete process.env["COLORFGBG"]; + } else { + process.env["COLORFGBG"] = original; + } + } +}); + +test("detectSystemTheme falls back to 'dark' when no sources available", () => { + const original = process.env["COLORFGBG"]; + try { + delete process.env["COLORFGBG"]; + // On non-macOS, detectMacOSTheme returns undefined → falls back to "dark" + // On macOS, it depends on system settings + const result = detectSystemTheme(); + assert.ok(result === "light" || result === "dark"); + } finally { + if (original !== undefined) { + process.env["COLORFGBG"] = original; + } + } +}); diff --git a/src/tests/permission-prompt.test.ts b/src/tests/permission-prompt.test.ts index 4f1d87e..26ef1a4 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.risk.low); + assert.equal(getScopeRiskColor("query-git-log"), LIGHT_THEME.risk.low); - 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.risk.medium); + assert.equal(getScopeRiskColor("write-in-cwd"), LIGHT_THEME.risk.medium); + assert.equal(getScopeRiskColor("network"), LIGHT_THEME.risk.medium); + assert.equal(getScopeRiskColor("mcp"), LIGHT_THEME.risk.medium); - assert.equal(getScopeRiskColor("write-out-cwd"), "#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.risk.high); + assert.equal(getScopeRiskColor("delete-in-cwd"), LIGHT_THEME.risk.high); + assert.equal(getScopeRiskColor("delete-out-cwd"), LIGHT_THEME.risk.high); + assert.equal(getScopeRiskColor("mutate-git-log"), LIGHT_THEME.risk.high); + assert.equal(getScopeRiskColor("unknown"), LIGHT_THEME.risk.critical); }); diff --git a/src/tests/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-manager.test.ts b/src/tests/theme-manager.test.ts new file mode 100644 index 0000000..596bf5f --- /dev/null +++ b/src/tests/theme-manager.test.ts @@ -0,0 +1,357 @@ +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import { ThemeManager } from "../ui/theme/ThemeManager"; +import { LIGHT_THEME, DARK_THEME, PRESETS, setCurrentTheme, getCurrentThemeTokens } from "../ui/theme"; +import type { ThemeTokens, ThemePreset } from "../ui/theme"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a ThemeManager with mocked settings */ +function createManager(overrides?: { preset?: ThemePreset; themeSettings?: Record }): ThemeManager { + // The manager reads settings from disk on construction. + // For unit tests we rely on the default settings (no custom theme file). + return new ThemeManager(process.cwd()); +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +test("ThemeManager constructor initializes with default theme", () => { + const manager = createManager(); + const theme = manager.getTheme(); + assert.ok(theme, "getTheme() should return a theme"); + assert.ok(theme.mode === "light" || theme.mode === "dark", "theme should have a valid mode"); + assert.ok(typeof theme.text.primary === "string", "theme should have text.primary"); + manager.dispose(); +}); + +test("ThemeManager constructor initializes preset from settings", () => { + const manager = createManager(); + const preset = manager.getPreset(); + assert.ok(typeof preset === "string", "getPreset() should return a string"); + assert.ok( + [ + "light", + "dark", + "monokai", + "dracula", + "github-light", + "github-dark", + "ansi-light", + "ansi-dark", + "custom", + ].includes(preset), + `preset should be a valid ThemePreset, got: ${preset}` + ); + manager.dispose(); +}); + +test("ThemeManager constructor initializes terminalBg as null before init()", () => { + const manager = createManager(); + assert.equal(manager.getTerminalBackground(), null); + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// Lifecycle: init +// --------------------------------------------------------------------------- + +test("ThemeManager.init() detects terminal background", async () => { + const manager = createManager(); + await manager.init(); + const bg = manager.getTerminalBackground(); + assert.ok(bg === "light" || bg === "dark", `terminalBg should be 'light' or 'dark', got: ${bg}`); + manager.dispose(); +}); + +test("ThemeManager.init() refreshes theme after detection", async () => { + const manager = createManager(); + const themeBefore = manager.getTheme(); + await manager.init(); + const themeAfter = manager.getTheme(); + // Theme may or may not change depending on terminal background, but it should be valid + assert.ok(themeAfter, "theme should exist after init"); + assert.ok(typeof themeAfter.text.primary === "string", "theme should have valid text.primary after init"); + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// Lifecycle: polling +// --------------------------------------------------------------------------- + +test("ThemeManager.startPolling creates an interval", () => { + const manager = createManager(); + // Should not throw + manager.startPolling(100); + manager.stopPolling(); + manager.dispose(); +}); + +test("ThemeManager.stopPolling is idempotent", () => { + const manager = createManager(); + manager.stopPolling(); // no-op + manager.startPolling(100); + manager.stopPolling(); + manager.stopPolling(); // no-op + manager.dispose(); +}); + +test("ThemeManager.dispose stops polling and clears listeners", () => { + const manager = createManager(); + let called = false; + manager.onChange(() => { + called = true; + }); + manager.startPolling(100); + manager.dispose(); + // After dispose, listeners should be cleared + // We can't directly test this, but dispose should not throw + assert.ok(true, "dispose should not throw"); +}); + +// --------------------------------------------------------------------------- +// Query methods +// --------------------------------------------------------------------------- + +test("ThemeManager.getTheme returns valid ThemeTokens", () => { + const manager = createManager(); + const theme = manager.getTheme(); + assert.ok(theme.name, "theme should have name"); + assert.ok(theme.mode, "theme should have mode"); + assert.ok(theme.text, "theme should have text group"); + assert.ok(theme.brand, "theme should have brand group"); + assert.ok(theme.status, "theme should have status group"); + assert.ok(theme.gradients, "theme should have gradients group"); + manager.dispose(); +}); + +test("ThemeManager.getPreset returns a valid preset name", () => { + const manager = createManager(); + const preset = manager.getPreset(); + assert.ok(preset in PRESETS || preset === "custom", `invalid preset: ${preset}`); + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// previewTheme +// --------------------------------------------------------------------------- + +test("ThemeManager.previewTheme changes theme but does not change preset", () => { + const manager = createManager(); + const originalPreset = manager.getPreset(); + const originalTheme = manager.getTheme(); + + // Preview a different preset + const targetPreset: ThemePreset = originalPreset === "dark" ? "light" : "dark"; + manager.previewTheme(targetPreset); + + // Theme should change + const previewedTheme = manager.getTheme(); + assert.notEqual( + previewedTheme.text.primary, + originalTheme.text.primary, + "theme text.primary should change on preview" + ); + + // Preset should NOT change + assert.equal(manager.getPreset(), originalPreset, "preset should not change on preview"); + + manager.dispose(); +}); + +test("ThemeManager.previewTheme notifies listeners", () => { + const manager = createManager(); + let notified = false; + let notifiedTheme: ThemeTokens | null = null; + manager.onChange((theme) => { + notified = true; + notifiedTheme = theme; + }); + + manager.previewTheme("dark"); + assert.ok(notified, "listener should be called on preview"); + assert.ok(notifiedTheme, "listener should receive theme"); + + manager.dispose(); +}); + +test("ThemeManager.previewTheme with partial tokens works", () => { + const manager = createManager(); + const originalTheme = manager.getTheme(); + + manager.previewTheme({ brand: { primary: "#ff0000", secondary: "#ff0000", accent: "#ff0000" } }); + + const previewed = manager.getTheme(); + // The previewed theme should have the custom brand color + // (or it may be overridden by terminal contrast, but brand.primary is not affected by contrast) + assert.ok(previewed, "theme should exist after partial preview"); + + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// switchTheme +// --------------------------------------------------------------------------- + +test("ThemeManager.switchTheme changes theme and preset", () => { + const manager = createManager(); + const originalPreset = manager.getPreset(); + + const targetPreset: ThemePreset = originalPreset === "dark" ? "light" : "dark"; + manager.switchTheme(targetPreset); + + assert.equal(manager.getPreset(), targetPreset, "preset should change on switch"); + assert.ok(manager.getTheme(), "theme should exist after switch"); + + manager.dispose(); +}); + +test("ThemeManager.switchTheme notifies listeners", () => { + const manager = createManager(); + let notified = false; + manager.onChange(() => { + notified = true; + }); + + manager.switchTheme("dark"); + assert.ok(notified, "listener should be called on switch"); + + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// revertTheme +// --------------------------------------------------------------------------- + +test("ThemeManager.revertTheme restores saved preset after preview", () => { + const manager = createManager(); + const originalPreset = manager.getPreset(); + const originalTheme = manager.getTheme(); + + // Preview a different theme (not saved to settings) + const targetPreset: ThemePreset = originalPreset === "dark" ? "light" : "dark"; + manager.previewTheme(targetPreset); + assert.notEqual(manager.getTheme().text.primary, originalTheme.text.primary, "theme should change on preview"); + + // Revert should restore the saved theme + manager.revertTheme(); + assert.equal(manager.getPreset(), originalPreset, "preset should revert to original"); + + manager.dispose(); +}); + +test("ThemeManager.revertTheme notifies listeners", () => { + const manager = createManager(); + let notified = false; + manager.onChange(() => { + notified = true; + }); + + manager.switchTheme("dark"); + notified = false; + manager.revertTheme(); + assert.ok(notified, "listener should be called on revert"); + + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// onChange +// --------------------------------------------------------------------------- + +test("ThemeManager.onChange returns unsubscribe function", () => { + const manager = createManager(); + let callCount = 0; + const unsubscribe = manager.onChange(() => { + callCount++; + }); + + manager.previewTheme("dark"); + assert.equal(callCount, 1); + + unsubscribe(); + manager.previewTheme("light"); + assert.equal(callCount, 1, "listener should not be called after unsubscribe"); + + manager.dispose(); +}); + +test("ThemeManager.onChange receives correct theme and preset", () => { + const manager = createManager(); + let receivedTheme: ThemeTokens | null = null; + let receivedPreset: ThemePreset | null = null; + + manager.onChange((theme, preset) => { + receivedTheme = theme; + receivedPreset = preset; + }); + + manager.switchTheme("dark"); + + assert.ok(receivedTheme, "should receive theme"); + assert.equal(receivedPreset, "dark", "should receive preset 'dark'"); + + manager.dispose(); +}); + +test("Multiple listeners are all called", () => { + const manager = createManager(); + let count1 = 0; + let count2 = 0; + + manager.onChange(() => { + count1++; + }); + manager.onChange(() => { + count2++; + }); + + manager.previewTheme("dark"); + assert.equal(count1, 1, "first listener should be called"); + assert.equal(count2, 1, "second listener should be called"); + + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// refreshFromSettings +// --------------------------------------------------------------------------- + +test("ThemeManager.refreshFromSettings updates theme", () => { + const manager = createManager(); + const themeBefore = manager.getTheme(); + manager.refreshFromSettings(); + const themeAfter = manager.getTheme(); + // Should return a valid theme (may be same if settings unchanged) + assert.ok(themeAfter, "theme should exist after refresh"); + assert.ok(typeof themeAfter.text.primary === "string", "theme should have valid text.primary"); + manager.dispose(); +}); + +test("ThemeManager.refreshFromSettings notifies listeners", () => { + const manager = createManager(); + let notified = false; + manager.onChange(() => { + notified = true; + }); + + manager.refreshFromSettings(); + assert.ok(notified, "listener should be called on refresh"); + + manager.dispose(); +}); + +// --------------------------------------------------------------------------- +// setCurrentTheme integration +// --------------------------------------------------------------------------- + +test("ThemeManager.switchTheme updates global setCurrentTheme", () => { + const manager = createManager(); + manager.switchTheme("dark"); + const globalTheme = getCurrentThemeTokens(); + assert.equal(globalTheme.brand.primary, manager.getTheme().brand.primary, "global theme should match manager theme"); + manager.dispose(); +}); diff --git a/src/tests/theme.test.ts b/src/tests/theme.test.ts new file mode 100644 index 0000000..b0ea99e --- /dev/null +++ b/src/tests/theme.test.ts @@ -0,0 +1,344 @@ +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, + 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, ThemePreset } from "../ui/theme"; + +chalk.level = 1; + +const DEFAULTS = { + model: "test-model", + baseURL: "https://test.example.com", +}; + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +test("LIGHT_THEME has all required top-level groups", () => { + const groups = [ + "mode", + "text", + "border", + "surface", + "brand", + "status", + "risk", + "typography", + "link", + "inlineCode", + "codeBlock", + "syntax", + "blockquote", + "list", + "task", + "table", + "hr", + "admonition", + "diff", + "agent", + "approval", + "gradients", + ]; + for (const key of groups) { + assert.ok(key in LIGHT_THEME, `LIGHT_THEME is missing group: ${key}`); + } +}); + +test("LIGHT_THEME brand colors match expected values", () => { + assert.equal(LIGHT_THEME.brand.primary, "#229ac3"); + assert.equal(LIGHT_THEME.brand.secondary, "#229ac3cc"); +}); + +test("all presets have a name field", () => { + for (const [key, preset] of Object.entries(PRESETS)) { + assert.ok(typeof preset.name === "string" && preset.name.length > 0, `preset "${key}" missing name`); + } +}); + +test("LIGHT_THEME status colors match expected values", () => { + assert.equal(LIGHT_THEME.status.success, "#1a7f37"); + assert.equal(LIGHT_THEME.status.danger, "#d1242f"); + assert.equal(LIGHT_THEME.status.warning, "#fa8c16"); + assert.equal(LIGHT_THEME.status.info, "#0969da"); +}); + +test("LIGHT_THEME text colors match expected values", () => { + assert.equal(LIGHT_THEME.text.primary, "#1F2328"); + assert.equal(LIGHT_THEME.text.secondary, "#46484b"); + assert.equal(LIGHT_THEME.text.muted, "#8b949e"); + // inverse = 背景色,用于深色背景上的反色文字 + assert.equal(LIGHT_THEME.text.inverse, "#ffffff"); +}); + +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("ansi-light" in PRESETS); + assert.ok("ansi-dark" in PRESETS); + assert.equal(Object.keys(PRESETS).length, 8); + assert.equal(PRESETS.light, LIGHT_THEME); + assert.equal(PRESETS.dark, DARK_THEME); + 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.brand.primary, LIGHT_THEME.brand.primary); + assert.equal(result.status.success, LIGHT_THEME.status.success); +}); + +test("resolveTheme returns LIGHT_THEME for explicit 'light' preset", () => { + const result = resolveTheme({ preset: "light" }); + assert.equal(result.brand.primary, LIGHT_THEME.brand.primary); +}); + +test("resolveTheme returns DARK_THEME for 'dark' preset", () => { + const result = resolveTheme({ preset: "dark" }); + assert.equal(result.brand.primary, DARK_THEME.brand.primary); + assert.equal(result.status.success, DARK_THEME.status.success); + assert.equal(result.mode, "dark"); +}); + +test("resolveTheme returns MONOKAI_THEME for 'monokai' preset", () => { + const result = resolveTheme({ preset: "monokai" }); + assert.equal(result.brand.primary, MONOKAI_THEME.brand.primary); + assert.equal(result.status.success, MONOKAI_THEME.status.success); + assert.equal(result.mode, "dark"); +}); + +test("resolveTheme returns DRACULA_THEME for 'dracula' preset", () => { + const result = resolveTheme({ preset: "dracula" }); + assert.equal(result.brand.primary, DRACULA_THEME.brand.primary); + assert.equal(result.status.success, DRACULA_THEME.status.success); + assert.equal(result.mode, "dark"); +}); + +test("resolveTheme applies overrides when preset is 'custom'", () => { + const result = resolveTheme({ + preset: "custom", + overrides: { brand: { primary: "#ff0000", secondary: "#ff0000", accent: "#ff0000" } }, + }); + assert.equal(result.brand.primary, "#ff0000"); + assert.equal(result.status.success, LIGHT_THEME.status.success); +}); + +test("resolveTheme full custom tokens with custom preset", () => { + const customTokens = { ...LIGHT_THEME, brand: { primary: "#aaaaaa", secondary: "#aaaaaacc", accent: "#aaaaaa" } }; + const result = resolveTheme({ preset: "custom", tokens: customTokens }); + assert.equal(result.brand.primary, "#aaaaaa"); +}); + +test("resolveTheme handles override with undefined fields gracefully", () => { + const result = resolveTheme({ + preset: "custom", + overrides: { brand: undefined } as Partial, + }); + assert.equal(result.brand.primary, LIGHT_THEME.brand.primary); +}); + +test("resolveTheme ignores overrides when preset is not custom", () => { + const result = resolveTheme({ + preset: "light", + overrides: { brand: { primary: "#ff0000", secondary: "#ff0000", accent: "#ff0000" } }, + }); + assert.equal(result.brand.primary, LIGHT_THEME.brand.primary); +}); + +test("resolveTheme returns LIGHT_THEME for custom preset without token/overrides", () => { + const result = resolveTheme({ preset: "custom" }); + assert.equal(result.brand.primary, LIGHT_THEME.brand.primary); +}); + +test("resolveTheme custom with base='dark' merges overrides onto DARK_THEME", () => { + const result = resolveTheme({ + preset: "custom", + base: "dark", + overrides: { brand: { primary: "#ff0000", secondary: "#ff0000", accent: "#ff0000" } }, + }); + // brand.primary should be overridden + assert.equal(result.brand.primary, "#ff0000"); + // mode and status should come from DARK_THEME (not affected by terminal contrast) + assert.equal(result.mode, "dark"); + assert.equal(result.status.success, DARK_THEME.status.success); +}); + +test("resolveTheme custom with invalid base falls back to LIGHT_THEME", () => { + const result = resolveTheme({ + preset: "custom", + base: "nonexistent" as ThemePreset, + overrides: { brand: { primary: "#ff0000", secondary: "#ff0000", accent: "#ff0000" } }, + }); + assert.equal(result.brand.primary, "#ff0000"); + assert.equal(result.mode, "light"); // falls back to LIGHT_THEME +}); + +// --------------------------------------------------------------------------- +// createThemedChalk +// --------------------------------------------------------------------------- + +test("createThemedChalk heading1 produces styled output via typography.h1", () => { + const tc = createThemedChalk(LIGHT_THEME); + assert.notEqual(tc.heading1("Hello"), "Hello"); +}); + +test("createThemedChalk heading1 changes when typography.h1 changes", () => { + const custom: ThemeTokens = { ...LIGHT_THEME, typography: { ...LIGHT_THEME.typography, h1: "#ff0000" } }; + assert.notEqual(createThemedChalk(LIGHT_THEME).heading1("test"), createThemedChalk(custom).heading1("test")); +}); + +test("createThemedChalk inlineCode changes when inlineCode.foreground changes", () => { + const custom: ThemeTokens = { ...LIGHT_THEME, inlineCode: { ...LIGHT_THEME.inlineCode, foreground: "#ff0000" } }; + assert.notEqual(createThemedChalk(LIGHT_THEME).inlineCode("test"), createThemedChalk(custom).inlineCode("test")); +}); + +test("createThemedChalk listBullet changes when list.bullet changes", () => { + const custom: ThemeTokens = { ...LIGHT_THEME, list: { ...LIGHT_THEME.list, bullet: "#ff0000" } }; + assert.notEqual(createThemedChalk(LIGHT_THEME).listBullet("test"), createThemedChalk(custom).listBullet("test")); +}); + +test("createThemedChalk quote changes when blockquote.foreground changes", () => { + const custom: ThemeTokens = { ...LIGHT_THEME, blockquote: { ...LIGHT_THEME.blockquote, foreground: "#ff0000" } }; + assert.notEqual(createThemedChalk(LIGHT_THEME).quote("test"), createThemedChalk(custom).quote("test")); +}); + +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 text.primary values", () => { + const custom1: ThemeTokens = { ...LIGHT_THEME, text: { ...LIGHT_THEME.text, primary: "#ff0000" } }; + const custom2: ThemeTokens = { ...LIGHT_THEME, text: { ...LIGHT_THEME.text, primary: "#00ff00" } }; + assert.notEqual(createThemedChalk(custom1).text("test"), createThemedChalk(custom2).text("test")); +}); + +// --------------------------------------------------------------------------- +// current-theme (module-level state) +// --------------------------------------------------------------------------- + +test("getCurrentThemedChalk returns LIGHT_THEME chalk by default", () => { + setCurrentTheme(LIGHT_THEME); + assert.notEqual(getCurrentThemedChalk().brandPrimary("test"), "test"); +}); + +test("setCurrentTheme changes getCurrentThemedChalk output", () => { + setCurrentTheme(LIGHT_THEME); + const first = getCurrentThemedChalk().text("test"); + + const custom: ThemeTokens = { ...LIGHT_THEME, text: { ...LIGHT_THEME.text, primary: "#ff0000" } }; + setCurrentTheme(custom); + const second = getCurrentThemedChalk().text("test"); + + assert.notEqual(first, second); + + setCurrentTheme(LIGHT_THEME); +}); + +test("setCurrentTheme changes getCurrentThemeTokens output", () => { + setCurrentTheme(LIGHT_THEME); + assert.equal(getCurrentThemeTokens().brand.primary, LIGHT_THEME.brand.primary); + + const custom: ThemeTokens = { ...LIGHT_THEME, brand: { ...LIGHT_THEME.brand, primary: "#ff0000" } }; + setCurrentTheme(custom); + assert.equal(getCurrentThemeTokens().brand.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.brand.primary, LIGHT_THEME.brand.primary); +}); + +test("resolveSettingsSources resolves custom theme from user settings", () => { + const result = resolveSettingsSources( + { + theme: { + preset: "custom", + overrides: { brand: { primary: "#abcdef", secondary: "#abcdef", accent: "#abcdef" } }, + }, + }, + null, + DEFAULTS, + {} + ); + assert.equal(result.theme.brand.primary, "#abcdef"); +}); + +test("resolveSettingsSources resolves custom theme from project settings", () => { + const result = resolveSettingsSources( + null, + { + theme: { + preset: "custom", + overrides: { brand: { primary: "#123456", secondary: "#123456", accent: "#123456" } }, + }, + }, + DEFAULTS, + {} + ); + assert.equal(result.theme.brand.primary, "#123456"); +}); + +test("resolveSettingsSources uses default theme when preset is not custom", () => { + const result = resolveSettingsSources( + { + theme: { preset: "light", overrides: { brand: { primary: "#abcdef", secondary: "#abcdef", accent: "#abcdef" } } }, + }, + null, + DEFAULTS, + {} + ); + assert.equal(result.theme.brand.primary, LIGHT_THEME.brand.primary); +}); + +// --------------------------------------------------------------------------- +// getScopeRiskColor with theme parameter +// --------------------------------------------------------------------------- + +test("getScopeRiskColor returns default theme colors when no theme is passed", () => { + assert.equal(getScopeRiskColor("read-in-cwd"), LIGHT_THEME.risk.low); + assert.equal(getScopeRiskColor("write-in-cwd"), LIGHT_THEME.risk.medium); + assert.equal(getScopeRiskColor("write-out-cwd"), LIGHT_THEME.risk.high); +}); + +test("getScopeRiskColor uses theme risk colors when theme is provided", () => { + const custom: Partial = { + risk: { low: "#aaaaaa", medium: "#bbbbbb", high: "#cccccc", critical: "#dddddd" }, + }; + assert.equal(getScopeRiskColor("read-in-cwd", custom as ThemeTokens), "#aaaaaa"); + assert.equal(getScopeRiskColor("mcp", custom as ThemeTokens), "#bbbbbb"); + 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..f5a7291 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.brand.accent; + const effectiveActiveColor = activeColor ?? theme.brand.accent; // Calculate visible window const visibleStart = calculateVisibleStart(activeIndex, items?.length, maxVisible); const visibleItems = items?.slice(visibleStart, visibleStart + maxVisible); @@ -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} + ); })} @@ -177,13 +197,15 @@ const DropdownMenu = React.memo(function DropdownMenu({ {/* Help text */} {helpText ? ( {helpText} diff --git a/src/ui/components/FileMentionMenu/index.tsx b/src/ui/components/FileMentionMenu/index.tsx index f00b367..ca3c285 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..e028de8 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,19 +185,45 @@ function StatusLine({ } function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElement { + const theme = useTheme(); + const getBackgroundColor = (kind: string) => { + switch (kind) { + case "added": + return theme.diff.addedBackground; + case "removed": + return theme.diff.removedBackground; + case "modified": + return theme.diff.modifiedBackground; + default: + return undefined; + } + }; + const getColor = (kind: string) => { + switch (kind) { + case "added": + return theme.diff.added; + case "removed": + return theme.diff.removed; + case "modified": + return theme.diff.modified; + default: + return undefined; + } + }; return ( └ Changes - + {lines.map((line, index) => ( - - - {line.marker} - - - {line.content} - - + + {line.marker} + {line.content} + ))} diff --git a/src/ui/components/MessageView/markdown.ts b/src/ui/components/MessageView/markdown.ts index 0b01264..042ad9b 100644 --- a/src/ui/components/MessageView/markdown.ts +++ b/src/ui/components/MessageView/markdown.ts @@ -1,4 +1,6 @@ import chalk from "chalk"; +import { getCurrentThemedChalk } from "../../theme"; +import type { ThemedChalk } from "../../theme"; /** * A rendered piece of markdown. Consumers should use `wrap="truncate-end"` for @@ -33,16 +35,17 @@ export function renderMarkdownSegments(text: string, maxWidth?: number): Markdow const segments: MarkdownSegment[] = []; const fenceSegments = splitByFences(text); + const tc = getCurrentThemedChalk(); for (const seg of fenceSegments) { if (seg.kind === "code") { - const langTag = seg.lang ? chalk.dim(`[${seg.lang}]`) + "\n" : ""; - segments.push({ kind: "code", body: langTag + chalk.cyan(seg.body), lang: seg.lang }); + const langTag = seg.lang ? tc.dim(`[${seg.lang}]`) + "\n" : ""; + segments.push({ kind: "code", body: langTag + tc.code(seg.body), lang: seg.lang }); continue; } const blocks = splitTableBlocks(seg.body); for (const b of blocks) { if (b.kind === "table") { - segments.push({ kind: "table", body: renderTableBorder(b.rows, maxWidth) }); + segments.push({ kind: "table", body: renderTableBorder(b.rows, maxWidth, tc) }); } else { const body = b.body .split("\n") @@ -225,7 +228,7 @@ function isWideChar(code: number): boolean { // Table rendering // --------------------------------------------------------------------------- -function renderTableBorder(rows: string[][], maxWidth?: number): string { +function renderTableBorder(rows: string[][], maxWidth?: number, tc?: ThemedChalk): string { if (rows.length === 0) return ""; const colCount = rows[0].length; @@ -340,10 +343,11 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - visualWidth(s))); - const top = "┌" + colWidths.map((w) => "─".repeat(w + 2)).join("┬") + "┐"; - const hdr = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; - const sep = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; - const bot = "└" + colWidths.map((w) => "─".repeat(w + 2)).join("┴") + "┘"; + const b = tc?.tableBorder ?? ((s: string) => s); + const top = b("┌") + colWidths.map((w) => b("─".repeat(w + 2))).join(b("┬")) + b("┐"); + const hdr = b("├") + colWidths.map((w) => b("─".repeat(w + 2))).join(b("┼")) + b("┤"); + const sep = b("├") + colWidths.map((w) => b("─".repeat(w + 2))).join(b("┼")) + b("┤"); + const bot = b("└") + colWidths.map((w) => b("─".repeat(w + 2))).join(b("┴")) + b("┘"); const out: string[] = [top]; @@ -351,7 +355,7 @@ function renderTableBorder(rows: string[][], maxWidth?: number): string { const h = heights[ri]; for (let li = 0; li < h; li++) { const line = wrapped[ri].map((cellLines, ci) => " " + pad(cellLines[li] ?? "", colWidths[ci]) + " "); - out.push("│" + line.join("│") + "│"); + out.push(b("│") + line.join(b("│")) + b("│")); } if (ri === 0 && rows.length > 1) out.push(hdr); else if (ri < rows.length - 1) out.push(sep); @@ -366,29 +370,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,6 +401,7 @@ function renderInlineLine(line: string): string { function renderInlineSpans(text: string): string { if (!text) return text; + const tc = getCurrentThemedChalk(); const parts: string[] = []; const codeRe = /`([^`]+)`/g; @@ -419,6 +425,10 @@ function renderInlineSpans(text: string): string { function renderEmphasisSpans(text: string): string { let result = text; + 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)); result = result.replace(/\*\*([^*]+)\*\*/g, (_, inner) => chalk.bold(inner)); result = result.replace(/(? chalk.italic(inner)); result = result.replace(/(? chalk.italic(inner)); diff --git a/src/ui/components/MessageView/utils.ts b/src/ui/components/MessageView/utils.ts index 91ae64b..26a914d 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 { @@ -222,13 +223,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.brandPrimary(`> ${text}`); } if (message.role === "assistant") { @@ -249,12 +251,12 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s const statusLine = `${chalk("✧")} ${chalk(formatStatusName(summary.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 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}`; @@ -262,14 +264,14 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s if (message.role === "system") { if (message.meta?.isModelChange) { - return chalk(`> ${message.content}`); + return tc.brandPrimary(`> ${message.content}`); } if (message.meta?.skill && typeof message.meta.skill === "object") { const skillName = (message.meta.skill as { name?: unknown }).name; 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..0b6ff8d 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.status.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..ed3f5af --- /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", + "monokai", + "dracula", + "ansi-light", + "ansi-dark", + "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: presetTheme?.name ?? preset, + labelColor: presetTheme?.brand.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..1e8b18f --- /dev/null +++ b/src/ui/components/ThemeableStatic/index.tsx @@ -0,0 +1,41 @@ +import React, { useMemo } from "react"; +import { Static } 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} 在主题变化或内容重置时强制重新挂载。 + * + * 使用 React.memo + 自定义比较跳过 children (render prop) 的比较, + * 避免父组件因 nowTick 等无关状态变化导致每帧重渲染。 + */ +const ThemeableStaticInner = function ThemeableStaticInner({ + items, + themeVersion, + resetKey, + children: render, +}: Props): React.ReactElement { + const compositeKey = `${themeVersion}:${resetKey ?? 0}`; + + const wrappedRender = useMemo(() => (item: T, index: number) => render(item, index), [render]); + return ( + + {wrappedRender} + + ); +}; + +function propsAreEqual(prev: Props, next: Props): boolean { + return prev.items === next.items && prev.themeVersion === next.themeVersion && prev.resetKey === next.resetKey; +} + +export default React.memo(ThemeableStaticInner, propsAreEqual) as typeof ThemeableStaticInner; 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..2c98e4a 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 (previous session resumable with /resume)", }, { kind: "init", diff --git a/src/ui/exit-summary.ts b/src/ui/exit-summary.ts index c55d9ce..a7c1933 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.border.subtle); + const titleColor = gradientString(...theme.gradients.logo); 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.textMuted(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/ThemeManager.ts b/src/ui/theme/ThemeManager.ts new file mode 100644 index 0000000..bdf985b --- /dev/null +++ b/src/ui/theme/ThemeManager.ts @@ -0,0 +1,199 @@ +import { resolveCurrentSettings, readSettings, readProjectSettings, writeSettings } from "../../settings"; +import type { ThemeTokens, ThemePreset, ThemeSettings } from "./types"; +import { detectSystemTheme, detectTerminalThemeAsync } from "./detect-system-theme"; +import { resolveTheme } from "./resolver"; +import { setCurrentTheme } from "./current-theme"; + +/** 主题变更回调 */ +type ThemeChangeListener = (theme: ThemeTokens, preset: ThemePreset) => void; + +/** + * 主题管理器。 + * 统一管理终端背景检测、主题解析、预览/切换/回退、运行时轮询。 + */ +export class ThemeManager { + private projectRoot: string; + private terminalBg: "light" | "dark" | null = null; + private currentTheme: ThemeTokens; + private currentPreset: ThemePreset; + private pollTimer: ReturnType | null = null; + private listeners = new Set(); + + constructor(projectRoot: string) { + this.projectRoot = projectRoot; + const settings = resolveCurrentSettings(projectRoot); + this.currentTheme = settings.theme; + this.currentPreset = this.loadPresetFromSettings(); + } + + // ——— 生命周期 ——— + + /** + * 异步初始化(含 OSC 11 终端背景查询)。 + * 应在 App 启动时调用一次。 + * + * 只有主题实际变化时才通知监听器,避免首次挂载时因 themeVersion + * 递增导致 Static 组件卸载再挂载,产生重复的欢迎页渲染。 + */ + async init(): Promise { + this.terminalBg = await detectTerminalThemeAsync(); + const themeSettings = this.loadThemeSettings(); + const newTheme = this.resolveWithContrast(themeSettings); + const newPreset = this.loadPresetFromSettings(); + + if (newPreset !== this.currentPreset) { + this.applyTheme(newTheme, newPreset); + } else { + this.currentTheme = newTheme; + this.currentPreset = newPreset; + setCurrentTheme(newTheme); + } + } + + /** + * 启动运行时终端背景轮询。 + * 检测到变化时自动刷新主题。 + */ + startPolling(intervalMs = 3000): void { + this.stopPolling(); + this.pollTimer = setInterval(() => { + const detected = detectSystemTheme(); + if (this.terminalBg !== null && detected !== this.terminalBg) { + this.terminalBg = detected; + this.refreshFromSettings(); + } + }, intervalMs); + } + + /** 停止轮询 */ + stopPolling(): void { + if (this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + /** 注销所有资源 */ + dispose(): void { + this.stopPolling(); + this.listeners.clear(); + } + + // ——— 查询 ——— + + /** 获取当前主题 */ + getTheme(): ThemeTokens { + return this.currentTheme; + } + + /** 获取当前预设名称 */ + getPreset(): ThemePreset { + return this.currentPreset; + } + + /** 获取终端背景色 */ + getTerminalBackground(): "light" | "dark" | null { + return this.terminalBg; + } + + // ——— 操作 ——— + + /** + * 预览主题:仅切换 UI,不保存到 settings,不更新 currentPreset。 + * 用于 /theme 选择器中上下键浏览。 + */ + previewTheme(presetOrTokens: string | Partial): void { + const themeSettings = this.buildThemeSettings(presetOrTokens); + const newTheme = this.resolveWithContrast(themeSettings); + this.applyTheme(newTheme, this.currentPreset); + } + + /** + * 切换主题并持久化到 settings.json。 + * 用于 /theme 选择器中确认选择。 + */ + switchTheme(presetOrTokens: string | Partial): void { + const preset: ThemePreset = typeof presetOrTokens === "string" ? (presetOrTokens as ThemePreset) : "custom"; + const themeSettings = this.buildThemeSettings(presetOrTokens); + const newTheme = this.resolveWithContrast(themeSettings); + + this.currentPreset = preset; + this.applyTheme(newTheme, preset); + this.persistToSettings(preset, presetOrTokens); + } + + /** + * 回退到 settings 中已保存的主题。 + * 用于 /theme 选择器中按 Esc 取消。 + */ + revertTheme(): void { + this.currentPreset = this.loadPresetFromSettings(); + const themeSettings = this.loadThemeSettings(); + const newTheme = this.resolveWithContrast(themeSettings); + this.applyTheme(newTheme, this.currentPreset); + } + + /** + * 从 settings 重新解析主题(终端背景变化时调用)。 + */ + refreshFromSettings(): void { + const themeSettings = this.loadThemeSettings(); + const newTheme = this.resolveWithContrast(themeSettings); + this.currentPreset = this.loadPresetFromSettings(); + this.applyTheme(newTheme, this.currentPreset); + } + + // ——— 监听 ——— + + /** 注册主题变更回调 */ + onChange(listener: ThemeChangeListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + // ——— 内部方法 ——— + + private applyTheme(theme: ThemeTokens, preset: ThemePreset): void { + this.currentTheme = theme; + setCurrentTheme(theme); + for (const listener of this.listeners) { + listener(theme, preset); + } + } + + private resolveWithContrast(themeSettings?: ThemeSettings): ThemeTokens { + // 先用缓存的终端背景解析,如果还没有缓存则同步检测 + if (this.terminalBg === null) { + this.terminalBg = detectSystemTheme(); + } + return resolveTheme(themeSettings, this.terminalBg); + } + + private buildThemeSettings(presetOrTokens: string | Partial): ThemeSettings { + if (typeof presetOrTokens === "string") { + return { preset: presetOrTokens as ThemePreset }; + } + return { preset: "custom", overrides: presetOrTokens }; + } + + private loadThemeSettings(): ThemeSettings | undefined { + const userSettings = readSettings(); + const projectSettings = readProjectSettings(this.projectRoot); + return userSettings?.theme ?? projectSettings?.theme; + } + + private loadPresetFromSettings(): ThemePreset { + const userSettings = readSettings(); + const projectSettings = readProjectSettings(this.projectRoot); + return (userSettings?.theme?.preset ?? projectSettings?.theme?.preset ?? "light") as ThemePreset; + } + + private persistToSettings(preset: ThemePreset, presetOrTokens: string | Partial): void { + const currentSettings = readSettings() ?? {}; + const newThemeSettings: ThemeSettings = { + preset, + ...(typeof presetOrTokens !== "string" ? { overrides: presetOrTokens } : {}), + }; + writeSettings({ ...currentSettings, theme: newThemeSettings }); + } +} diff --git a/src/ui/theme/chalk-theme.ts b/src/ui/theme/chalk-theme.ts new file mode 100644 index 0000000..12c0cf7 --- /dev/null +++ b/src/ui/theme/chalk-theme.ts @@ -0,0 +1,456 @@ +import chalk, { type ChalkInstance } from "chalk"; +import type { ThemeTokens } from "./types"; + +/** + * 将 ThemeTokens 中的颜色 token 转换为实际的 chalk 颜色实例。 + * 对于命名颜色(如 "cyanBright"),通过 chalk 的索引访问获取对应颜色函数。 + * 对于 hex 颜色,直接用 chalk.hex()。 + */ +function chalkColor(color: string): ChalkInstance { + if (color.startsWith("#")) { + return chalk.hex(color); + } + const chalkWithIndex = chalk as unknown as Record; + const instance = chalkWithIndex[color]; + if (instance) { + return instance; + } + return chalk; +} + +/** + * 创建背景色 chalk 实例。 + * hex 颜色使用 chalk.bgHex(),命名颜色使用 chalk.bgXxx()。 + */ +function chalkBgColor(color: string): ChalkInstance { + if (color.startsWith("#")) { + return chalk.bgHex(color); + } + const name = `bg${color.charAt(0).toUpperCase()}${color.slice(1)}`; + const chalkWithIndex = chalk as unknown as Record; + const instance = chalkWithIndex[name]; + if (instance) { + return instance; + } + return chalk; +} + +type StyleFn = (text: string) => string; + +/** + * 主题化 chalk 样式函数集合。 + * 与 ThemeTokens 分组一一对应,用于 markdown 渲染、raw mode 输出等非 Ink 组件的终端输出。 + */ +export interface ThemedChalk { + // ——— text ——— + text: StyleFn; + textSecondary: StyleFn; + textMuted: StyleFn; + textDisabled: StyleFn; + textInverse: StyleFn; + + // ——— border ——— + borderDefault: StyleFn; + borderSubtle: StyleFn; + borderActive: StyleFn; + borderFocus: StyleFn; + + // ——— surface ——— + surfaceDefault: StyleFn; + surfaceElevated: StyleFn; + surfaceMuted: StyleFn; + surfaceCode: StyleFn; + surfacePanel: StyleFn; + surfaceQuote: StyleFn; + surfaceSelection: StyleFn; + + // ——— brand ——— + brandPrimary: StyleFn; + brandSecondary: StyleFn; + brandAccent: StyleFn; + + // ——— status ——— + success: StyleFn; + warning: StyleFn; + danger: StyleFn; + info: StyleFn; + + // ——— risk ——— + riskLow: StyleFn; + riskMedium: StyleFn; + riskHigh: StyleFn; + riskCritical: StyleFn; + + // ——— typography (Markdown 渲染) ——— + heading1: StyleFn; + heading2: StyleFn; + heading3: StyleFn; + heading4: StyleFn; + heading5: StyleFn; + heading6: StyleFn; + paragraph: StyleFn; + strong: StyleFn; + emphasis: StyleFn; + delete: StyleFn; + + // ——— link ——— + link: StyleFn; + linkVisited: StyleFn; + linkHover: StyleFn; + + // ——— inlineCode ——— + inlineCode: StyleFn; + inlineCodeBg: StyleFn; + inlineCodeBorder: StyleFn; + + // ——— codeBlock ——— + code: StyleFn; + codeBg: StyleFn; + codeBorder: StyleFn; + codeTitle: StyleFn; + lineNumber: StyleFn; + codeHighlight: StyleFn; + + // ——— syntax ——— + syntaxKeyword: StyleFn; + syntaxString: StyleFn; + syntaxFunction: StyleFn; + syntaxVariable: StyleFn; + syntaxProperty: StyleFn; + syntaxType: StyleFn; + syntaxNumber: StyleFn; + syntaxOperator: StyleFn; + syntaxPunctuation: StyleFn; + syntaxComment: StyleFn; + syntaxRegexp: StyleFn; + syntaxConstant: StyleFn; + + // ——— blockquote ——— + quote: StyleFn; + quoteBorder: StyleFn; + + // ——— list ——— + listBullet: StyleFn; + listOrdered: StyleFn; + listMarker: StyleFn; + + // ——— task ——— + taskChecked: StyleFn; + taskUnchecked: StyleFn; + + // ——— table ——— + tableBorder: StyleFn; + tableHeaderFg: StyleFn; + tableHeaderBg: StyleFn; + tableCellFg: StyleFn; + + // ——— hr ——— + hr: StyleFn; + + // ——— admonition ——— + admonitionNote: StyleFn; + admonitionTip: StyleFn; + admonitionWarning: StyleFn; + admonitionImportant: StyleFn; + admonitionCaution: StyleFn; + + // ——— diff ——— + diffAdded: StyleFn; + diffRemoved: StyleFn; + diffModified: StyleFn; + diffAddedBg: StyleFn; + diffRemovedBg: StyleFn; + diffModifiedBg: StyleFn; + + // ——— agent ——— + agentThinking: StyleFn; + agentReasoning: StyleFn; + agentToolCall: StyleFn; + agentToolResult: StyleFn; + agentStreaming: StyleFn; + agentCompleted: StyleFn; + + // ——— approval ——— + approvalAllow: StyleFn; + approvalDeny: StyleFn; + approvalReview: StyleFn; + + // ——— chalk 修饰符(不依赖主题色) ——— + bold: StyleFn; + italic: StyleFn; + dim: StyleFn; +} + +export function createThemedChalk(theme: ThemeTokens): ThemedChalk { + // text + const txPrimary = chalkColor(theme.text.primary); + const txSecondary = chalkColor(theme.text.secondary); + const txMuted = chalkColor(theme.text.muted); + const txDisabled = chalkColor(theme.text.disabled); + const txInverse = chalkColor(theme.text.inverse); + + // border + const brDefault = chalkColor(theme.border.default); + const brSubtle = chalkColor(theme.border.subtle); + const brActive = chalkColor(theme.border.active); + const brFocus = chalkColor(theme.border.focus); + + // surface (background) + const sfDefault = chalkBgColor(theme.surface.default); + const sfElevated = chalkBgColor(theme.surface.elevated); + const sfMuted = chalkBgColor(theme.surface.muted); + const sfCode = chalkBgColor(theme.surface.code); + const sfPanel = chalkBgColor(theme.surface.panel); + const sfQuote = chalkBgColor(theme.surface.quote); + const sfSelection = chalkBgColor(theme.surface.selection); + + // brand + const brBrandPrimary = chalkColor(theme.brand.primary); + const brBrandSecondary = chalkColor(theme.brand.secondary); + const brBrandAccent = chalkColor(theme.brand.accent); + + // status + const stSuccess = chalkColor(theme.status.success); + const stWarning = chalkColor(theme.status.warning); + const stDanger = chalkColor(theme.status.danger); + const stInfo = chalkColor(theme.status.info); + + // risk + const rkLow = chalkColor(theme.risk.low); + const rkMedium = chalkColor(theme.risk.medium); + const rkHigh = chalkColor(theme.risk.high); + const rkCritical = chalkColor(theme.risk.critical); + + // typography + const h1 = chalkColor(theme.typography.h1); + const h2 = chalkColor(theme.typography.h2); + const h3 = chalkColor(theme.typography.h3); + const h4 = chalkColor(theme.typography.h4); + const h5 = chalkColor(theme.typography.h5); + const h6 = chalkColor(theme.typography.h6); + const strong = chalkColor(theme.typography.strong); + const em = chalkColor(theme.typography.emphasis); + const del = chalkColor(theme.typography.delete); + + // link + const lnk = chalkColor(theme.link.default); + const lnkVisited = chalkColor(theme.link.visited); + const lnkHover = chalkColor(theme.link.hover); + + // inlineCode + const icFg = chalkColor(theme.inlineCode.foreground); + const icBg = chalkBgColor(theme.inlineCode.background); + const icBorder = chalkColor(theme.inlineCode.border); + + // codeBlock + const cbFg = chalkColor(theme.codeBlock.foreground); + const cbBg = chalkBgColor(theme.codeBlock.background); + const cbBorder = chalkColor(theme.codeBlock.border); + const cbTitle = chalkColor(theme.codeBlock.title); + const cbLineNo = chalkColor(theme.codeBlock.lineNumber); + const cbHighlight = chalkColor(theme.codeBlock.highlight); + + // syntax + const synKeyword = chalkColor(theme.syntax.keyword); + const synString = chalkColor(theme.syntax.string); + const synFunction = chalkColor(theme.syntax.function); + const synVariable = chalkColor(theme.syntax.variable); + const synProperty = chalkColor(theme.syntax.property); + const synType = chalkColor(theme.syntax.type); + const synNumber = chalkColor(theme.syntax.number); + const synOperator = chalkColor(theme.syntax.operator); + const synPunctuation = chalkColor(theme.syntax.punctuation); + const synComment = chalkColor(theme.syntax.comment); + const synRegexp = chalkColor(theme.syntax.regexp); + const synConstant = chalkColor(theme.syntax.constant); + + // blockquote + const bqFg = chalkColor(theme.blockquote.foreground); + const bqBorder = chalkColor(theme.blockquote.border); + + // list + const lsBullet = chalkColor(theme.list.bullet); + const lsOrdered = chalkColor(theme.list.ordered); + const lsMarker = chalkColor(theme.list.marker); + + // task + const tkChecked = chalkColor(theme.task.checked); + const tkUnchecked = chalkColor(theme.task.unchecked); + + // table + const tblBorder = chalkColor(theme.table.border); + const tblHeaderFg = chalkColor(theme.table.headerForeground); + const tblHeaderBg = chalkBgColor(theme.table.headerBackground); + const tblCellFg = chalkColor(theme.table.cellForeground); + + // hr + const hrFg = chalkColor(theme.hr.foreground); + + // admonition + const admNote = chalkColor(theme.admonition.note); + const admTip = chalkColor(theme.admonition.tip); + const admWarning = chalkColor(theme.admonition.warning); + const admImportant = chalkColor(theme.admonition.important); + const admCaution = chalkColor(theme.admonition.caution); + + // diff + const dfAdded = chalkColor(theme.diff.added); + const dfRemoved = chalkColor(theme.diff.removed); + const dfModified = chalkColor(theme.diff.modified); + const dfAddedBg = chalkBgColor(theme.diff.addedBackground); + const dfRemovedBg = chalkBgColor(theme.diff.removedBackground); + const dfModifiedBg = chalkBgColor(theme.diff.modifiedBackground); + + // agent + const agThinking = chalkColor(theme.agent.thinking); + const agReasoning = chalkColor(theme.agent.reasoning); + const agToolCall = chalkColor(theme.agent.toolCall); + const agToolResult = chalkColor(theme.agent.toolResult); + const agStreaming = chalkColor(theme.agent.streaming); + const agCompleted = chalkColor(theme.agent.completed); + + // approval + const apAllow = chalkColor(theme.approval.allow); + const apDeny = chalkColor(theme.approval.deny); + const apReview = chalkColor(theme.approval.review); + + return { + // text + text: (t) => txPrimary(t), + textSecondary: (t) => txSecondary(t), + textMuted: (t) => txMuted(t), + textDisabled: (t) => txDisabled(t), + textInverse: (t) => txInverse(t), + + // border + borderDefault: (t) => brDefault(t), + borderSubtle: (t) => brSubtle(t), + borderActive: (t) => brActive(t), + borderFocus: (t) => brFocus(t), + + // surface (background) + surfaceDefault: (t) => sfDefault(t), + surfaceElevated: (t) => sfElevated(t), + surfaceMuted: (t) => sfMuted(t), + surfaceCode: (t) => sfCode(t), + surfacePanel: (t) => sfPanel(t), + surfaceQuote: (t) => sfQuote(t), + surfaceSelection: (t) => sfSelection(t), + + // brand + brandPrimary: (t) => brBrandPrimary(t), + brandSecondary: (t) => brBrandSecondary(t), + brandAccent: (t) => brBrandAccent(t), + + // status + success: (t) => stSuccess(t), + warning: (t) => stWarning(t), + danger: (t) => stDanger(t), + info: (t) => stInfo(t), + + // risk + riskLow: (t) => rkLow(t), + riskMedium: (t) => rkMedium(t), + riskHigh: (t) => rkHigh(t), + riskCritical: (t) => rkCritical(t), + + // typography + heading1: (t) => chalk.bold(h1(t)), + heading2: (t) => chalk.bold(h2(t)), + heading3: (t) => chalk.bold(h3(t)), + heading4: (t) => chalk.bold(h4(t)), + heading5: (t) => chalk.bold(h5(t)), + heading6: (t) => chalk.bold(h6(t)), + paragraph: (t) => txPrimary(t), + strong: (t) => chalk.bold(strong(t)), + emphasis: (t) => chalk.italic(em(t)), + delete: (t) => del(t), + + // link + link: (t) => lnk(t), + linkVisited: (t) => lnkVisited(t), + linkHover: (t) => lnkHover(t), + + // inlineCode + inlineCode: (t) => icFg(t), + inlineCodeBg: (t) => icBg(t), + inlineCodeBorder: (t) => icBorder(t), + + // codeBlock + code: (t) => cbFg(t), + codeBg: (t) => cbBg(t), + codeBorder: (t) => cbBorder(t), + codeTitle: (t) => cbTitle(t), + lineNumber: (t) => cbLineNo(t), + codeHighlight: (t) => cbHighlight(t), + + // syntax + syntaxKeyword: (t) => synKeyword(t), + syntaxString: (t) => synString(t), + syntaxFunction: (t) => synFunction(t), + syntaxVariable: (t) => synVariable(t), + syntaxProperty: (t) => synProperty(t), + syntaxType: (t) => synType(t), + syntaxNumber: (t) => synNumber(t), + syntaxOperator: (t) => synOperator(t), + syntaxPunctuation: (t) => synPunctuation(t), + syntaxComment: (t) => synComment(t), + syntaxRegexp: (t) => synRegexp(t), + syntaxConstant: (t) => synConstant(t), + + // blockquote + quote: (t) => chalk.italic(bqFg(t)), + quoteBorder: (t) => bqBorder(t), + + // list + listBullet: (t) => lsBullet(t), + listOrdered: (t) => lsOrdered(t), + listMarker: (t) => lsMarker(t), + + // task + taskChecked: (t) => tkChecked(t), + taskUnchecked: (t) => tkUnchecked(t), + + // table + tableBorder: (t) => tblBorder(t), + tableHeaderFg: (t) => tblHeaderFg(t), + tableHeaderBg: (t) => tblHeaderBg(t), + tableCellFg: (t) => tblCellFg(t), + + // hr + hr: (t) => hrFg(t), + + // admonition + admonitionNote: (t) => admNote(t), + admonitionTip: (t) => admTip(t), + admonitionWarning: (t) => admWarning(t), + admonitionImportant: (t) => admImportant(t), + admonitionCaution: (t) => admCaution(t), + + // diff + diffAdded: (t) => dfAdded(t), + diffRemoved: (t) => dfRemoved(t), + diffModified: (t) => dfModified(t), + diffAddedBg: (t) => dfAddedBg(t), + diffRemovedBg: (t) => dfRemovedBg(t), + diffModifiedBg: (t) => dfModifiedBg(t), + + // agent + agentThinking: (t) => agThinking(t), + agentReasoning: (t) => agReasoning(t), + agentToolCall: (t) => agToolCall(t), + agentToolResult: (t) => agToolResult(t), + agentStreaming: (t) => agStreaming(t), + agentCompleted: (t) => agCompleted(t), + + // approval + approvalAllow: (t) => apAllow(t), + approvalDeny: (t) => apDeny(t), + approvalReview: (t) => apReview(t), + + // chalk 修饰符 + bold: (t) => chalk.bold(t), + italic: (t) => chalk.italic(t), + dim: (t) => chalk.dim(t), + }; +} diff --git a/src/ui/theme/colors-theme.ts b/src/ui/theme/colors-theme.ts new file mode 100644 index 0000000..b655f4b --- /dev/null +++ b/src/ui/theme/colors-theme.ts @@ -0,0 +1,230 @@ +import type { ThemeTokens } from "./types"; +import type { DetectedTheme } from "./detect-system-theme"; + +/** + * 用户配置的简化主题色板。 + * 只需定义基础色,系统自动推导出完整的 ThemeTokens。 + */ +export interface ColorsTheme { + /** 主背景色 */ + Background: string; + /** 主前景/文字色 */ + Foreground: string; + /** 次要文字色(dimmed) */ + Gray: string; + /** 浅蓝:信息提示、链接 */ + LightBlue: string; + /** 强调蓝:品牌色、交互态、选中项 */ + AccentBlue: string; + /** 紫色:特殊强调、已访问链接 */ + AccentPurple: string; + /** 青色:代码高亮 */ + AccentCyan: string; + /** 绿色:成功、diff 新增 */ + AccentGreen: string; + /** 黄色:警告、进行中 */ + AccentYellow: string; + /** 红色:错误、危险 */ + AccentRed: string; + /** 黄色淡化:中风险、列表标记 */ + AccentYellowDim: string; + /** 红色淡化:高风险 */ + AccentRedDim: string; + /** Diff 新增行背景 */ + DiffAdded: string; + /** Diff 删除行背景 */ + DiffRemoved: string; + /** 注释色 */ + Comment: string; + /** 渐变色数组(可选) */ + GradientColors?: string[]; +} + +/** + * 将 hex 颜色淡化(混合灰色)。 + * 对非 hex 命名色(如 ANSI 的 "white"、"black")返回原色,不做淡化。 + */ +function dimHex(hex: string, ratio: number): string { + if (!hex.startsWith("#")) { + return hex; + } + const h = hex.slice(1); + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + const gr = 128; + const dr = Math.round(r + (gr - r) * ratio); + const dg = Math.round(g + (gr - g) * ratio); + const db = Math.round(b + (gr - b) * ratio); + return `#${dr.toString(16).padStart(2, "0")}${dg.toString(16).padStart(2, "0")}${db.toString(16).padStart(2, "0")}`; +} + +/** + * 从 ColorsTheme 推导完整的 ThemeTokens。 + * + * @param c 自定义主题色板 + * @param mode 主题模式 + * @param name 主题显示名称 + * @param terminalBackground - 终端实际背景色(可选)。当与 mode 不匹配时, + * 自动反转文字色以确保对比度。例如 light 主题 + 深色终端 → 文字变亮。 + */ +export function buildThemeTokens( + c: ColorsTheme, + mode: DetectedTheme, + name: string, + terminalBackground?: DetectedTheme +): ThemeTokens { + const gradient = c.GradientColors ?? [c.AccentBlue, c.AccentPurple]; + + // 判断是否需要反转文字色 + const bgMismatch = terminalBackground && terminalBackground !== mode; + const needInvert = bgMismatch ?? false; + + // 文字色:当终端背景与主题模式不匹配时反转,确保对比度 + // fg = 当前终端上实际使用的前景色 + // inverse = fg 的反色(用于 badge、高亮等需要反差的场景) + // muted/disabled = 中性色,深浅背景上都可读 + const fg = needInvert ? c.Background : c.Foreground; + const inv = needInvert ? c.Foreground : c.Background; + + return { + name, + mode, + text: { + primary: fg, + secondary: dimHex(fg, 0.4), + muted: c.Gray, + disabled: dimHex(c.Gray, 0.5), + inverse: inv, + }, + border: { + default: dimHex(fg, 0.7), + subtle: dimHex(fg, 0.85), + active: c.AccentBlue, + focus: c.AccentBlue, + }, + surface: { + default: c.Background, + elevated: mode === "dark" ? dimHex(c.Background, 0.15) : "#ffffff", + muted: dimHex(c.Background, 0.08), + code: dimHex(c.Background, 0.08), + panel: dimHex(c.Background, 0.08), + quote: dimHex(c.Background, 0.08), + selection: mode === "dark" ? "#264f78" : "#ddf4ff", + }, + brand: { + primary: c.AccentBlue, + secondary: `${c.AccentBlue}cc`, + accent: c.AccentBlue, + }, + status: { + success: c.AccentGreen, + warning: c.AccentYellow, + danger: c.AccentRed, + info: c.LightBlue, + }, + risk: { + low: c.AccentGreen, + medium: c.AccentYellowDim, + high: c.AccentRed, + critical: c.AccentRed, + }, + typography: { + h1: c.AccentBlue, + h2: c.AccentBlue, + h3: c.AccentBlue, + h4: c.AccentBlue, + h5: c.AccentBlue, + h6: c.AccentBlue, + paragraph: fg, + strong: fg, + emphasis: fg, + delete: c.AccentRed, + }, + link: { + default: c.LightBlue, + visited: c.AccentPurple, + hover: c.LightBlue, + }, + inlineCode: { + foreground: `${c.AccentPurple}cc`, + background: dimHex(c.Background, 0.08), + border: dimHex(fg, 0.7), + }, + codeBlock: { + foreground: fg, + background: mode === "dark" ? dimHex(c.Background, 0.15) : dimHex(c.Background, 0.05), + border: dimHex(fg, 0.7), + title: fg, + lineNumber: c.Gray, + highlight: mode === "dark" ? "#2d333b" : "#fff8c5", + }, + syntax: { + keyword: c.AccentRed, + string: mode === "dark" ? "#a5d6ff" : "#0a3069", + function: c.AccentPurple, + variable: mode === "dark" ? "#ffa657" : "#953800", + property: c.AccentCyan, + type: mode === "dark" ? "#ffa657" : "#953800", + number: c.AccentCyan, + operator: c.AccentRed, + punctuation: fg, + comment: c.Comment, + regexp: c.AccentGreen, + constant: c.AccentCyan, + }, + blockquote: { + foreground: c.Gray, + border: dimHex(fg, 0.7), + }, + list: { + bullet: c.AccentYellowDim, + ordered: c.AccentYellowDim, + marker: c.AccentYellowDim, + }, + task: { + checked: c.AccentGreen, + unchecked: dimHex(fg, 0.7), + }, + table: { + border: dimHex(fg, 0.7), + headerForeground: fg, + headerBackground: dimHex(c.Background, 0.08), + cellForeground: fg, + }, + hr: { foreground: dimHex(fg, 0.7) }, + admonition: { + note: c.LightBlue, + tip: c.AccentGreen, + warning: c.AccentYellow, + important: c.AccentPurple, + caution: c.AccentRed, + }, + diff: { + added: c.AccentGreen, + removed: c.AccentRed, + modified: c.AccentYellow, + addedBackground: c.DiffAdded, + removedBackground: c.DiffRemoved, + modifiedBackground: mode === "dark" ? "#2d2700" : "#fff8c5", + }, + agent: { + thinking: c.Comment, + reasoning: c.Comment, + toolCall: c.AccentBlue, + toolResult: c.Gray, + streaming: c.AccentYellow, + completed: c.AccentGreen, + }, + approval: { + allow: c.AccentGreen, + deny: c.AccentRed, + review: c.AccentYellow, + }, + gradients: { + banner: gradient, + logo: gradient, + thinking: [c.Comment, c.Gray], + }, + }; +} diff --git a/src/ui/theme/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/detect-system-theme.ts b/src/ui/theme/detect-system-theme.ts new file mode 100644 index 0000000..0c958e9 --- /dev/null +++ b/src/ui/theme/detect-system-theme.ts @@ -0,0 +1,204 @@ +import { execSync } from "child_process"; + +export type DetectedTheme = "dark" | "light"; + +// --------------------------------------------------------------------------- +// OSC 11 – query terminal background color +// --------------------------------------------------------------------------- + +const OSC11_TIMEOUT_MS = 200; + +interface Rgb { + r: number; + g: number; + b: number; +} + +/** + * Normalises a variable-length hex colour component (1–4 hex digits) to + * the [0, 1] range. + */ +function hexComponent(hex: string): number { + const max = 16 ** hex.length - 1; + return parseInt(hex, 16) / max; +} + +/** + * Parses an XParseColor RGB string returned by OSC 11. + * + * Accepted formats: + * - `rgb:RRRR/GGGG/BBBB` (1–4 hex digits per component) + * - `#RRGGBB` or `#RRRRGGGGBBBB` (equal-length triplets) + */ +export function parseOscRgb(data: string): Rgb | undefined { + const rgbMatch = /^rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i.exec(data); + if (rgbMatch) { + return { + r: hexComponent(rgbMatch[1]!), + g: hexComponent(rgbMatch[2]!), + b: hexComponent(rgbMatch[3]!), + }; + } + + const hashMatch = /^#([0-9a-f]+)$/i.exec(data); + if (hashMatch && hashMatch[1]!.length % 3 === 0) { + const hex = hashMatch[1]!; + const n = hex.length / 3; + return { + r: hexComponent(hex.slice(0, n)), + g: hexComponent(hex.slice(n, 2 * n)), + b: hexComponent(hex.slice(2 * n)), + }; + } + + return undefined; +} + +/** + * Converts an OSC 11 colour response into a dark/light theme decision + * using ITU-R BT.709 relative luminance. + */ +export function themeFromOscColor(data: string): DetectedTheme | undefined { + const rgb = parseOscRgb(data); + if (!rgb) return undefined; + const luminance = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b; + return luminance > 0.5 ? "light" : "dark"; +} + +/** + * Sends an OSC 11 query (`ESC ] 11 ; ? BEL`) to the terminal and waits + * for the response containing the background colour. + * + * Returns `undefined` when stdin/stdout is not a TTY or when no response + * arrives within OSC11_TIMEOUT_MS. + */ +export function detectOsc11Theme(): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return Promise.resolve(undefined); + } + + return new Promise((resolve) => { + const stdin = process.stdin; + let resolved = false; + let buffer = ""; + + const finish = (result: DetectedTheme | undefined) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + stdin.removeListener("data", onData); + resolve(result); + }; + + const timer = setTimeout(() => finish(undefined), OSC11_TIMEOUT_MS); + + const onData = (data: Buffer) => { + buffer += data.toString(); + // OSC response: ESC ] 11 ; BEL or ESC ] 11 ; ST + const match = /\x1b\]11;(.*?)(?:\x07|\x1b\\)/.exec(buffer); + if (match) { + finish(themeFromOscColor(match[1]!)); + } + }; + + stdin.on("data", onData); + process.stdout.write("\x1b]11;?\x07"); + }); +} + +// --------------------------------------------------------------------------- +// Synchronous detection helpers +// --------------------------------------------------------------------------- + +/** + * Detects the macOS system appearance using `defaults read -g AppleInterfaceStyle`. + * Returns 'dark' if Dark Mode is active, 'light' when the key is missing + * (the canonical macOS Light Mode signal), and undefined for any other failure + * so the caller can continue its fallback chain. + * Returns undefined on non-macOS platforms. + */ +export function detectMacOSTheme(): DetectedTheme | undefined { + if (process.platform !== "darwin") { + return undefined; + } + + try { + const result = execSync("defaults read -g AppleInterfaceStyle", { + encoding: "utf-8", + timeout: 3000, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + return result.toLowerCase() === "dark" ? "dark" : "light"; + } catch (error) { + const err = error as { stderr?: string | Buffer; message?: string }; + const stderr = typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString?.() ?? ""); + const message = err.message ?? ""; + // Only the explicit "… does not exist" error confirms Light Mode. + if (/does not exist/i.test(stderr) || /does not exist/i.test(message)) { + return "light"; + } + return undefined; + } +} + +/** + * Detects theme from the COLORFGBG environment variable. + * + * COLORFGBG format: "foreground;background" where values are ANSI color indices (0-15). + * Index 7 (light gray) and 9-15 → light. 0-6, 8 → dark. + */ +export function detectFromColorFgBg(): DetectedTheme | undefined { + const colorFgBg = process.env["COLORFGBG"]; + if (!colorFgBg) { + return undefined; + } + + const parts = colorFgBg.split(";"); + const bgStr = parts[parts.length - 1]; + if (bgStr === undefined) { + return undefined; + } + + const bg = parseInt(bgStr, 10); + if (isNaN(bg)) { + return undefined; + } + + if (bg === 7 || (bg >= 9 && bg <= 15)) { + return "light"; + } + + return "dark"; +} + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +/** + * Synchronous theme detection (for theme dialog live-preview). + * + * Order: COLORFGBG → macOS system appearance → default dark. + */ +export function detectSystemTheme(): DetectedTheme { + return detectFromColorFgBg() ?? detectMacOSTheme() ?? "dark"; +} + +/** + * Asynchronous theme detection (for startup). + * + * Checks cheap synchronous sources first (COLORFGBG) so we never pay the + * ~200 ms OSC 11 timeout when a fast answer is already available. + * + * Order: COLORFGBG → OSC 11 → macOS system appearance → default dark. + */ +export async function detectTerminalThemeAsync(): Promise { + const colorFgBgResult = detectFromColorFgBg(); + if (colorFgBgResult) return colorFgBgResult; + + const osc11Result = await detectOsc11Theme(); + if (osc11Result) return osc11Result; + + return detectMacOSTheme() ?? "dark"; +} diff --git a/src/ui/theme/index.ts b/src/ui/theme/index.ts new file mode 100644 index 0000000..c7f5fc9 --- /dev/null +++ b/src/ui/theme/index.ts @@ -0,0 +1,20 @@ +export type { ThemeTokens, ThemePreset, ThemeSettings } from "./types"; +export type { ColorsTheme } from "./colors-theme"; +export { buildThemeTokens } from "./colors-theme"; +export { + LIGHT_THEME, + DARK_THEME, + MONOKAI_THEME, + DRACULA_THEME, + GITHUB_LIGHT_THEME, + GITHUB_DARK_THEME, + ANSI_LIGHT_THEME, + ANSI_DARK_THEME, + PRESETS, +} from "./presets"; +export { resolveTheme } from "./resolver"; +export { ThemeManager } from "./ThemeManager"; +export { ThemeProvider, useTheme } from "./ThemeContext"; +export { createThemedChalk } from "./chalk-theme"; +export type { ThemedChalk } from "./chalk-theme"; +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..67ecec0 --- /dev/null +++ b/src/ui/theme/presets.ts @@ -0,0 +1,198 @@ +import type { ThemeTokens } from "./types"; +import type { ColorsTheme } from "./colors-theme"; +import { buildThemeTokens } from "./colors-theme"; + +// ——— 预设色板 ——— + +const LIGHT_COLORS: ColorsTheme = { + Background: "#ffffff", + Foreground: "#1F2328", + Gray: "#8b949e", + LightBlue: "#0969da", + AccentBlue: "#229ac3", + AccentPurple: "#8250df", + AccentCyan: "#0550ae", + AccentGreen: "#1a7f37", + AccentYellow: "#fa8c16", + AccentRed: "#d1242f", + AccentYellowDim: "#9a6700", + AccentRedDim: "#a40e26", + DiffAdded: "#dafbe1", + DiffRemoved: "#ffebe9", + Comment: "#6e7781", + GradientColors: ["#229ac3", "#8250df"], +}; + +const DARK_COLORS: ColorsTheme = { + Background: "#0d1117", + Foreground: "#e6edf3", + Gray: "#6e7681", + LightBlue: "#58a6ff", + AccentBlue: "#229ac3", + AccentPurple: "#bc8cff", + AccentCyan: "#79c0ff", + AccentGreen: "#3fb950", + AccentYellow: "#d29922", + AccentRed: "#f85149", + AccentYellowDim: "#d29922", + AccentRedDim: "#f85149", + DiffAdded: "#12261e", + DiffRemoved: "#2d1518", + Comment: "#8b949e", + GradientColors: ["#229ac3", "#8250df"], +}; + +const GITHUB_LIGHT_COLORS: ColorsTheme = { + Background: "#f8f8f8", + Foreground: "#24292E", + LightBlue: "#0086b3", + AccentBlue: "#458", + AccentPurple: "#900", + AccentCyan: "#009926", + AccentGreen: "#008080", + AccentYellow: "#990073", + AccentRed: "#d14", + AccentYellowDim: "#8B7000", + AccentRedDim: "#993333", + DiffAdded: "#C6EAD8", + DiffRemoved: "#FFCCCC", + Comment: "#998", + Gray: "#999", + GradientColors: ["#458", "#008080"], +}; + +const GITHUB_DARK_COLORS: ColorsTheme = { + Background: "#24292e", + Foreground: "#c0c4c8", + LightBlue: "#79B8FF", + AccentBlue: "#79B8FF", + AccentPurple: "#B392F0", + AccentCyan: "#9ECBFF", + AccentGreen: "#85E89D", + AccentYellow: "#FFAB70", + AccentRed: "#F97583", + AccentYellowDim: "#8B7530", + AccentRedDim: "#8B3A4A", + DiffAdded: "#3C4636", + DiffRemoved: "#502125", + Comment: "#6A737D", + Gray: "#6A737D", + GradientColors: ["#79B8FF", "#85E89D"], +}; + +const DRACULA_THEME_COLORS: ColorsTheme = { + Background: "#282a36", + Foreground: "#a3afb7", + LightBlue: "#8be9fd", + AccentBlue: "#8be9fd", + AccentPurple: "#ff79c6", + AccentCyan: "#8be9fd", + AccentGreen: "#50fa7b", + AccentYellow: "#fff783", + AccentRed: "#ff5555", + AccentYellowDim: "#8B7530", + AccentRedDim: "#8B3A4A", + DiffAdded: "#11431d", + DiffRemoved: "#6e1818", + Comment: "#6272a4", + Gray: "#6272a4", + GradientColors: ["#ff79c6", "#8be9fd"], +}; + +/** ANSI Light 终端色主题(浅色背景) */ +const ANSI_LIGHT_COLORS: ColorsTheme = { + Background: "white", + Foreground: "#444", + LightBlue: "blue", + AccentBlue: "blue", + AccentPurple: "purple", + AccentCyan: "cyan", + AccentGreen: "green", + AccentYellow: "orange", + AccentRed: "red", + AccentYellowDim: "orange", + AccentRedDim: "red", + DiffAdded: "#E5F2E5", + DiffRemoved: "#FFE5E5", + Comment: "gray", + Gray: "gray", + GradientColors: ["blue", "green"], +}; + +/** ANSI Dark 终端色主题(深色背景) */ +const ANSI_DARK_COLORS: ColorsTheme = { + Background: "black", + Foreground: "white", + LightBlue: "bluebright", + AccentBlue: "blue", + AccentPurple: "magenta", + AccentCyan: "cyan", + AccentGreen: "green", + AccentYellow: "yellow", + AccentRed: "red", + AccentYellowDim: "yellow", + AccentRedDim: "red", + DiffAdded: "#003300", + DiffRemoved: "#4D0000", + Comment: "gray", + Gray: "gray", + GradientColors: ["cyan", "green"], +}; + +/** Monokai 色板(text.primary 使用品牌色而非前景色,需 overrides) */ +const MONOKAI_COLORS: ColorsTheme = { + Background: "#272822", + Foreground: "#f8f8f2", + Gray: "#75715e", + LightBlue: "#66d9ef", + AccentBlue: "#f92672", + AccentPurple: "#ae81ff", + AccentCyan: "#66d9ef", + AccentGreen: "#a6e22e", + AccentYellow: "#fd971f", + AccentRed: "#f92672", + AccentYellowDim: "#fd971f", + AccentRedDim: "#f92672", + DiffAdded: "#2d3a1f", + DiffRemoved: "#3d1a25", + Comment: "#75715e", + GradientColors: ["#f92672", "#ae81ff"], +}; + +// ——— 通过 ColorsTheme 自动推导的预设 ——— + +/** 浅色主题(默认) */ +export const LIGHT_THEME: ThemeTokens = buildThemeTokens(LIGHT_COLORS, "light", "Light"); + +/** 暗色主题 */ +export const DARK_THEME: ThemeTokens = buildThemeTokens(DARK_COLORS, "dark", "Dark"); + +/** GitHub Light 主题 */ +export const GITHUB_LIGHT_THEME: ThemeTokens = buildThemeTokens(GITHUB_LIGHT_COLORS, "light", "GitHub Light"); + +/** GitHub Dark 主题 */ +export const GITHUB_DARK_THEME: ThemeTokens = buildThemeTokens(GITHUB_DARK_COLORS, "dark", "GitHub Dark"); + +/** Dracula 主题 */ +export const DRACULA_THEME: ThemeTokens = buildThemeTokens(DRACULA_THEME_COLORS, "dark", "Dracula"); + +/** ANSI Light 终端色主题 */ +export const ANSI_LIGHT_THEME: ThemeTokens = buildThemeTokens(ANSI_LIGHT_COLORS, "light", "ANSI Light"); + +/** ANSI Dark 终端色主题 */ +export const ANSI_DARK_THEME: ThemeTokens = buildThemeTokens(ANSI_DARK_COLORS, "dark", "ANSI Dark"); + +/** Monokai 主题(text.primary 使用品牌色,typography 使用前景色) */ +export const MONOKAI_THEME: ThemeTokens = buildThemeTokens(MONOKAI_COLORS, "dark", "Monokai"); + +/** 预设主题映射表 */ +export const PRESETS: Record = { + light: LIGHT_THEME, + dark: DARK_THEME, + monokai: MONOKAI_THEME, + dracula: DRACULA_THEME, + "github-light": GITHUB_LIGHT_THEME, + "github-dark": GITHUB_DARK_THEME, + "ansi-light": ANSI_LIGHT_THEME, + "ansi-dark": ANSI_DARK_THEME, +}; diff --git a/src/ui/theme/resolver.ts b/src/ui/theme/resolver.ts new file mode 100644 index 0000000..dadd175 --- /dev/null +++ b/src/ui/theme/resolver.ts @@ -0,0 +1,126 @@ +import { type ThemeTokens, type ThemeSettings } from "./types"; +import { buildThemeTokens } from "./colors-theme"; +import { LIGHT_THEME, PRESETS } from "./presets"; + +/** + * 深度合并两个对象。right 的值覆盖 left。 + * 支持任意深度嵌套。 + */ +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 + * - 当 terminalBg 与主题 mode 不匹配时,自动反转文字色 + */ +export function resolveTheme(themeSettings: ThemeSettings | undefined, terminalBg?: "light" | "dark"): ThemeTokens { + if (!themeSettings) { + return applyTerminalContrast(LIGHT_THEME, terminalBg); + } + + const { preset } = themeSettings; + + // preset 为预设名称时使用对应预设 + if (preset && preset !== "custom" && preset in PRESETS) { + return applyTerminalContrast(PRESETS[preset], terminalBg); + } + + // preset="custom":基于 base 预设应用用户自定义 + if (preset === "custom") { + const baseName = themeSettings.base; + const baseTheme = baseName && baseName !== "custom" && baseName in PRESETS ? PRESETS[baseName] : LIGHT_THEME; + + // 优先级:tokens > colors + overrides > overrides > colors + if (themeSettings.tokens) { + return deepMerge(applyTerminalContrast(baseTheme, terminalBg), themeSettings.tokens); + } + if (themeSettings.colors && themeSettings.overrides) { + return deepMerge( + buildThemeTokens(themeSettings.colors, baseTheme.mode, "Custom", terminalBg), + themeSettings.overrides + ); + } + if (themeSettings.colors) { + return buildThemeTokens(themeSettings.colors, baseTheme.mode, "Custom", terminalBg); + } + if (themeSettings.overrides) { + return deepMerge(applyTerminalContrast(baseTheme, terminalBg), themeSettings.overrides); + } + } + + // 未配置或无效 preset,回退默认 + return applyTerminalContrast(LIGHT_THEME, terminalBg); +} + +/** + * 当终端背景与主题模式不匹配时,反转文字相关 token 以确保对比度。 + * 只反转 primary ↔ inverse,muted/disabled(中性色)保持不变。 + */ +export function applyTerminalContrast(base: ThemeTokens, terminalBg?: "light" | "dark"): ThemeTokens { + if (!terminalBg || terminalBg === base.mode) return base; + + return { + ...base, + text: { + primary: base.text.inverse, + secondary: base.text.inverse, + muted: base.text.muted, + disabled: base.text.disabled, + inverse: base.text.primary, + }, + typography: { + ...base.typography, + paragraph: base.text.inverse, + strong: base.text.inverse, + emphasis: base.text.inverse, + }, + inlineCode: { + ...base.inlineCode, + border: base.text.inverse, + }, + codeBlock: { + ...base.codeBlock, + foreground: base.text.inverse, + border: base.text.inverse, + title: base.text.inverse, + lineNumber: base.text.disabled, + }, + syntax: { + ...base.syntax, + punctuation: base.text.inverse, + }, + blockquote: { + ...base.blockquote, + border: base.text.inverse, + }, + task: { + ...base.task, + unchecked: base.text.inverse, + }, + table: { + ...base.table, + border: base.text.inverse, + headerForeground: base.text.inverse, + cellForeground: base.text.inverse, + }, + hr: { foreground: base.text.inverse }, + }; +} diff --git a/src/ui/theme/types.ts b/src/ui/theme/types.ts new file mode 100644 index 0000000..5f2c746 --- /dev/null +++ b/src/ui/theme/types.ts @@ -0,0 +1,288 @@ +import type { ColorsTheme } from "./colors-theme"; + +/** 主题颜色 Token 定义 */ +export interface ThemeTokens { + /** 主题显示名称(在 /theme 选择器中展示) */ + name: string; + /** 主题模式 */ + mode: "light" | "dark"; + + // ——— 文字色 ——— + text: { + /** 主文字 */ + primary: string; + /** 次要文字:标签、描述 */ + secondary: string; + /** 暗化文字:提示、占位符、引用块 */ + muted: string; + /** 禁用文字 */ + disabled: string; + /** 反色文字:深色背景上的亮色文字(如代码块标签) */ + inverse: string; + }; + + // ——— 边框色 ——— + border: { + /** 默认边框 */ + default: string; + /** 淡化边框:内部分割线 */ + subtle: string; + /** 激活边框:选中项、下拉菜单 */ + active: string; + /** 聚焦边框:输入框聚焦态 */ + focus: string; + }; + + // ——— 表面色 ——— + surface: { + /** 默认背景 */ + default: string; + /** 提升背景:弹出层、卡片 */ + elevated: string; + /** 暗化背景:代码块、面板 */ + muted: string; + /** 代码背景 */ + code: string; + /** 面板背景 */ + panel: string; + /** 引用块背景 */ + quote: string; + /** 选中行背景 */ + selection: string; + }; + + // ——— 品牌色 ——— + brand: { + /** 品牌主色:Logo、渐变起始色 */ + primary: string; + /** 品牌辅色:渐变终止色 */ + secondary: string; + /** 强调色:选中项、光标、交互态 */ + accent: string; + }; + + // ——— 状态色 ——— + status: { + /** 成功:工具执行成功、MCP ready */ + success: string; + /** 警告:MCP 启动/重连、进行中 */ + warning: string; + /** 危险:错误、工具失败 */ + danger: string; + /** 信息:技能加载、图片附件 */ + info: string; + }; + + // ——— 风险色 ——— + risk: { + /** 低风险:read-in-cwd、query-git-log */ + low: string; + /** 中风险:read-out-cwd、write-in-cwd、network、mcp */ + medium: string; + /** 高风险:write-out-cwd、delete-in-cwd */ + high: string; + /** 极高风险:delete-out-cwd、mutate-git-log */ + critical: string; + }; + + // ——— 排版色 ——— + typography: { + h1: string; + h2: string; + h3: string; + h4: string; + h5: string; + h6: string; + /** 段落文字 */ + paragraph: string; + /** 粗体 */ + strong: string; + /** 斜体 */ + emphasis: string; + /** 删除线 */ + delete: string; + }; + + // ——— 链接色 ——— + link: { + /** 默认链接 */ + default: string; + /** 已访问链接 */ + visited: string; + /** 悬停链接 */ + hover: string; + }; + + // ——— 行内代码 ——— + inlineCode: { + /** 前景色 */ + foreground: string; + /** 背景色 */ + background: string; + /** 边框色 */ + border: string; + }; + + // ——— 代码块 ——— + codeBlock: { + /** 前景色 */ + foreground: string; + /** 背景色 */ + background: string; + /** 边框色 */ + border: string; + /** 标题色 */ + title: string; + /** 行号色 */ + lineNumber: string; + /** 高亮行色 */ + highlight: string; + }; + + // ——— 语法高亮 ——— + syntax: { + keyword: string; + string: string; + function: string; + variable: string; + property: string; + type: string; + number: string; + operator: string; + punctuation: string; + comment: string; + regexp: string; + constant: string; + }; + + // ——— 引用块 ——— + blockquote: { + /** 引用文字色 */ + foreground: string; + /** 引用边框色 */ + border: string; + }; + + // ——— 列表 ——— + list: { + /** 无序列表标记 */ + bullet: string; + /** 有序列表标记 */ + ordered: string; + /** 列表标记(通用) */ + marker: string; + }; + + // ——— 任务列表 ——— + task: { + /** 已完成 */ + checked: string; + /** 未完成 */ + unchecked: string; + }; + + // ——— 表格 ——— + table: { + /** 表格边框 */ + border: string; + /** 表头文字 */ + headerForeground: string; + /** 表头背景 */ + headerBackground: string; + /** 单元格文字 */ + cellForeground: string; + }; + + // ——— 分割线 ——— + hr: { + /** 分割线颜色 */ + foreground: string; + }; + + // ——— 提示框 ——— + admonition: { + note: string; + tip: string; + warning: string; + important: string; + caution: string; + }; + + // ——— Diff ——— + diff: { + /** 新增行文字 */ + added: string; + /** 删除行文字 */ + removed: string; + /** 修改行文字 */ + modified: string; + /** 新增行背景 */ + addedBackground: string; + /** 删除行背景 */ + removedBackground: string; + /** 修改行背景 */ + modifiedBackground: string; + }; + + // ——— Agent 状态色 ——— + agent: { + /** 思考中 */ + thinking: string; + /** 推理中 */ + reasoning: string; + /** 工具调用 */ + toolCall: string; + /** 工具结果 */ + toolResult: string; + /** 流式输出/忙碌 */ + streaming: string; + /** 完成 */ + completed: string; + }; + + // ——— 审批色 ——— + approval: { + /** 允许 */ + allow: string; + /** 拒绝 */ + deny: string; + /** 审查 */ + review: string; + }; + + // ——— 渐变 ——— + gradients: { + /** Banner 渐变 */ + banner: string[]; + /** Logo 渐变 */ + logo: string[]; + /** 思考状态渐变 */ + thinking: string[]; + }; +} + +/** 预设主题名称 */ +export type ThemePreset = + | "light" + | "dark" + | "monokai" + | "dracula" + | "github-light" + | "github-dark" + | "ansi-light" + | "ansi-dark" + | "custom"; + +/** 主题配置(用户可配置部分) */ +export type ThemeSettings = { + /** 选择预设主题,如 "light"、"dark" 等;"custom" 使用用户自定义 */ + preset?: ThemePreset; + /** custom 模式下的基础预设,默认 "light"。基于此预设做 overrides 合并 */ + base?: ThemePreset; + /** 简化色板配置(仅 preset="custom" 时生效)。系统自动推导完整 token */ + colors?: ColorsTheme; + /** 覆盖部分 token(仅 preset="custom" 时生效,可与 colors 配合使用) */ + overrides?: Partial; + /** 完全自定义(仅 preset="custom" 时生效,优先级最高) */ + 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 f07da6f..fe04882 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(); } @@ -356,7 +370,6 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl [ sessionManager, pendingPermissionReply, - exit, onRestart, refreshSkills, refreshSessionsList, @@ -435,7 +448,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] ); @@ -456,8 +470,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); @@ -576,7 +590,6 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl if (mode === RawMode.Raw) { // In raw mode, re-render all messages directly to stdout at the new width. - // Use process.stdout.write instead of writeRef to avoid Ink interference. process.stdout.write(ANSI_CLEAR_SCREEN); const activeSessionId = sessionManager.getActiveSessionId(); const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; @@ -584,21 +597,12 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl return; } - // Force full redraw on terminal resize to avoid stale wrapped rows. - writeRef.current("\u001B[2J\u001B[H"); - - setMessages([]); - setShowWelcome(false); + // Don't clear the screen on resize — Ink handles re-layout naturally. + // Clearing causes scroll-to-top and flash, especially on tab switch in iTerm2. + // Just force ThemeableStatic to remount so Ink recalculates row heights. setWelcomeNonce((n) => n + 1); - - const activeSessionId = sessionManager.getActiveSessionId(); - const nextMessages = - activeSessionId && !busy ? loadVisibleMessages(sessionManager, activeSessionId) : messagesRef.current; - setTimeout(() => { - setMessages(nextMessages); - setShowWelcome(true); - }, 0); - }, [busy, mode, sessionManager, columns, stdout]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [busy, mode, sessionManager, columns]); const screenWidth = useMemo(() => columns ?? stdout?.columns ?? 80, [columns, stdout]); const screenHeight = useMemo(() => rows ?? stdout?.rows ?? 24, [rows, stdout]); @@ -701,7 +705,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl return ( - + {(item) => { if (item.id.startsWith("__welcome__")) { return ( @@ -723,15 +727,15 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl /> ); }} - + {statusLine ? ( - + {statusLine} ) : null} {errorLine ? ( - - Error: {errorLine} + + Error: {errorLine} ) : null} {showProcessStdout ? ( @@ -786,7 +790,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..903b6b2 100644 --- a/src/ui/views/AppContainer.tsx +++ b/src/ui/views/AppContainer.tsx @@ -1,7 +1,9 @@ -import React from "react"; -import { AppContext } from "../contexts"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AppContext, RawModeProvider } from "../contexts"; import App from "./App"; -import { RawModeProvider } from "../contexts"; +import type { ThemePreset, ThemeTokens } from "../theme"; +import { ThemeManager, ThemeProvider } from "../theme"; +import { readProjectSettings, readSettings, resolveCurrentSettings } from "../../settings"; const AppContainer: React.FC<{ projectRoot: string; @@ -9,11 +11,87 @@ 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); + + // ThemeManager 实例(随 projectRoot 变化重建) + const managerRef = useRef(null); + const getManager = useCallback(() => { + if (!managerRef.current) { + managerRef.current = new ThemeManager(projectRoot); + } + return managerRef.current; + }, [projectRoot]); + + // 监听主题变更,同步到 React 状态 + useEffect(() => { + const manager = getManager(); + return manager.onChange((newTheme, preset) => { + setTheme(newTheme); + setCurrentPreset(preset); + setThemeVersion((v) => v + 1); + }); + }, [getManager]); + + // 同步全局 chalk 主题 + useEffect(() => { + const manager = getManager(); + setTheme(manager.getTheme()); + }, [getManager]); + + // 启动:异步检测终端背景 → 刷新主题 → 开始轮询 + useEffect(() => { + const manager = getManager(); + void manager.init().then(() => { + manager.startPolling(); + }); + return () => { + manager.dispose(); + managerRef.current = null; + }; + }, [projectRoot, getManager]); + + // 检查是否有 custom 主题配置 + const hasCustomThemeConfig = useMemo(() => { + const userSettings = readSettings(); + const projectSettings = readProjectSettings(projectRoot); + const themeSettings = userSettings?.theme ?? projectSettings?.theme; + return themeSettings?.preset === "custom" && !!(themeSettings?.overrides || themeSettings?.tokens); + }, [projectRoot]); + + const previewTheme = useCallback( + (presetOrTokens: string | Partial) => { + getManager().previewTheme(presetOrTokens); + }, + [getManager] + ); + + const switchTheme = useCallback( + (presetOrTokens: string | Partial) => { + getManager().switchTheme(presetOrTokens); + }, + [getManager] + ); + + const revertTheme = useCallback(() => { + getManager().revertTheme(); + }, [getManager]); + return ( - - - - + + + + + + ); }; diff --git a/src/ui/views/AskUserQuestionPrompt.tsx b/src/ui/views/AskUserQuestionPrompt.tsx index a2f91ad..a2b21f5 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.brand.accent : theme.text.muted} paddingX={1} width={64} > {otherText ? ( - + {otherText} - {isCursor ? : null} + {isCursor ? : null} ) : ( {isCursor ? "type your answer here" : "type a custom answer"} diff --git a/src/ui/views/ExitSummaryView.tsx b/src/ui/views/ExitSummaryView.tsx new file mode 100644 index 0000000..225b95f --- /dev/null +++ b/src/ui/views/ExitSummaryView.tsx @@ -0,0 +1,95 @@ +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; +}; + +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.logo); + + 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..c41a10d 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,16 @@ export function McpStatusList({ statuses, onCancel, onReconnect }: Props): React if (statuses.length === 0) { return ( - + - + Manage MCP servers 0 servers @@ -100,6 +109,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 +197,18 @@ function ServerListView({ paddingX={1} marginTop={1} > - + {/* Header row */} - + Manage MCP servers ( - {readyCount} ready, - {startingCount} starting, - {reconnectingCount > 0 && {reconnectingCount} reconnecting,} - {failedCount} failed + {readyCount} ready, + {startingCount} starting, + {reconnectingCount > 0 && {reconnectingCount} reconnecting,} + {failedCount} failed ) @@ -209,7 +219,7 @@ function ServerListView({ borderLeft={false} borderRight={false} borderStyle="round" - borderDimColor + borderColor={theme.border.default} flexDirection="column" flexGrow={1} paddingX={1} @@ -255,16 +265,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.status.success : status.status === "failed" - ? "red" + ? theme.status.danger : status.status === "reconnecting" - ? "#ff9900" - : "yellow"; + ? theme.status.warning + : theme.status.warning; // 加载动画:循环显示 (空) → . → .. → ... → (空) → ... const [dots, setDots] = React.useState(0); @@ -290,7 +301,7 @@ function ServerRow({ {/* Server row */} - + {selected ? "> " : " "} {icon} {status.name} @@ -328,6 +339,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 +427,12 @@ function ServerDetailView({ server.status === "ready" ? "✓" : server.status === "failed" ? "✗" : server.status === "reconnecting" ? "↻" : "●"; const statusColor = server.status === "ready" - ? "green" + ? theme.status.success : server.status === "failed" - ? "red" + ? theme.status.danger : server.status === "reconnecting" - ? "#ff9900" - : "yellow"; + ? theme.status.warning + : theme.status.warning; return ( - + {/* Header row */} {statusIcon} - + {server.name} — {server.status === "ready" ? "Details" : "Status"} @@ -461,7 +473,7 @@ function ServerDetailView({ borderLeft={false} borderRight={false} borderStyle="round" - borderDimColor + borderColor={theme.border.default} flexDirection="column" flexGrow={1} paddingX={1} @@ -515,11 +527,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.status.warning : selected ? theme.brand.accent : undefined; return ( - {selected ? "> " : " "} + {selected ? "> " : " "} {icon} {isAction ? `[${item.name}]` : item.name} @@ -529,8 +542,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..5ff825f 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.risk.low; case "read-out-cwd": case "write-in-cwd": case "network": case "mcp": - return "#f59e0b"; + return t.risk.medium; case "write-out-cwd": case "delete-in-cwd": case "delete-out-cwd": case "mutate-git-log": case "unknown": - return "#ef4444"; + return t.risk.high; default: - return "#ef4444"; + return t.risk.critical; } } diff --git a/src/ui/views/ProcessStdoutView.tsx b/src/ui/views/ProcessStdoutView.tsx index bd5e636..d0cbedc 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 ab2974e..063183e 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, @@ -55,9 +57,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; @@ -86,17 +89,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) { @@ -110,7 +117,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({ @@ -125,14 +132,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([]); @@ -142,6 +154,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); @@ -170,18 +183,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]); @@ -200,9 +214,16 @@ export const PromptInput = React.memo(function PromptInput({ ? `${loadingText}${processOrPasteHint}` : `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}` : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processOrPasteHint}`; + const showFooterText = useMemo( - () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu, - [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu] + () => + showMenu || + showSkillsDropdown || + openRawModelDropdown || + showModelDropdown || + showThemeDropdown || + showFileMentionMenu, + [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showThemeDropdown, showFileMentionMenu] ); const cursorPlacement = useMemo( () => getPromptCursorPlacement(buffer, screenWidth, 2, footerText), @@ -349,7 +370,7 @@ export const PromptInput = React.memo(function PromptInput({ setPendingExit(false); } - if (openRawModelDropdown || showSkillsDropdown || showModelDropdown) { + if (openRawModelDropdown || showSkillsDropdown || showModelDropdown || showThemeDropdown) { return; } @@ -647,6 +668,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(); @@ -726,20 +753,22 @@ export const PromptInput = React.memo(function PromptInput({ clearUndoRedoStacks(); } + 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) @@ -752,10 +781,10 @@ export const PromptInput = React.memo(function PromptInput({ borderBottom={true} borderLeft={false} borderRight={false} - borderDimColor + borderColor={isFocused ? theme.brand.accent : theme.border.default} > - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)} + {renderBufferWithCursor(buffer, isFocused, placeholder, pastesRef.current, theme.status.warning)} {inlineHint ? {inlineHint} : null} + setShowThemeDropdown(false)} + onThemeChange={(preset: ThemePreset) => switchTheme?.(preset)} + onThemePreview={onThemePreview} + onThemeRevert={onThemeRevert} + onStatusMessage={setStatusMessage} + /> {!showFooterText && ( - + {footerText} )} @@ -873,11 +913,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) { @@ -891,13 +933,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 = ""; @@ -906,7 +948,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); @@ -919,7 +961,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; @@ -932,16 +979,16 @@ function renderFocusedText(text: string, cursor: number, validIds: Map= end) return ""; @@ -964,12 +1012,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. @@ -985,7 +1033,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..a092457 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.default} 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..e7acdb2 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..353cfcc 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.logo; 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..beeaa1e 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.default} 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.default} 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..9f74740 100644 --- a/src/ui/views/UpdatePrompt.tsx +++ b/src/ui/views/UpdatePrompt.tsx @@ -67,7 +67,7 @@ export function UpdatePrompt({ currentVersion, latestVersion, installCommand, on {options.map((option, index) => { const selected = index === selectedIndex; return ( - + {selected ? "> " : " "} {index + 1}. {option.label} diff --git a/src/ui/views/WelcomeScreen.tsx b/src/ui/views/WelcomeScreen.tsx index 96aef71..a9704bd 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}