diff --git a/CLAUDE.md b/CLAUDE.md index e39b627..f901d48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,4 +40,5 @@ This project is a TypeScript-based router for Claude Code requests. It allows ro - **Providers and Transformers**: The application supports multiple LLM providers. Transformers adapt the request and response formats for different provider APIs. - **Claude Code Integration**: When a user runs `ccr code`, the command is forwarded to the running router service. The service then processes the request, applies routing rules, and sends it to the configured LLM. If the service isn't running, `ccr code` will attempt to start it automatically. - **Dependencies**: The project is built with `esbuild`. It has a key local dependency `@musistudio/llms`, which probably contains the core logic for interacting with different LLM APIs. -- `@musistudio/llms` is implemented based on `fastify` and exposes `fastify`'s hook and middleware interfaces, allowing direct use of `server.addHook`. \ No newline at end of file +- `@musistudio/llms` is implemented based on `fastify` and exposes `fastify`'s hook and middleware interfaces, allowing direct use of `server.addHook`. +- 无论如何你都不能自动提交git diff --git a/README.md b/README.md index 9866e10..b0149e5 100644 --- a/README.md +++ b/README.md @@ -329,7 +329,7 @@ You can also create your own transformers and load them via the `transformers` f { "transformers": [ { - "path": "$HOME/.claude-code-router/plugins/gemini-cli.js", + "path": "/User/xxx/.claude-code-router/plugins/gemini-cli.js", "options": { "project": "xxx" } @@ -361,7 +361,7 @@ In your `config.json`: ```json { - "CUSTOM_ROUTER_PATH": "$HOME/.claude-code-router/custom-router.js" + "CUSTOM_ROUTER_PATH": "/User/xxx/.claude-code-router/custom-router.js" } ``` @@ -370,7 +370,7 @@ The custom router file must be a JavaScript module that exports an `async` funct Here is an example of a `custom-router.js` based on `custom-router.example.js`: ```javascript -// $HOME/.claude-code-router/custom-router.js +// /User/xxx/.claude-code-router/custom-router.js /** * A custom router function to determine which model to use based on the request. diff --git a/README_zh.md b/README_zh.md index 15d1710..2a44e36 100644 --- a/README_zh.md +++ b/README_zh.md @@ -301,7 +301,7 @@ Transformers 允许您修改请求和响应负载,以确保与不同提供商 { "transformers": [ { - "path": "$HOME/.claude-code-router/plugins/gemini-cli.js", + "path": "/User/xxx/.claude-code-router/plugins/gemini-cli.js", "options": { "project": "xxx" } @@ -333,7 +333,7 @@ Transformers 允许您修改请求和响应负载,以确保与不同提供商 ```json { - "CUSTOM_ROUTER_PATH": "$HOME/.claude-code-router/custom-router.js" + "CUSTOM_ROUTER_PATH": "/User/xxx/.claude-code-router/custom-router.js" } ``` @@ -342,7 +342,7 @@ Transformers 允许您修改请求和响应负载,以确保与不同提供商 这是一个基于 `custom-router.example.js` 的 `custom-router.js` 示例: ```javascript -// $HOME/.claude-code-router/custom-router.js +// /User/xxx/.claude-code-router/custom-router.js /** * 一个自定义路由函数,用于根据请求确定使用哪个模型。 diff --git a/config.example.json b/config.example.json deleted file mode 100644 index a6d592a..0000000 --- a/config.example.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "Providers": [ - { - "name": "openrouter", - "api_base_url": "https://openrouter.ai/api/v1/chat/completions", - "api_key": "sk-xxx", - "models": [ - "google/gemini-2.5-pro-preview", - "anthropic/claude-sonnet-4", - "anthropic/claude-3.5-sonnet", - "anthropic/claude-3.7-sonnet:thinking" - ], - "transformer": { - "use": ["openrouter"] - } - }, - { - "name": "deepseek", - "api_base_url": "https://api.deepseek.com/chat/completions", - "api_key": "sk-xxx", - "models": ["deepseek-chat", "deepseek-reasoner"], - "transformer": { - "use": ["deepseek"], - "deepseek-chat": { - "use": ["tooluse"] - } - } - }, - { - "name": "ollama", - "api_base_url": "http://localhost:11434/v1/chat/completions", - "api_key": "ollama", - "models": ["qwen2.5-coder:latest"] - }, - { - "name": "gemini", - "api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/", - "api_key": "sk-xxx", - "models": ["gemini-2.5-flash", "gemini-2.5-pro"], - "transformer": { - "use": ["gemini"] - } - }, - { - "name": "volcengine", - "api_base_url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions", - "api_key": "sk-xxx", - "models": ["deepseek-v3-250324", "deepseek-r1-250528"], - "transformer": { - "use": ["deepseek"] - } - }, - { - "name": "siliconflow", - "api_base_url": "https://api.siliconflow.cn/v1/chat/completions", - "api_key": "sk-xxx", - "models": ["moonshotai/Kimi-K2-Instruct"], - "transformer": { - "use": [ - [ - "maxtoken", - { - "max_tokens": 16384 - } - ] - ] - } - }, - { - "name": "modelscope", - "api_base_url": "https://api-inference.modelscope.cn/v1/chat/completions", - "api_key": "", - "models": ["Qwen/Qwen3-Coder-480B-A35B-Instruct", "Qwen/Qwen3-235B-A22B-Thinking-2507"], - "transformer": { - "use": [ - [ - "maxtoken", - { - "max_tokens": 65536 - } - ], - "enhancetool" - ], - "Qwen/Qwen3-235B-A22B-Thinking-2507": { - "use": ["reasoning"] - } - } - }, - { - "name": "dashscope", - "api_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", - "api_key": "", - "models": ["qwen3-coder-plus"], - "transformer": { - "use": [ - [ - "maxtoken", - { - "max_tokens": 65536 - } - ], - "enhancetool" - ] - } - } - ], - "Router": { - "default": "deepseek,deepseek-chat", - "background": "ollama,qwen2.5-coder:latest", - "think": "deepseek,deepseek-reasoner", - "longContext": "openrouter,google/gemini-2.5-pro-preview", - "longContextThreshold": 60000, - "webSearch": "gemini,gemini-2.5-flash" - }, - "APIKEY": "your-secret-key", - "HOST": "0.0.0.0", - "API_TIMEOUT_MS": 600000, - "NON_INTERACTIVE_MODE": false, - "LOG": true, - "LOG_LEVEL": "info" -} diff --git a/package.json b/package.json index bba22ad..fcd1635 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@musistudio/claude-code-router", - "version": "1.0.37", + "version": "1.0.38", "description": "Use Claude Code without an Anthropics account and route it to another LLM provider", "bin": { "ccr": "./dist/cli.js" @@ -20,7 +20,7 @@ "license": "MIT", "dependencies": { "@fastify/static": "^8.2.0", - "@musistudio/llms": "^1.0.23", + "@musistudio/llms": "^1.0.24", "dotenv": "^16.4.7", "json5": "^2.2.3", "openurl": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ced4767..cf223d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^8.2.0 version: 8.2.0 '@musistudio/llms': - specifier: ^1.0.23 - version: 1.0.23(ws@8.18.3)(zod@3.25.67) + specifier: ^1.0.24 + version: 1.0.24(ws@8.18.3)(zod@3.25.67) dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -260,8 +260,8 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} - '@musistudio/llms@1.0.23': - resolution: {integrity: sha512-+ygbTi6vsNXj9OTD/w/1ai6rYGB/EOHWO+GmpMKCA66HrE8czAQ9UbZz4SjSLqLFGxokBs+ru7ntM4w8TVq6/Q==} + '@musistudio/llms@1.0.24': + resolution: {integrity: sha512-Hz6ZT92/ZM/eR5kTdCBHD6zoEMOvT5u6g/vfCir5Hwvl4QGHk3g30EmX1pZAXJf83kLnB/lSEq/HQimFIXHIhQ==} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1112,7 +1112,7 @@ snapshots: '@lukeed/ms@2.0.2': {} - '@musistudio/llms@1.0.23(ws@8.18.3)(zod@3.25.67)': + '@musistudio/llms@1.0.24(ws@8.18.3)(zod@3.25.67)': dependencies: '@anthropic-ai/sdk': 0.54.0 '@fastify/cors': 11.0.1 diff --git a/src/cli.ts b/src/cli.ts index 2aa30d1..ff02b49 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,7 @@ import { run } from "./index"; import { showStatus } from "./utils/status"; import { executeCodeCommand } from "./utils/codeCommand"; +import { parseStatusLineData, type StatusLineInput } from "./utils/statusline"; import { cleanupPidFile, isServiceRunning, @@ -23,6 +24,7 @@ Commands: stop Stop server restart Restart server status Show server status + statusline Show status line information code Execute claude command ui Open the web UI in browser -v, version Show version information @@ -83,6 +85,28 @@ async function main() { case "status": await showStatus(); break; + case "statusline": + // 从stdin读取JSON输入 + let inputData = ""; + process.stdin.setEncoding("utf-8"); + process.stdin.on("readable", () => { + let chunk; + while ((chunk = process.stdin.read()) !== null) { + inputData += chunk; + } + }); + + process.stdin.on("end", async () => { + try { + const input: StatusLineInput = JSON.parse(inputData); + const statusLine = await parseStatusLineData(input); + console.log(statusLine); + } catch (error) { + console.error("Error parsing status line data:", error); + process.exit(1); + } + }); + break; case "code": if (!isServiceRunning()) { console.log("Service not running, starting service..."); diff --git a/src/utils/statusline.ts b/src/utils/statusline.ts new file mode 100644 index 0000000..b61da1e --- /dev/null +++ b/src/utils/statusline.ts @@ -0,0 +1,747 @@ +import fs from "node:fs/promises"; +import { execSync } from "child_process"; +import path from "node:path"; +import { CONFIG_FILE, HOME_DIR } from "../constants"; +import JSON5 from "json5"; + +export interface StatusLineModuleConfig { + type: string; + icon?: string; + text: string; + color?: string; + background?: string; +} + +export interface StatusLineThemeConfig { + modules: StatusLineModuleConfig[]; +} + +export interface StatusLineInput { + hook_event_name: string; + session_id: string; + transcript_path: string; + cwd: string; + model: { + id: string; + display_name: string; + }; + workspace: { + current_dir: string; + project_dir: string; + }; +} + +export interface AssistantMessage { + type: "assistant"; + message: { + model: string; + usage: { + input_tokens: number; + output_tokens: number; + }; + }; +} + +// ANSIColor代码 +const COLORS: Record = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + // 标准颜色 + black: "\x1b[30m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + white: "\x1b[37m", + // 亮色 + bright_black: "\x1b[90m", + bright_red: "\x1b[91m", + bright_green: "\x1b[92m", + bright_yellow: "\x1b[93m", + bright_blue: "\x1b[94m", + bright_magenta: "\x1b[95m", + bright_cyan: "\x1b[96m", + bright_white: "\x1b[97m", + // 背景颜色 + bg_black: "\x1b[40m", + bg_red: "\x1b[41m", + bg_green: "\x1b[42m", + bg_yellow: "\x1b[43m", + bg_blue: "\x1b[44m", + bg_magenta: "\x1b[45m", + bg_cyan: "\x1b[46m", + bg_white: "\x1b[47m", + // 亮背景色 + bg_bright_black: "\x1b[100m", + bg_bright_red: "\x1b[101m", + bg_bright_green: "\x1b[102m", + bg_bright_yellow: "\x1b[103m", + bg_bright_blue: "\x1b[104m", + bg_bright_magenta: "\x1b[105m", + bg_bright_cyan: "\x1b[106m", + bg_bright_white: "\x1b[107m", +}; + +// 使用TrueColor(24位色)支持十六进制颜色 +const TRUE_COLOR_PREFIX = "\x1b[38;2;"; +const TRUE_COLOR_BG_PREFIX = "\x1b[48;2;"; + +// 将十六进制颜色转为RGB格式 +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + // 移除#和空格 + hex = hex.replace(/^#/, '').trim(); + + // 处理简写形式 (#RGB -> #RRGGBB) + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + + if (hex.length !== 6) { + return null; + } + + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + // 验证RGB值是否有效 + if (isNaN(r) || isNaN(g) || isNaN(b) || r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) { + return null; + } + + return { r, g, b }; +} + +// 获取颜色代码 +function getColorCode(colorName: string): string { + // 检查是否是十六进制颜色 + if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) { + const rgb = hexToRgb(colorName); + if (rgb) { + return `${TRUE_COLOR_PREFIX}${rgb.r};${rgb.g};${rgb.b}m`; + } + } + + // 默认返回空字符串 + return ""; +} + + +// 变量替换函数,支持{{var}}格式的变量替换 +function replaceVariables(text: string, variables: Record): string { + return text.replace(/\{\{(\w+)\}\}/g, (match, varName) => { + return variables[varName] || match; + }); +} + +// 默认主题配置 - 使用Nerd Fonts图标和美观配色 +const DEFAULT_THEME: StatusLineThemeConfig = { + modules: [ + { + type: "workDir", + icon: "󰉋", // nf-md-folder_outline + text: "{{workDirName}}", + color: "bright_blue" + }, + { + type: "gitBranch", + icon: "", // nf-dev-git_branch + text: "{{gitBranch}}", + color: "bright_magenta" + }, + { + type: "model", + icon: "󰚩", // nf-md-robot_outline + text: "{{model}}", + color: "bright_cyan" + }, + { + type: "usage", + icon: "↑", // 上箭头 + text: "{{inputTokens}}", + color: "bright_green" + }, + { + type: "usage", + icon: "↓", // 下箭头 + text: "{{outputTokens}}", + color: "bright_yellow" + } + ] +}; + +// Powerline风格主题配置 +const POWERLINE_THEME: StatusLineThemeConfig = { + modules: [ + { + type: "workDir", + icon: "󰉋", // nf-md-folder_outline + text: "{{workDirName}}", + color: "white", + background: "bg_bright_blue" + }, + { + type: "gitBranch", + icon: "", // nf-dev-git_branch + text: "{{gitBranch}}", + color: "white", + background: "bg_bright_magenta" + }, + { + type: "model", + icon: "󰚩", // nf-md-robot_outline + text: "{{model}}", + color: "white", + background: "bg_bright_cyan" + }, + { + type: "usage", + icon: "↑", // 上箭头 + text: "{{inputTokens}}", + color: "white", + background: "bg_bright_green" + }, + { + type: "usage", + icon: "↓", // 下箭头 + text: "{{outputTokens}}", + color: "white", + background: "bg_bright_yellow" + } + ] +}; + +// 简单文本主题配置 - 用于图标无法显示时的fallback +const SIMPLE_THEME: StatusLineThemeConfig = { + modules: [ + { + type: "workDir", + icon: "", + text: "{{workDirName}}", + color: "bright_blue" + }, + { + type: "gitBranch", + icon: "", + text: "{{gitBranch}}", + color: "bright_magenta" + }, + { + type: "model", + icon: "", + text: "{{model}}", + color: "bright_cyan" + }, + { + type: "usage", + icon: "↑", + text: "{{inputTokens}}", + color: "bright_green" + }, + { + type: "usage", + icon: "↓", + text: "{{outputTokens}}", + color: "bright_yellow" + } + ] +}; + +// 格式化usage信息,如果大于1000则使用k单位 +function formatUsage(input_tokens: number, output_tokens: number): string { + if (input_tokens > 1000 || output_tokens > 1000) { + const inputFormatted = input_tokens > 1000 ? `${(input_tokens / 1000).toFixed(1)}k` : `${input_tokens}`; + const outputFormatted = output_tokens > 1000 ? `${(output_tokens / 1000).toFixed(1)}k` : `${output_tokens}`; + return `${inputFormatted} ${outputFormatted}`; + } + return `${input_tokens} ${output_tokens}`; +} + +// 读取用户主目录的主题配置 +async function getProjectThemeConfig(): Promise<{ theme: StatusLineThemeConfig | null, style: string }> { + try { + // 只使用主目录的固定配置文件 + const configPath = CONFIG_FILE; + + // 检查配置文件是否存在 + try { + await fs.access(configPath); + } catch { + return { theme: null, style: 'default' }; + } + + const configContent = await fs.readFile(configPath, "utf-8"); + const config = JSON5.parse(configContent); + + // 检查是否有StatusLine配置 + if (config.StatusLine) { + // 获取当前使用的风格,默认为default + const currentStyle = config.StatusLine.currentStyle || 'default'; + + // 检查是否有对应风格的配置 + if (config.StatusLine[currentStyle] && config.StatusLine[currentStyle].modules) { + return { theme: config.StatusLine[currentStyle], style: currentStyle }; + } + } + } catch (error) { + // 如果读取失败,返回null + // console.error("Failed to read theme config:", error); + } + + return { theme: null, style: 'default' }; +} + +// 检查是否应该使用简单主题(fallback方案) +// 当环境变量 USE_SIMPLE_ICONS 被设置时,或者当检测到可能不支持Nerd Fonts的终端时 +function shouldUseSimpleTheme(): boolean { + // 检查环境变量 + if (process.env.USE_SIMPLE_ICONS === 'true') { + return true; + } + + // 检查终端类型(一些常见的不支持复杂图标的终端) + const term = process.env.TERM || ''; + const unsupportedTerms = ['dumb', 'unknown']; + if (unsupportedTerms.includes(term)) { + return true; + } + + // 默认情况下,假设终端支持Nerd Fonts + return false; +} + +// 检查Nerd Fonts图标是否能正确显示 +// 通过检查终端字体信息或使用试探性方法 +function canDisplayNerdFonts(): boolean { + // 如果环境变量明确指定使用简单图标,则不能显示Nerd Fonts + if (process.env.USE_SIMPLE_ICONS === 'true') { + return false; + } + + // 检查一些常见的支持Nerd Fonts的终端环境变量 + const fontEnvVars = ['NERD_FONT', 'NERDFONT', 'FONT']; + for (const envVar of fontEnvVars) { + const value = process.env[envVar]; + if (value && (value.includes('Nerd') || value.includes('nerd'))) { + return true; + } + } + + // 检查终端类型 + const termProgram = process.env.TERM_PROGRAM || ''; + const supportedTerminals = ['iTerm.app', 'vscode', 'Hyper', 'kitty', 'alacritty']; + if (supportedTerminals.includes(termProgram)) { + return true; + } + + // 检查COLORTERM环境变量 + const colorTerm = process.env.COLORTERM || ''; + if (colorTerm.includes('truecolor') || colorTerm.includes('24bit')) { + return true; + } + + // 默认情况下,假设可以显示Nerd Fonts(但允许用户通过环境变量覆盖) + return process.env.USE_SIMPLE_ICONS !== 'true'; +} + +// 检查特定Unicode字符是否能正确显示 +// 这是一个简单的试探性检查 +function canDisplayUnicodeCharacter(char: string): boolean { + // 对于Nerd Fonts图标,我们假设支持UTF-8的终端可以显示 + // 但实际上很难准确检测,所以我们依赖环境变量和终端类型检测 + try { + // 检查终端是否支持UTF-8 + const lang = process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || ''; + if (lang.includes('UTF-8') || lang.includes('utf8') || lang.includes('UTF8')) { + return true; + } + + // 检查LC_*环境变量 + const lcVars = ['LC_ALL', 'LC_CTYPE', 'LANG']; + for (const lcVar of lcVars) { + const value = process.env[lcVar]; + if (value && (value.includes('UTF-8') || value.includes('utf8'))) { + return true; + } + } + } catch (e) { + // 如果检查失败,默认返回true + return true; + } + + // 默认情况下,假设可以显示 + return true; +} + +export async function parseStatusLineData(input: StatusLineInput): Promise { + try { + // 检查是否应该使用简单主题 + const useSimpleTheme = shouldUseSimpleTheme(); + + // 检查是否可以显示Nerd Fonts图标 + const canDisplayNerd = canDisplayNerdFonts(); + + // 确定使用的主题:如果用户强制使用简单主题或无法显示Nerd Fonts,则使用简单主题 + const effectiveTheme = useSimpleTheme || !canDisplayNerd ? SIMPLE_THEME : DEFAULT_THEME; + + // 获取主目录的主题配置,如果没有则使用确定的默认配置 + const { theme: projectTheme, style: currentStyle } = await getProjectThemeConfig(); + const theme = projectTheme || effectiveTheme; + + // 获取当前工作目录和Git分支 + const workDir = input.workspace.current_dir; + let gitBranch = ""; + + try { + // 尝试获取Git分支名 + gitBranch = execSync("git branch --show-current", { + cwd: workDir, + stdio: ["pipe", "pipe", "ignore"], + }) + .toString() + .trim(); + } catch (error) { + // 如果不是Git仓库或获取失败,则忽略错误 + } + + // 从transcript_path文件中读取最后一条assistant消息 + const transcriptContent = await fs.readFile(input.transcript_path, "utf-8"); + const lines = transcriptContent.trim().split("\n"); + + // 反向遍历寻找最后一条assistant消息 + let model = ""; + let inputTokens = 0; + let outputTokens = 0; + + for (let i = lines.length - 1; i >= 0; i--) { + try { + const message: AssistantMessage = JSON.parse(lines[i]); + if (message.type === "assistant" && message.message.model) { + model = message.message.model; + + if (message.message.usage) { + inputTokens = message.message.usage.input_tokens; + outputTokens = message.message.usage.output_tokens; + } + break; + } + } catch (parseError) { + // 忽略解析错误,继续查找 + continue; + } + } + + // 如果没有从transcript中获取到模型名称,则尝试从配置文件中获取 + if (!model) { + try { + // 获取项目配置文件路径 + const projectConfigPath = path.join(workDir, ".claude-code-router", "config.json"); + let configPath = projectConfigPath; + + // 检查项目配置文件是否存在,如果不存在则使用用户主目录的配置文件 + try { + await fs.access(projectConfigPath); + } catch { + configPath = CONFIG_FILE; + } + + // 读取配置文件 + const configContent = await fs.readFile(configPath, "utf-8"); + const config = JSON5.parse(configContent); + + // 从Router字段的default内容中获取模型名称 + if (config.Router && config.Router.default) { + const [, defaultModel] = config.Router.default.split(","); + if (defaultModel) { + model = defaultModel.trim(); + } + } + } catch (configError) { + // 如果配置文件读取失败,则忽略错误 + } + } + + // 如果仍然没有获取到模型名称,则使用传入的JSON数据中的model字段的display_name + if (!model) { + model = input.model.display_name; + } + + // 获取工作目录名 + const workDirName = workDir.split("/").pop() || ""; + + // 格式化usage信息 + const usage = formatUsage(inputTokens, outputTokens); + const [formattedInputTokens, formattedOutputTokens] = usage.split(" "); + + // 定义变量替换映射 + const variables = { + workDirName, + gitBranch, + model, + inputTokens: formattedInputTokens, + outputTokens: formattedOutputTokens + }; + + // 确定使用的风格 + const isPowerline = currentStyle === 'powerline'; + + // 根据风格渲染状态行 + if (isPowerline) { + return renderPowerlineStyle(theme, variables); + } else { + return renderDefaultStyle(theme, variables); + } + } catch (error) { + // 发生错误时返回空字符串 + return ""; + } +} + +// 读取用户主目录的主题配置(指定风格) +async function getProjectThemeConfigForStyle(style: string): Promise { + try { + // 只使用主目录的固定配置文件 + const configPath = CONFIG_FILE; + + // 检查配置文件是否存在 + try { + await fs.access(configPath); + } catch { + return null; + } + + const configContent = await fs.readFile(configPath, "utf-8"); + const config = JSON5.parse(configContent); + + // 检查是否有StatusLine配置 + if (config.StatusLine && config.StatusLine[style] && config.StatusLine[style].modules) { + return config.StatusLine[style]; + } + } catch (error) { + // 如果读取失败,返回null + // console.error("Failed to read theme config:", error); + } + + return null; +} + +// 渲染默认风格的状态行 +function renderDefaultStyle( + theme: StatusLineThemeConfig, + variables: Record +): string { + const modules = theme.modules || DEFAULT_THEME.modules; + const parts: string[] = []; + + // 遍历模块数组,渲染每个模块 + for (let i = 0; i < Math.min(modules.length, 5); i++) { + const module = modules[i]; + const color = module.color ? getColorCode(module.color) : ""; + const background = module.background ? getColorCode(module.background) : ""; + const icon = module.icon || ""; + const text = replaceVariables(module.text, variables); + + // 如果text为空且不是usage类型,则跳过该模块 + if (!text && module.type !== "usage") { + continue; + } + + // 构建模块字符串 + let part = `${background}${color}`; + if (icon) { + part += `${icon} `; + } + part += `${text}${COLORS.reset}`; + + parts.push(part); + } + + // 使用空格连接所有部分 + return parts.join(" "); +} + +// Powerline符号 +const SEP_RIGHT = "\uE0B0"; //  + +// 颜色编号(256色表) +const COLOR_MAP: Record = { + // 基础颜色映射到256色 + black: 0, + red: 1, + green: 2, + yellow: 3, + blue: 4, + magenta: 5, + cyan: 6, + white: 7, + bright_black: 8, + bright_red: 9, + bright_green: 10, + bright_yellow: 11, + bright_blue: 12, + bright_magenta: 13, + bright_cyan: 14, + bright_white: 15, + // 亮背景色映射 + bg_black: 0, + bg_red: 1, + bg_green: 2, + bg_yellow: 3, + bg_blue: 4, + bg_magenta: 5, + bg_cyan: 6, + bg_white: 7, + bg_bright_black: 8, + bg_bright_red: 9, + bg_bright_green: 10, + bg_bright_yellow: 11, + bg_bright_blue: 12, + bg_bright_magenta: 13, + bg_bright_cyan: 14, + bg_bright_white: 15, + // 自定义颜色映射 + bg_bright_orange: 202, + bg_bright_purple: 129, +}; + +// 获取TrueColor的RGB值 +function getTrueColorRgb(colorName: string): { r: number; g: number; b: number } | null { + // 如果是预定义颜色,返回对应RGB + if (COLOR_MAP[colorName] !== undefined) { + const color256 = COLOR_MAP[colorName]; + return color256ToRgb(color256); + } + + // 处理十六进制颜色 + if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) { + return hexToRgb(colorName); + } + + // 处理背景色十六进制 + if (colorName.startsWith('bg_#')) { + return hexToRgb(colorName.substring(3)); + } + + return null; +} + +// 将256色表索引转换为RGB值 +function color256ToRgb(index: number): { r: number; g: number; b: number } | null { + if (index < 0 || index > 255) return null; + + // ANSI 256色表转换 + if (index < 16) { + // 基本颜色 + const basicColors = [ + [0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0], + [0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192], + [128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0], + [0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255] + ]; + return { r: basicColors[index][0], g: basicColors[index][1], b: basicColors[index][2] }; + } else if (index < 232) { + // 216色:6×6×6的颜色立方体 + const i = index - 16; + const r = Math.floor(i / 36); + const g = Math.floor((i % 36) / 6); + const b = i % 6; + const rgb = [0, 95, 135, 175, 215, 255]; + return { r: rgb[r], g: rgb[g], b: rgb[b] }; + } else { + // 灰度色 + const gray = 8 + (index - 232) * 10; + return { r: gray, g: gray, b: gray }; + } +} + +// 生成一个无缝拼接的段:文本在 bgN 上显示,分隔符从 bgN 过渡到 nextBgN +function segment(text: string, textFg: string, bgColor: string, nextBgColor: string | null): string { + const bgRgb = getTrueColorRgb(bgColor); + if (!bgRgb) { + // 如果无法获取RGB,使用默认蓝色背景 + const defaultBlueRgb = { r: 33, g: 150, b: 243 }; + const curBg = `\x1b[48;2;${defaultBlueRgb.r};${defaultBlueRgb.g};${defaultBlueRgb.b}m`; + const fgColor = `\x1b[38;2;255;255;255m`; + const body = `${curBg}${fgColor} ${text} \x1b[0m`; + return body; + } + + const curBg = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`; + + // 获取前景色RGB + let fgRgb = { r: 255, g: 255, b: 255 }; // 默认前景色为白色 + const textFgRgb = getTrueColorRgb(textFg); + if (textFgRgb) { + fgRgb = textFgRgb; + } + + const fgColor = `\x1b[38;2;${fgRgb.r};${fgRgb.g};${fgRgb.b}m`; + const body = `${curBg}${fgColor} ${text} \x1b[0m`; + + if (nextBgColor != null) { + const nextBgRgb = getTrueColorRgb(nextBgColor); + if (nextBgRgb) { + // 分隔符:前景色是当前段的背景色,背景色是下一段的背景色 + const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`; + const sepNextBg = `\x1b[48;2;${nextBgRgb.r};${nextBgRgb.g};${nextBgRgb.b}m`; + const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`; + return body + sep; + } + // 如果没有下一个背景色,假设终端背景为黑色并渲染黑色箭头 + const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`; + const sepNextBg = `\x1b[48;2;0;0;0m`; // 黑色背景 + const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`; + return body + sep; + } + + return body; +} + +// 渲染Powerline风格的状态行 +function renderPowerlineStyle( + theme: StatusLineThemeConfig, + variables: Record +): string { + const modules = theme.modules || POWERLINE_THEME.modules; + const segments: string[] = []; + + // 遍历模块数组,渲染每个模块 + for (let i = 0; i < Math.min(modules.length, 5); i++) { + const module = modules[i]; + const color = module.color || "white"; + const backgroundName = module.background || ""; + const icon = module.icon || ""; + const text = replaceVariables(module.text, variables); + + // 如果text为空且不是usage类型,则跳过该模块 + if (!text && module.type !== "usage") { + continue; + } + + // 构建显示文本 + let displayText = ""; + if (icon) { + displayText += `${icon} `; + } + displayText += text; + + // 获取下一个模块的背景色(用于分隔符) + let nextBackground: string | null = null; + if (i < modules.length - 1) { + const nextModule = modules[i + 1]; + nextBackground = nextModule.background || null; + } + + // 使用模块定义的背景色,或者为Powerline风格提供默认背景色 + const actualBackground = backgroundName || "bg_bright_blue"; + + // 生成段,支持十六进制颜色 + const segmentStr = segment(displayText, color, actualBackground, nextBackground); + segments.push(segmentStr); + } + + return segments.join(""); +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 43ab46d..e32de6f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,11 +8,13 @@ "name": "temp-project", "version": "0.0.0", "dependencies": { + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/vite": "^4.1.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -21,6 +23,8 @@ "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.525.0", "react": "^19.1.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.1.0", "react-i18next": "^15.6.1", "react-router-dom": "^7.7.0", @@ -1086,6 +1090,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", + "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1514,6 +1541,129 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -1650,12 +1800,53 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2955,6 +3146,17 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.192", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.192.tgz", @@ -3221,7 +3423,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -3438,6 +3639,15 @@ "node": ">=8" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -4016,6 +4226,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT", + "peer": true + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4252,6 +4469,45 @@ "node": ">=0.10.0" } }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "license": "MIT", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", @@ -4290,6 +4546,12 @@ } } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4407,6 +4669,15 @@ } } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4545,6 +4816,12 @@ "node": ">=0.10.0" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/ui/package.json b/ui/package.json index c22d527..04e28a7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,13 +10,13 @@ "preview": "vite preview" }, "dependencies": { - "@radix-ui/react-tooltip": "^1.2.7", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/vite": "^4.1.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -25,6 +25,9 @@ "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.525.0", "react": "^19.1.0", + "react-colorful": "^5.6.1", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.1.0", "react-i18next": "^15.6.1", "react-router-dom": "^7.7.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 2ef40c1..9104959 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@radix-ui/react-switch': specifier: ^1.2.5 version: 1.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-tooltip': + specifier: ^1.2.7 + version: 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tailwindcss/vite': specifier: ^4.1.11 version: 4.1.11(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)) @@ -50,6 +53,15 @@ importers: react: specifier: ^19.1.0 version: 19.1.0 + react-colorful: + specifier: ^5.6.1 + version: 5.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-dnd: + specifier: ^16.0.1 + version: 16.0.1(@types/node@24.1.0)(@types/react@19.1.8)(react@19.1.0) + react-dnd-html5-backend: + specifier: ^16.0.1 + version: 16.0.1 react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) @@ -489,6 +501,9 @@ packages: '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -546,6 +561,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.2': resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} peerDependencies: @@ -616,6 +644,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.9': resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} peerDependencies: @@ -642,6 +683,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -677,6 +731,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -749,9 +816,31 @@ packages: '@types/react': optional: true + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-dnd/asap@5.0.2': + resolution: {integrity: sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==} + + '@react-dnd/invariant@4.0.2': + resolution: {integrity: sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==} + + '@react-dnd/shallowequal@4.0.2': + resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1162,6 +1251,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dnd-core@16.0.1: + resolution: {integrity: sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==} + electron-to-chromium@1.5.190: resolution: {integrity: sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==} @@ -1320,6 +1412,9 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -1586,6 +1681,30 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-colorful@5.6.1: + resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + react-dnd-html5-backend@16.0.1: + resolution: {integrity: sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==} + + react-dnd@16.0.1: + resolution: {integrity: sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==} + peerDependencies: + '@types/hoist-non-react-statics': '>= 3.3.1' + '@types/node': '>= 12' + '@types/react': '>= 16' + react: '>= 16.14' + peerDependenciesMeta: + '@types/hoist-non-react-statics': + optional: true + '@types/node': + optional: true + '@types/react': + optional: true + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: @@ -1607,6 +1726,9 @@ packages: typescript: optional: true + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -1662,6 +1784,9 @@ packages: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} + redux@4.2.1: + resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2197,6 +2322,8 @@ snapshots: '@radix-ui/primitive@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2253,6 +2380,19 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.8)(react@19.1.0)': dependencies: react: 19.1.0 @@ -2327,6 +2467,24 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/rect': 1.1.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2347,6 +2505,16 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) @@ -2378,6 +2546,26 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.8)(react@19.1.0)': dependencies: react: 19.1.0 @@ -2432,8 +2620,23 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/rect@1.1.1': {} + '@react-dnd/asap@5.0.2': {} + + '@react-dnd/invariant@4.0.2': {} + + '@react-dnd/shallowequal@4.0.2': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.45.1': @@ -2831,6 +3034,12 @@ snapshots: detect-node-es@1.1.0: {} + dnd-core@16.0.1: + dependencies: + '@react-dnd/asap': 5.0.2 + '@react-dnd/invariant': 4.0.2 + redux: 4.2.1 + electron-to-chromium@1.5.190: {} enhanced-resolve@5.18.2: @@ -3017,6 +3226,10 @@ snapshots: has-flag@4.0.0: {} + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -3222,6 +3435,27 @@ snapshots: queue-microtask@1.2.3: {} + react-colorful@5.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + react-dnd-html5-backend@16.0.1: + dependencies: + dnd-core: 16.0.1 + + react-dnd@16.0.1(@types/node@24.1.0)(@types/react@19.1.8)(react@19.1.0): + dependencies: + '@react-dnd/invariant': 4.0.2 + '@react-dnd/shallowequal': 4.0.2 + dnd-core: 16.0.1 + fast-deep-equal: 3.1.3 + hoist-non-react-statics: 3.3.2 + react: 19.1.0 + optionalDependencies: + '@types/node': 24.1.0 + '@types/react': 19.1.8 + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 @@ -3237,6 +3471,8 @@ snapshots: react-dom: 19.1.0(react@19.1.0) typescript: 5.8.3 + react-is@16.13.1: {} + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.0): @@ -3282,6 +3518,10 @@ snapshots: react@19.1.0: {} + redux@4.2.1: + dependencies: + '@babel/runtime': 7.27.6 + resolve-from@4.0.0: {} reusify@1.1.0: {} diff --git a/ui/src/components/ConfigProvider.tsx b/ui/src/components/ConfigProvider.tsx index f679cee..cd8d4ce 100644 --- a/ui/src/components/ConfigProvider.tsx +++ b/ui/src/components/ConfigProvider.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useState, useEffect } from 'react'; import type { ReactNode, Dispatch, SetStateAction } from 'react'; import { api } from '@/lib/api'; -import type { Config } from '@/types'; +import type { Config, StatusLineConfig } from '@/types'; interface ConfigContextType { config: Config | null; @@ -78,6 +78,17 @@ export function ConfigProvider({ children }: ConfigProviderProps) { PROXY_URL: typeof data.PROXY_URL === 'string' ? data.PROXY_URL : '', transformers: Array.isArray(data.transformers) ? data.transformers : [], Providers: Array.isArray(data.Providers) ? data.Providers : [], + StatusLine: data.StatusLine && typeof data.StatusLine === 'object' ? { + enabled: typeof data.StatusLine.enabled === 'boolean' ? data.StatusLine.enabled : false, + currentStyle: typeof data.StatusLine.currentStyle === 'string' ? data.StatusLine.currentStyle : 'default', + default: data.StatusLine.default && typeof data.StatusLine.default === 'object' && Array.isArray(data.StatusLine.default.modules) ? data.StatusLine.default : { modules: [] }, + powerline: data.StatusLine.powerline && typeof data.StatusLine.powerline === 'object' && Array.isArray(data.StatusLine.powerline.modules) ? data.StatusLine.powerline : { modules: [] } + } : { + enabled: false, + currentStyle: 'default', + default: { modules: [] }, + powerline: { modules: [] } + }, Router: data.Router && typeof data.Router === 'object' ? { default: typeof data.Router.default === 'string' ? data.Router.default : '', background: typeof data.Router.background === 'string' ? data.Router.background : '', @@ -113,6 +124,7 @@ export function ConfigProvider({ children }: ConfigProviderProps) { PROXY_URL: '', transformers: [], Providers: [], + StatusLine: undefined, Router: { default: '', background: '', diff --git a/ui/src/components/SettingsDialog.tsx b/ui/src/components/SettingsDialog.tsx index 9da6ffc..7cf934a 100644 --- a/ui/src/components/SettingsDialog.tsx +++ b/ui/src/components/SettingsDialog.tsx @@ -1,4 +1,3 @@ - import { useTranslation } from "react-i18next"; import { Dialog, @@ -13,6 +12,9 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Combobox } from "@/components/ui/combobox"; import { useConfig } from "./ConfigProvider"; +import { StatusLineConfigDialog } from "./StatusLineConfigDialog"; +import { useState } from "react"; +import type { StatusLineConfig } from "@/types"; interface SettingsDialogProps { isOpen: boolean; @@ -22,6 +24,7 @@ interface SettingsDialogProps { export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) { const { t } = useTranslation(); const { config, setConfig } = useConfig(); + const [isStatusLineConfigOpen, setIsStatusLineConfigOpen] = useState(false); if (!config) { return null; @@ -35,16 +38,71 @@ export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) { setConfig({ ...config, CLAUDE_PATH: e.target.value }); }; + const handleStatusLineEnabledChange = (checked: boolean) => { + // Ensure we have a complete StatusLineConfig object + const newStatusLineConfig: StatusLineConfig = { + enabled: checked, + currentStyle: config.StatusLine?.currentStyle || "default", + default: config.StatusLine?.default || { modules: [] }, + powerline: config.StatusLine?.powerline || { modules: [] }, + }; + + setConfig({ + ...config, + StatusLine: newStatusLineConfig, + }); + }; + + const openStatusLineConfig = () => { + setIsStatusLineConfigOpen(true); + }; + return ( - + {t("toplevel.title")}
- - + + +
+ {/* StatusLine Configuration */} +
+
+
+ + +
+ +
@@ -62,34 +120,114 @@ export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) { />
- - + +
- - setConfig({ ...config, HOST: e.target.value })} className="transition-all-ease focus:scale-[1.01]" /> + + setConfig({ ...config, HOST: e.target.value })} + className="transition-all-ease focus:scale-[1.01]" + />
- - setConfig({ ...config, PORT: parseInt(e.target.value, 10) })} className="transition-all-ease focus:scale-[1.01]" /> + + + setConfig({ ...config, PORT: parseInt(e.target.value, 10) }) + } + className="transition-all-ease focus:scale-[1.01]" + />
- - setConfig({ ...config, API_TIMEOUT_MS: e.target.value })} className="transition-all-ease focus:scale-[1.01]" /> + + + setConfig({ ...config, API_TIMEOUT_MS: e.target.value }) + } + className="transition-all-ease focus:scale-[1.01]" + />
- - setConfig({ ...config, PROXY_URL: e.target.value })} placeholder="http://127.0.0.1:7890" className="transition-all-ease focus:scale-[1.01]" /> + + + setConfig({ ...config, PROXY_URL: e.target.value }) + } + placeholder="http://127.0.0.1:7890" + className="transition-all-ease focus:scale-[1.01]" + />
- - setConfig({ ...config, APIKEY: e.target.value })} className="transition-all-ease focus:scale-[1.01]" /> + + setConfig({ ...config, APIKEY: e.target.value })} + className="transition-all-ease focus:scale-[1.01]" + />
- +
+ +
); } diff --git a/ui/src/components/StatusLineConfigDialog.tsx b/ui/src/components/StatusLineConfigDialog.tsx new file mode 100644 index 0000000..a1c5e55 --- /dev/null +++ b/ui/src/components/StatusLineConfigDialog.tsx @@ -0,0 +1,647 @@ +import { useTranslation } from "react-i18next"; +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Combobox } from "@/components/ui/combobox"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { Badge } from "@/components/ui/badge"; +import { useConfig } from "./ConfigProvider"; +import { validateStatusLineConfig, formatValidationError, createDefaultStatusLineConfig } from "@/utils/statusline"; +import type { StatusLineConfig, StatusLineModuleConfig, StatusLineThemeConfig } from "@/types"; + + +const DEFAULT_MODULE: StatusLineModuleConfig = { + type: "workDir", + icon: "󰉋", + text: "{{workDirName}}", + color: "bright_blue" +}; + +// 模块类型选项 +const MODULE_TYPES = [ + { label: "workDir", value: "workDir" }, + { label: "gitBranch", value: "gitBranch" }, + { label: "model", value: "model" }, + { label: "usage", value: "usage" } +]; + +// ANSI颜色代码映射 +const ANSI_COLORS: Record = { + // 标准颜色 + black: "text-black", + red: "text-red-600", + green: "text-green-600", + yellow: "text-yellow-500", + blue: "text-blue-500", + magenta: "text-purple-500", + cyan: "text-cyan-500", + white: "text-white", + // 亮色 + bright_black: "text-gray-500", + bright_red: "text-red-400", + bright_green: "text-green-400", + bright_yellow: "text-yellow-300", + bright_blue: "text-blue-300", + bright_magenta: "text-purple-300", + bright_cyan: "text-cyan-300", + bright_white: "text-white", + // 背景颜色 + bg_black: "bg-black", + bg_red: "bg-red-600", + bg_green: "bg-green-600", + bg_yellow: "bg-yellow-500", + bg_blue: "bg-blue-500", + bg_magenta: "bg-purple-500", + bg_cyan: "bg-cyan-500", + bg_white: "bg-white", + // 亮背景色 + bg_bright_black: "bg-gray-800", + bg_bright_red: "bg-red-400", + bg_bright_green: "bg-green-400", + bg_bright_yellow: "bg-yellow-300", + bg_bright_blue: "bg-blue-300", + bg_bright_magenta: "bg-purple-300", + bg_bright_cyan: "bg-cyan-300", + bg_bright_white: "bg-gray-100", + // Powerline样式需要的额外背景色 + bg_bright_orange: "bg-orange-400", + bg_bright_purple: "bg-purple-400", +}; + +// 变量替换函数 +function replaceVariables(text: string, variables: Record): string { + return text.replace(/\{\{(\w+)\}\}/g, (match, varName) => { + return variables[varName] || match; + }); +} + +// 渲染单个模块预览 +function renderModulePreview(module: StatusLineModuleConfig, isPowerline: boolean = false): React.ReactNode { + // 模拟变量数据 + const variables = { + workDirName: "project", + gitBranch: "main", + model: "Claude Sonnet 4", + inputTokens: "1.2k", + outputTokens: "2.5k" + }; + + const text = replaceVariables(module.text, variables); + const icon = module.icon || ""; + + // 如果text为空且不是usage类型,则跳过该模块 + if (!text && module.type !== "usage") { + return null; + } + + // 如果是Powerline样式,添加背景色和分隔符 + if (isPowerline) { + const bgColorClass = module.background ? ANSI_COLORS[module.background] || "" : ""; + const textColorClass = module.color ? ANSI_COLORS[module.color] || "text-white" : "text-white"; + + return ( +
+
+ {icon && {icon}} + {text} +
+
+
+ ); + } + + return ( + <> + {icon && {icon}} + {text} + + ); +} + + +interface StatusLineConfigDialogProps { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +} + +export function StatusLineConfigDialog({ isOpen, onOpenChange }: StatusLineConfigDialogProps) { + const { t } = useTranslation(); + const { config, setConfig } = useConfig(); + + // 添加Powerline分隔符样式 + useEffect(() => { + const styleElement = document.createElement('style'); + styleElement.innerHTML = ` + .powerline-module { + display: inline-flex; + align-items: center; + height: 28px; + position: relative; + padding: 0 8px; + overflow: visible; + } + + .powerline-module-content { + display: flex; + align-items: center; + gap: 4px; + position: relative; + } + + .powerline-separator { + width: 0; + height: 0; + border-top: 14px solid transparent; + border-bottom: 14px solid transparent; + border-left: 8px solid; + position: absolute; + right: -8px; + top: 0; + display: block; + } + + /* 使用层级确保每个模块的三角形覆盖在下一个模块上方 */ + .cursor-pointer:nth-child(1) .powerline-separator { z-index: 10; } + .cursor-pointer:nth-child(2) .powerline-separator { z-index: 9; } + .cursor-pointer:nth-child(3) .powerline-separator { z-index: 8; } + .cursor-pointer:nth-child(4) .powerline-separator { z-index: 7; } + .cursor-pointer:nth-child(5) .powerline-separator { z-index: 6; } + .cursor-pointer:nth-child(6) .powerline-separator { z-index: 5; } + .cursor-pointer:nth-child(7) .powerline-separator { z-index: 4; } + .cursor-pointer:nth-child(8) .powerline-separator { z-index: 3; } + .cursor-pointer:nth-child(9) .powerline-separator { z-index: 2; } + .cursor-pointer:nth-child(10) .powerline-separator { z-index: 1; } + + .cursor-pointer:last-child .powerline-separator { + display: none; + } + + /* 根据data属性动态设置颜色,确保与模块背景色一致 */ + .powerline-separator[data-current-bg="bg_black"] { border-left-color: #000000; } + .powerline-separator[data-current-bg="bg_red"] { border-left-color: #dc2626; } + .powerline-separator[data-current-bg="bg_green"] { border-left-color: #16a34a; } + .powerline-separator[data-current-bg="bg_yellow"] { border-left-color: #eab308; } + .powerline-separator[data-current-bg="bg_blue"] { border-left-color: #3b82f6; } + .powerline-separator[data-current-bg="bg_magenta"] { border-left-color: #a855f7; } + .powerline-separator[data-current-bg="bg_cyan"] { border-left-color: #06b6d4; } + .powerline-separator[data-current-bg="bg_white"] { border-left-color: #ffffff; } + .powerline-separator[data-current-bg="bg_bright_black"] { border-left-color: #1f2937; } + .powerline-separator[data-current-bg="bg_bright_red"] { border-left-color: #f87171; } + .powerline-separator[data-current-bg="bg_bright_green"] { border-left-color: #4ade80; } + .powerline-separator[data-current-bg="bg_bright_yellow"] { border-left-color: #fde047; } + .powerline-separator[data-current-bg="bg_bright_blue"] { border-left-color: #93c5fd; } + .powerline-separator[data-current-bg="bg_bright_magenta"] { border-left-color: #c084fc; } + .powerline-separator[data-current-bg="bg_bright_cyan"] { border-left-color: #22d3ee; } + .powerline-separator[data-current-bg="bg_bright_white"] { border-left-color: #f3f4f6; } + .powerline-separator[data-current-bg="bg_bright_orange"] { border-left-color: #fb923c; } + .powerline-separator[data-current-bg="bg_bright_purple"] { border-left-color: #c084fc; } + `; + document.head.appendChild(styleElement); + + // 清理函数 + return () => { + document.head.removeChild(styleElement); + }; + }, []); + + const [statusLineConfig, setStatusLineConfig] = useState( + config?.StatusLine || createDefaultStatusLineConfig() + ); + + const [selectedModuleIndex, setSelectedModuleIndex] = useState(null); + + // 模块类型选项 + const MODULE_TYPES_OPTIONS = MODULE_TYPES.map(item => ({ + ...item, + label: t(`statusline.${item.label}`) + })); + + + + const handleThemeChange = (value: string) => { + setStatusLineConfig(prev => ({ ...prev, currentStyle: value })); + }; + + const handleModuleChange = (index: number, field: keyof StatusLineModuleConfig, value: string) => { + const currentTheme = statusLineConfig.currentStyle as keyof StatusLineConfig; + const themeConfig = statusLineConfig[currentTheme]; + const modules = themeConfig && typeof themeConfig === 'object' && 'modules' in themeConfig + ? [...((themeConfig as StatusLineThemeConfig).modules || [])] + : []; + if (modules[index]) { + modules[index] = { ...modules[index], [field]: value }; + } + + setStatusLineConfig(prev => ({ + ...prev, + [currentTheme]: { modules } + })); + }; + + const [validationErrors, setValidationErrors] = useState([]); + + const handleSave = () => { + // 验证配置 + const validationResult = validateStatusLineConfig(statusLineConfig); + + if (!validationResult.isValid) { + // 格式化错误信息 + const errorMessages = validationResult.errors.map(error => + formatValidationError(error, t) + ); + setValidationErrors(errorMessages); + return; + } + + // 清除之前的错误 + setValidationErrors([]); + + if (config) { + setConfig({ + ...config, + StatusLine: statusLineConfig + }); + onOpenChange(false); + } + }; + + // 创建自定义Alert组件 + const CustomAlert = ({ + title, + description, + variant = "default" + }: { + title: string; + description: React.ReactNode; + variant?: "default" | "destructive"; + }) => { + const isError = variant === "destructive"; + + return ( +
+
+
+ {isError ? ( + + + + ) : ( + + + + )} +
+
+

+ {title} +

+
+ {description} +
+
+
+
+ ); + }; + + const currentThemeKey = statusLineConfig.currentStyle as keyof StatusLineConfig; +const currentThemeConfig = statusLineConfig[currentThemeKey]; +const currentModules = currentThemeConfig && typeof currentThemeConfig === 'object' && 'modules' in currentThemeConfig + ? ((currentThemeConfig as StatusLineThemeConfig).modules || []) + : []; +const selectedModule = selectedModuleIndex !== null && currentModules.length > selectedModuleIndex ? currentModules[selectedModuleIndex] : null; + + return ( + + + + + + + + + + {t("statusline.title")} + + + + {/* 错误显示区域 */} + {validationErrors.length > 0 && ( +
+ + {validationErrors.map((error, index) => ( +
  • {error}
  • + ))} + + } + /> +
    + )} + +
    + {/* 配置面板 */} +
    + {/* 主题样式选择 */} +
    + +
    + +
    +
    + + +
    + + {/* 三栏布局:组件列表 | 预览区域 | 属性配置 */} +
    + {/* 左侧:支持的组件 */} +
    +

    组件

    +
    + {MODULE_TYPES_OPTIONS.map((moduleType) => ( +
    { + e.dataTransfer.setData("moduleType", moduleType.value); + }} + > + {moduleType.label} +
    + ))} +
    +
    + + {/* 中间:预览区域 */} +
    +

    预览

    +
    { + e.preventDefault(); + }} + onDrop={(e) => { + e.preventDefault(); + const moduleType = e.dataTransfer.getData("moduleType"); + if (moduleType) { + // 添加新模块 + const currentTheme = statusLineConfig.currentStyle as keyof StatusLineConfig; + const themeConfig = statusLineConfig[currentTheme]; + const modules = themeConfig && typeof themeConfig === 'object' && 'modules' in themeConfig + ? [...((themeConfig as StatusLineThemeConfig).modules || [])] + : []; + + // 根据模块类型设置默认值 + let newModule: StatusLineModuleConfig; + switch (moduleType) { + case "workDir": + newModule = { type: "workDir", icon: "󰉋", text: "{{workDirName}}", color: "bright_blue" }; + break; + case "gitBranch": + newModule = { type: "gitBranch", icon: "🌿", text: "{{gitBranch}}", color: "bright_green" }; + break; + case "model": + newModule = { type: "model", icon: "🤖", text: "{{model}}", color: "bright_yellow" }; + break; + case "usage": + newModule = { type: "usage", icon: "📊", text: "{{inputTokens}} → {{outputTokens}}", color: "bright_magenta" }; + break; + default: + newModule = { ...DEFAULT_MODULE, type: moduleType }; + } + + modules.push(newModule); + + setStatusLineConfig(prev => ({ + ...prev, + [currentTheme]: { modules } + })); + } + }} + > + {currentModules.length > 0 ? ( +
    + {currentModules.map((module, index) => ( +
    setSelectedModuleIndex(index)} + draggable + onDragStart={(e) => { + e.dataTransfer.setData("dragIndex", index.toString()); + }} + onDragOver={(e) => { + e.preventDefault(); + }} + onDrop={(e) => { + e.preventDefault(); + const dragIndex = parseInt(e.dataTransfer.getData("dragIndex")); + if (!isNaN(dragIndex) && dragIndex !== index) { + // 重新排序模块 + const currentTheme = statusLineConfig.currentStyle as keyof StatusLineConfig; + const themeConfig = statusLineConfig[currentTheme]; + const modules = themeConfig && typeof themeConfig === 'object' && 'modules' in themeConfig + ? [...((themeConfig as StatusLineThemeConfig).modules || [])] + : []; + + if (dragIndex >= 0 && dragIndex < modules.length && index >= 0 && index <= modules.length) { + const [movedModule] = modules.splice(dragIndex, 1); + modules.splice(index, 0, movedModule); + + setStatusLineConfig(prev => ({ + ...prev, + [currentTheme]: { modules } + })); + + // 更新选中项的索引 + if (selectedModuleIndex === dragIndex) { + setSelectedModuleIndex(index); + } else if (selectedModuleIndex === index) { + setSelectedModuleIndex(dragIndex); + } + } + } + }} + > + {renderModulePreview(module, statusLineConfig.currentStyle === 'powerline')} +
    + ))} +
    + ) : ( +
    + + + + + + + 拖拽组件到此处进行配置 + +
    + )} +
    +
    + + {/* 右侧:属性配置 */} +
    +

    属性

    +
    + {selectedModule && selectedModuleIndex !== null ? ( +
    +
    + + handleModuleChange(selectedModuleIndex, "type", value)} + /> +

    + 选择模块类型以确定显示的信息 +

    +
    + +
    + + handleModuleChange(selectedModuleIndex, "icon", e.target.value)} + placeholder="例如: 󰉋" + /> +

    + 输入图标字符或表情符号(可选) +

    +
    + +
    + + handleModuleChange(selectedModuleIndex, "text", e.target.value)} + placeholder="例如: {{workDirName}}" + /> +
    +

    输入显示文本,可使用变量:

    +
    + {"{{workDirName}}"} + {"{{gitBranch}}"} + {"{{model}}"} + {"{{inputTokens}}"} + {"{{outputTokens}}"} +
    +
    +
    + +
    + + handleModuleChange(selectedModuleIndex, "color", value)} + /> +

    + 选择文字颜色 +

    +
    + +
    + + handleModuleChange(selectedModuleIndex, "background", value)} + /> +

    + 选择背景颜色(可选) +

    +
    + + +
    + ) : ( +
    +

    选择一个组件进行配置

    +
    + )} +
    +
    +
    +
    + + + + + +
    +
    + ); +} diff --git a/ui/src/components/StatusLineImportExport.tsx b/ui/src/components/StatusLineImportExport.tsx new file mode 100644 index 0000000..5ab875d --- /dev/null +++ b/ui/src/components/StatusLineImportExport.tsx @@ -0,0 +1,309 @@ +import { useTranslation } from "react-i18next"; +import React, { useState, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { validateStatusLineConfig, backupConfig, restoreConfig, createDefaultStatusLineConfig } from "@/utils/statusline"; +import type { StatusLineConfig } from "@/types"; + +interface StatusLineImportExportProps { + config: StatusLineConfig; + onImport: (config: StatusLineConfig) => void; + onShowToast: (message: string, type: 'success' | 'error' | 'warning') => void; +} + +export function StatusLineImportExport({ config, onImport, onShowToast }: StatusLineImportExportProps) { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + const [isImporting, setIsImporting] = useState(false); + + // 导出配置为JSON文件 + const handleExport = () => { + try { + // 在导出前验证配置 + const validationResult = validateStatusLineConfig(config); + + if (!validationResult.isValid) { + onShowToast(t("statusline.export_validation_failed"), 'error'); + return; + } + + const dataStr = JSON.stringify(config, null, 2); + const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(dataStr)}`; + + const exportFileDefaultName = `statusline-config-${new Date().toISOString().slice(0, 10)}.json`; + + const linkElement = document.createElement('a'); + linkElement.setAttribute('href', dataUri); + linkElement.setAttribute('download', exportFileDefaultName); + linkElement.click(); + + onShowToast(t("statusline.export_success"), 'success'); + } catch (error) { + console.error("Export failed:", error); + onShowToast(t("statusline.export_failed"), 'error'); + } + }; + + // 导入配置从JSON文件 + const handleImport = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setIsImporting(true); + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const content = e.target?.result as string; + const importedConfig = JSON.parse(content) as StatusLineConfig; + + // 验证导入的配置 + const validationResult = validateStatusLineConfig(importedConfig); + + if (!validationResult.isValid) { + // 格式化错误信息 + const errorMessages = validationResult.errors.map(error => + error.message + ).join('; '); + throw new Error(`${t("statusline.invalid_config")}: ${errorMessages}`); + } + + onImport(importedConfig); + onShowToast(t("statusline.import_success"), 'success'); + } catch (error) { + console.error("Import failed:", error); + onShowToast(t("statusline.import_failed") + (error instanceof Error ? `: ${error.message}` : ""), 'error'); + } finally { + setIsImporting(false); + // 重置文件输入,以便可以再次选择同一个文件 + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + reader.onerror = () => { + onShowToast(t("statusline.import_failed"), 'error'); + setIsImporting(false); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + reader.readAsText(file); + }; + + // 下载配置模板 + const handleDownloadTemplate = () => { + try { + // 使用新的默认配置函数 + const templateConfig = createDefaultStatusLineConfig(); + + const dataStr = JSON.stringify(templateConfig, null, 2); + const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(dataStr)}`; + + const templateFileName = "statusline-config-template.json"; + + const linkElement = document.createElement('a'); + linkElement.setAttribute('href', dataUri); + linkElement.setAttribute('download', templateFileName); + linkElement.click(); + + onShowToast(t("statusline.template_download_success"), 'success'); + } catch (error) { + console.error("Template download failed:", error); + onShowToast(t("statusline.template_download_failed"), 'error'); + } + }; + + // 配置备份功能 + const handleBackup = () => { + try { + const backupStr = backupConfig(config); + const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(backupStr)}`; + + const backupFileName = `statusline-backup-${new Date().toISOString().slice(0, 10)}.json`; + + const linkElement = document.createElement('a'); + linkElement.setAttribute('href', dataUri); + linkElement.setAttribute('download', backupFileName); + linkElement.click(); + + onShowToast(t("statusline.backup_success"), 'success'); + } catch (error) { + console.error("Backup failed:", error); + onShowToast(t("statusline.backup_failed"), 'error'); + } + }; + + // 配置恢复功能 + const handleRestore = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const content = e.target?.result as string; + const restoredConfig = restoreConfig(content); + + if (!restoredConfig) { + throw new Error(t("statusline.invalid_backup_file")); + } + + // 验证恢复的配置 + const validationResult = validateStatusLineConfig(restoredConfig); + + if (!validationResult.isValid) { + // 格式化错误信息 + const errorMessages = validationResult.errors.map(error => + error.message + ).join('; '); + throw new Error(`${t("statusline.invalid_config")}: ${errorMessages}`); + } + + onImport(restoredConfig); + onShowToast(t("statusline.restore_success"), 'success'); + } catch (error) { + console.error("Restore failed:", error); + onShowToast(t("statusline.restore_failed") + (error instanceof Error ? `: ${error.message}` : ""), 'error'); + } finally { + // 重置文件输入 + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + reader.onerror = () => { + onShowToast(t("statusline.restore_failed"), 'error'); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + reader.readAsText(file); + }; + + // 移除本地验证函数,因为我们现在使用utils中的验证函数 + + return ( + + + + + + + + + {t("statusline.import_export")} + + + +
    +
    + + + +
    + +
    + + + +
    + + +
    + + + +
    +
    + + + + + +
    +

    + {t("statusline.import_export_help")} +

    +
    +
    +
    +
    +
    + ); +} diff --git a/ui/src/components/ui/color-picker.tsx b/ui/src/components/ui/color-picker.tsx new file mode 100644 index 0000000..7010910 --- /dev/null +++ b/ui/src/components/ui/color-picker.tsx @@ -0,0 +1,262 @@ +"use client" + +import * as React from "react" +import { HexColorPicker } from "react-colorful" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" + +interface ColorPickerProps { + value?: string; + onChange: (value: string) => void; + placeholder?: string; + showPreview?: boolean; +} + +// 预定义的ANSI颜色映射 +const ANSI_COLOR_MAP: Record = { + "black": "#000000", + "red": "#ff0000", + "green": "#00ff00", + "yellow": "#ffff00", + "blue": "#0000ff", + "magenta": "#ff00ff", + "cyan": "#00ffff", + "white": "#ffffff", + "bright_black": "#808080", + "bright_red": "#ff8080", + "bright_green": "#80ff80", + "bright_yellow": "#ffff80", + "bright_blue": "#8080ff", + "bright_magenta": "#ff80ff", + "bright_cyan": "#80ffff", + "bright_white": "#ffffff" +} + +// 背景颜色映射(添加bg_前缀) +const ANSI_BG_COLOR_MAP: Record = Object.keys(ANSI_COLOR_MAP).reduce((acc, key) => { + acc[`bg_${key}`] = ANSI_COLOR_MAP[key] + return acc +}, {} as Record) + +// 合并所有颜色映射 +const ALL_COLOR_MAP = { ...ANSI_COLOR_MAP, ...ANSI_BG_COLOR_MAP } + +// 获取颜色值的函数 +const getColorValue = (color: string): string => { + // 如果是预定义的ANSI颜色 + if (ALL_COLOR_MAP[color]) { + return ALL_COLOR_MAP[color] + } + + // 如果是十六进制颜色 + if (color.startsWith("#")) { + return color + } + + // 默认返回黑色 + return "#000000" +} + +export function ColorPicker({ + value = "", + onChange, + placeholder = "选择颜色...", + showPreview = true +}: ColorPickerProps) { + const [open, setOpen] = React.useState(false) + const [customColor, setCustomColor] = React.useState("") + + // 当value变化时更新customColor + React.useEffect(() => { + if (value.startsWith("#")) { + setCustomColor(value) + } else { + setCustomColor("") + } + }, [value]) + + const handleColorChange = (color: string) => { + onChange(color) + } + + const handleCustomColorChange = (e: React.ChangeEvent) => { + const color = e.target.value + setCustomColor(color) + // 验证十六进制颜色格式 + if (/^#[0-9A-F]{6}$/i.test(color)) { + handleColorChange(color) + } + } + + const handlePresetColorClick = (colorName: string) => { + handleColorChange(colorName) + setOpen(false) + } + + const selectedColorValue = getColorValue(value) + + // 获取ANSI颜色名称(如果适用) + const ansiColorName = Object.keys(ALL_COLOR_MAP).find(key => ALL_COLOR_MAP[key] === selectedColorValue) || value + + return ( +
    + + + + + +
    + {/* 颜色选择器标题 */} +
    +

    颜色选择器

    + +
    + + {/* 颜色预览 */} +
    +
    +
    +
    + {value ? ansiColorName : "未选择颜色"} +
    + {value && value.startsWith("#") && ( +
    + {value.toUpperCase()} +
    + )} +
    +
    + + {/* 颜色选择器 */} +
    + +
    + + {/* 自定义颜色输入 */} +
    + +
    + + +
    +

    + 输入十六进制颜色值 (例如: #FF0000) +

    +
    + + {/* 预定义颜色选项 */} +
    +
    + + 文字颜色 +
    +
    + {Object.entries(ANSI_COLOR_MAP).map(([name, color]) => ( + + ))} +
    +
    + + {/* 背景颜色选项 */} +
    +
    + + 背景色 +
    +
    + {Object.entries(ANSI_BG_COLOR_MAP).map(([name, color]) => ( + + ))} +
    +
    +
    + + +
    + ) +} diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index 2994a45..faa78b7 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -115,5 +115,55 @@ "cancel": "Cancel", "save_failed": "Failed to save config", "save_and_restart": "Save & Restart" + }, + "statusline": { + "title": "Status Line Configuration", + "enable": "Enable Status Line", + "theme": "Theme Style", + "theme_default": "Default", + "theme_powerline": "Powerline", + "modules": "Modules", + "module_type": "Type", + "module_icon": "Icon", + "module_text": "Text", + "module_color": "Color", + "module_background": "Background", + "add_module": "Add Module", + "remove_module": "Remove Module", + "preview": "Preview", + "workDir": "Working Directory", + "gitBranch": "Git Branch", + "model": "Model", + "usage": "Usage", + "background_none": "None", + "color_black": "Black", + "color_red": "Red", + "color_green": "Green", + "color_yellow": "Yellow", + "color_blue": "Blue", + "color_magenta": "Magenta", + "color_cyan": "Cyan", + "color_white": "White", + "color_bright_black": "Bright Black", + "color_bright_red": "Bright Red", + "color_bright_green": "Bright Green", + "color_bright_yellow": "Bright Yellow", + "color_bright_blue": "Bright Blue", + "color_bright_magenta": "Bright Magenta", + "color_bright_cyan": "Bright Cyan", + "color_bright_white": "Bright White", + "import_export": "Import/Export", + "import": "Import Config", + "export": "Export Config", + "download_template": "Download Template", + "import_export_help": "Export current configuration as a JSON file, or import configuration from a JSON file. You can also download a configuration template for reference.", + "export_success": "Configuration exported successfully", + "export_failed": "Failed to export configuration", + "import_success": "Configuration imported successfully", + "import_failed": "Failed to import configuration", + "invalid_config": "Invalid configuration file", + "template_download_success": "Template downloaded successfully", + "template_download_success_desc": "Configuration template has been downloaded to your device", + "template_download_failed": "Failed to download template" } } diff --git a/ui/src/locales/zh.json b/ui/src/locales/zh.json index be625ed..367f482 100644 --- a/ui/src/locales/zh.json +++ b/ui/src/locales/zh.json @@ -115,5 +115,55 @@ "cancel": "取消", "save_failed": "配置保存失败", "save_and_restart": "保存并重启" + }, + "statusline": { + "title": "状态栏配置", + "enable": "启用状态栏", + "theme": "主题样式", + "theme_default": "默认", + "theme_powerline": "Powerline", + "modules": "模块", + "module_type": "类型", + "module_icon": "图标", + "module_text": "文本", + "module_color": "颜色", + "module_background": "背景", + "add_module": "添加模块", + "remove_module": "移除模块", + "preview": "预览", + "workDir": "工作目录", + "gitBranch": "Git分支", + "model": "模型", + "usage": "使用情况", + "background_none": "无", + "color_black": "黑色", + "color_red": "红色", + "color_green": "绿色", + "color_yellow": "黄色", + "color_blue": "蓝色", + "color_magenta": "品红", + "color_cyan": "青色", + "color_white": "白色", + "color_bright_black": "亮黑色", + "color_bright_red": "亮红色", + "color_bright_green": "亮绿色", + "color_bright_yellow": "亮黄色", + "color_bright_blue": "亮蓝色", + "color_bright_magenta": "亮品红", + "color_bright_cyan": "亮青色", + "color_bright_white": "亮白色", + "import_export": "导入/导出", + "import": "导入配置", + "export": "导出配置", + "download_template": "下载模板", + "import_export_help": "导出当前配置为JSON文件,或从JSON文件导入配置。您也可以下载配置模板作为参考。", + "export_success": "配置导出成功", + "export_failed": "配置导出失败", + "import_success": "配置导入成功", + "import_failed": "配置导入失败", + "invalid_config": "无效的配置文件", + "template_download_success": "模板下载成功", + "template_download_success_desc": "配置模板已下载到您的设备", + "template_download_failed": "模板下载失败" } } diff --git a/ui/src/types.ts b/ui/src/types.ts index b3cf608..af60b45 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -27,10 +27,30 @@ export interface Transformer { options?: Record; } +export interface StatusLineModuleConfig { + type: string; + icon?: string; + text: string; + color?: string; + background?: string; +} + +export interface StatusLineThemeConfig { + modules: StatusLineModuleConfig[]; +} + +export interface StatusLineConfig { + enabled: boolean; + currentStyle: string; + default: StatusLineThemeConfig; + powerline: StatusLineThemeConfig; +} + export interface Config { Providers: Provider[]; Router: RouterConfig; transformers: Transformer[]; + StatusLine?: StatusLineConfig; // Top-level settings LOG: boolean; LOG_LEVEL: string; diff --git a/ui/src/utils/statusline.ts b/ui/src/utils/statusline.ts new file mode 100644 index 0000000..976e42c --- /dev/null +++ b/ui/src/utils/statusline.ts @@ -0,0 +1,146 @@ +import type { StatusLineConfig, StatusLineModuleConfig } from "@/types"; + +// 验证结果(保留接口但不使用) +export interface ValidationResult { + isValid: boolean; + errors: any[]; +} + +/** + * 验证StatusLine配置 - 已移除所有验证 + * @param config 要验证的配置对象 + * @returns 始终返回验证通过 + */ +export function validateStatusLineConfig(config: unknown): ValidationResult { + // 不再执行任何验证 + return { isValid: true, errors: [] }; +} + + +/** + * 格式化错误信息(支持国际化)- 不再使用 + */ +export function formatValidationError(error: unknown, t: (key: string, options?: Record) => string): string { + return t("statusline.validation.unknown_error"); +} + +/** + * 解析颜色值,支持十六进制和内置颜色名称 + * @param color 颜色值(可以是颜色名称或十六进制值) + * @param defaultColor 默认颜色(十六进制) + * @returns 十六进制颜色值 + */ +export function parseColorValue(color: string | undefined, defaultColor: string = "#ffffff"): string { + if (!color) { + return defaultColor; + } + + // 如果是十六进制颜色值(以#开头) + if (color.startsWith('#')) { + return color; + } + + // 如果是已知的颜色名称,返回对应的十六进制值 + return COLOR_HEX_MAP[color] || defaultColor; +} + +/** + * 判断是否为有效的十六进制颜色值 + * @param color 要检查的颜色值 + * @returns 是否为有效的十六进制颜色值 + */ +export function isHexColor(color: string): boolean { + return /^#([0-9A-F]{3}){1,2}$/i.test(color); +} + +// 颜色枚举到十六进制的映射 +export const COLOR_HEX_MAP: Record = { + black: "#000000", + red: "#cd0000", + green: "#00cd00", + yellow: "#cdcd00", + blue: "#0000ee", + magenta: "#cd00cd", + cyan: "#00cdcd", + white: "#e5e5e5", + bright_black: "#7f7f7f", + bright_red: "#ff0000", + bright_green: "#00ff00", + bright_yellow: "#ffff00", + bright_blue: "#5c5cff", + bright_magenta: "#ff00ff", + bright_cyan: "#00ffff", + bright_white: "#ffffff", + bg_black: "#000000", + bg_red: "#cd0000", + bg_green: "#00cd00", + bg_yellow: "#cdcd00", + bg_blue: "#0000ee", + bg_magenta: "#cd00cd", + bg_cyan: "#00cdcd", + bg_white: "#e5e5e5", + bg_bright_black: "#7f7f7f", + bg_bright_red: "#ff0000", + bg_bright_green: "#00ff00", + bg_bright_yellow: "#ffff00", + bg_bright_blue: "#5c5cff", + bg_bright_magenta: "#ff00ff", + bg_bright_cyan: "#00ffff", + bg_bright_white: "#ffffff" +}; + +/** + * 创建默认的StatusLine配置 + */ +export function createDefaultStatusLineConfig(): StatusLineConfig { + return { + enabled: false, + currentStyle: "default", + default: { + modules: [ + { type: "workDir", icon: "󰉋", text: "{{workDirName}}", color: "bright_blue" }, + { type: "gitBranch", icon: "", text: "{{gitBranch}}", color: "bright_magenta" }, + { type: "model", icon: "󰚩", text: "{{model}}", color: "bright_cyan" }, + { type: "usage", icon: "↑", text: "{{inputTokens}}", color: "bright_green" }, + { type: "usage", icon: "↓", text: "{{outputTokens}}", color: "bright_yellow" } + ] + }, + powerline: { + modules: [ + { type: "workDir", icon: "󰉋", text: "{{workDirName}}", color: "white", background: "bg_bright_blue" }, + { type: "gitBranch", icon: "", text: "{{gitBranch}}", color: "white", background: "bg_bright_magenta" }, + { type: "model", icon: "󰚩", text: "{{model}}", color: "white", background: "bg_bright_cyan" }, + { type: "usage", icon: "↑", text: "{{inputTokens}}", color: "white", background: "bg_bright_green" }, + { type: "usage", icon: "↓", text: "{{outputTokens}}", color: "white", background: "bg_bright_yellow" } + ] + } + }; +} + +/** + * 创建配置备份 + */ +export function backupConfig(config: StatusLineConfig): string { + const backup = { + config, + timestamp: new Date().toISOString(), + version: "1.0" + }; + return JSON.stringify(backup, null, 2); +} + +/** + * 从备份恢复配置 + */ +export function restoreConfig(backupStr: string): StatusLineConfig | null { + try { + const backup = JSON.parse(backupStr); + if (backup && backup.config && backup.timestamp) { + return backup.config as StatusLineConfig; + } + return null; + } catch (error) { + console.error("Failed to restore config from backup:", error); + return null; + } +} diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index 8a65f06..66bfd00 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/ConfigProvider.tsx","./src/components/JsonEditor.tsx","./src/components/Login.tsx","./src/components/ProtectedRoute.tsx","./src/components/ProviderList.tsx","./src/components/Providers.tsx","./src/components/PublicRoute.tsx","./src/components/Router.tsx","./src/components/SettingsDialog.tsx","./src/components/TransformerList.tsx","./src/components/Transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/utils.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/jsoneditor.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/statuslineconfigdialog.tsx","./src/components/statuslineimportexport.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/color-picker.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/statuslinetestpage.tsx","./src/utils/statusline.ts"],"version":"5.8.3"}