diff --git a/README.md b/README.md index b01ce35..2303365 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) and [Codex](https://developers.openai.com/codex). You can use it locally or remotely to view your active projects and sessions in Claude Code, Cursor, or Codex and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere. + [English](./README.md) | [中文](./README.zh-CN.md) + ## Screenshots
diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..b62cd21 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,371 @@ +
+ Claude Code UI +

Cloud CLI (又名 Claude Code UI)

+
+ + +[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview) 和 [Codex](https://developers.openai.com/codex) 的桌面端和移动端界面。您可以在本地或远程使用它来查看 Claude Code、Cursor 或 Codex 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。 + + [English](./README.md) | [中文](./README.zh-CN.md) + +## 截图 + +
+ + + + + + + + + +
+

桌面视图

+Desktop Interface +
+显示项目概览和聊天界面的主界面 +
+

移动端体验

+Mobile Interface +
+具有触摸导航的响应式移动设计 +
+

CLI 选择

+CLI Selection +
+在 Claude Code、Cursor CLI 和 Codex 之间选择 +
+ + + +
+ +## 功能特性 + +- **响应式设计** - 在桌面、平板和移动设备上无缝运行,您也可以在移动端使用 Claude Code、Cursor 或 Codex +- **交互式聊天界面** - 内置聊天界面,与 Claude Code、Cursor 或 Codex 无缝通信 +- **集成 Shell 终端** - 通过内置 shell 功能直接访问 Claude Code、Cursor CLI 或 Codex +- **文件浏览器** - 交互式文件树,支持语法高亮和实时编辑 +- **Git 浏览器** - 查看、暂存和提交您的更改。您还可以切换分支 +- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录 +- **TaskMaster AI 集成** *(可选)* - 通过 AI 驱动的任务规划、PRD 解析和工作流自动化实现高级项目管理 +- **模型兼容性** - 适用于 Claude Sonnet 4.5、Opus 4.5 和 GPT-5.2 + + +## 快速开始 + +### 前置要求 + +- [Node.js](https://nodejs.org/) v20 或更高版本 +- 已安装并配置 [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code),和/或 +- 已安装并配置 [Cursor CLI](https://docs.cursor.com/en/cli/overview),和/或 +- 已安装并配置 [Codex](https://developers.openai.com/codex) + +### 一键操作(推荐) + +无需安装,直接运行: + +```bash +npx @siteboon/claude-code-ui +``` + +服务器将启动并可通过 `http://localhost:3001`(或您配置的 PORT)访问。 + +**重启**: 停止服务器后只需再次运行相同的 `npx` 命令 + +### 全局安装(供常规使用) + +为了频繁使用,一次性全局安装: + +```bash +npm install -g @siteboon/claude-code-ui +``` + +然后使用简单命令启动: + +```bash +claude-code-ui +``` + + +**重启**: 使用 Ctrl+C 停止,然后再次运行 `claude-code-ui`。 + +**更新**: +```bash +cloudcli update +``` + +### CLI 使用方法 + +全局安装后,您可以访问 `claude-code-ui` 和 `cloudcli` 命令: + +| 命令 / 选项 | 简写 | 描述 | +|------------------|-------|-------------| +| `cloudcli` 或 `claude-code-ui` | | 启动服务器(默认) | +| `cloudcli start` | | 显式启动服务器 | +| `cloudcli status` | | 显示配置和数据位置 | +| `cloudcli update` | | 更新到最新版本 | +| `cloudcli help` | | 显示帮助信息 | +| `cloudcli version` | | 显示版本信息 | +| `--port ` | `-p` | 设置服务器端口(默认: 3001) | +| `--database-path ` | | 设置自定义数据库位置 | + +**示例:** +```bash +cloudcli # 使用默认设置启动 +cloudcli -p 8080 # 在自定义端口启动 +cloudcli status # 显示当前配置 +``` + +### 作为后台服务运行(推荐用于生产环境) + +在生产环境中,使用 PM2(Process Manager 2)将 Claude Code UI 作为后台服务运行: + +#### 安装 PM2 + +```bash +npm install -g pm2 +``` + +#### 作为后台服务启动 + +```bash +# 在后台启动服务器 +pm2 start claude-code-ui --name "claude-code-ui" + +# 或使用更短的别名 +pm2 start cloudcli --name "claude-code-ui" + +# 在自定义端口启动 +pm2 start cloudcli --name "claude-code-ui" -- --port 8080 +``` + + +#### 系统启动时自动启动 + +要使 Claude Code UI 在系统启动时自动启动: + +```bash +# 为您的平台生成启动脚本 +pm2 startup + +# 保存当前进程列表 +pm2 save +``` + + +### 本地开发安装 + +1. **克隆仓库:** +```bash +git clone https://github.com/siteboon/claudecodeui.git +cd claudecodeui +``` + +2. **安装依赖:** +```bash +npm install +``` + +3. **配置环境:** +```bash +cp .env.example .env +# 使用您喜欢的设置编辑 .env +``` + +4. **启动应用程序:** +```bash +# 开发模式(支持热重载) +npm run dev + +``` +应用程序将在您在 .env 中指定的端口启动 + +5. **打开浏览器:** + - 开发环境: `http://localhost:3001` + +## 安全与工具配置 + +**🔒 重要提示**: 所有 Claude Code 工具**默认禁用**。这可以防止潜在的有害操作自动运行。 + +### 启用工具 + +要使用 Claude Code 的完整功能,您需要手动启用工具: + +1. **打开工具设置** - 点击侧边栏中的齿轮图标 +3. **选择性启用** - 仅打开您需要的工具 +4. **应用设置** - 您的偏好设置将保存在本地 + +
+ +![工具设置模态框](public/screenshots/tools-modal.png) +*工具设置界面 - 仅启用您需要的内容* + +
+ +**推荐方法**: 首先启用基本工具,然后根据需要添加更多。您可以随时调整这些设置。 + +## TaskMaster AI 集成 *(可选)* + +Claude Code UI 支持 **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)**(aka claude-task-master)集成,用于高级项目管理和 AI 驱动的任务规划。 + +它提供 +- 从 PRD(产品需求文档)生成 AI 驱动的任务 +- 智能任务分解和依赖管理 +- 可视化任务板和进度跟踪 + +**设置与文档**: 访问 [TaskMaster AI GitHub 仓库](https://github.com/eyaltoledano/claude-task-master)获取安装说明、配置指南和使用示例。 +安装后,您应该能够从设置中启用它 + + +## 使用指南 + +### 核心功能 + +#### 项目管理 +当可用时,它会自动发现 Claude Code、Cursor 或 Codex 会话并将它们分组到项目中 +- **项目操作** - 重命名、删除和组织项目 +- **智能导航** - 快速访问最近的项目和会话 +- **MCP 支持** - 通过 UI 添加您自己的 MCP 服务器 + +#### 聊天界面 +- **使用响应式聊天或 Claude Code/Cursor CLI/Codex CLI** - 您可以使用自适应聊天界面或使用 shell 按钮连接到您选择的 CLI +- **实时通信** - 通过 WebSocket 连接从您选择的 CLI(Claude Code/Cursor/Codex)流式传输响应 +- **会话管理** - 恢复之前的对话或启动新会话 +- **消息历史** - 带有时间戳和元数据的完整对话历史 +- **多格式支持** - 文本、代码块和文件引用 + +#### 文件浏览器与编辑器 +- **交互式文件树** - 使用展开/折叠导航浏览项目结构 +- **实时文件编辑** - 直接在界面中读取、修改和保存文件 +- **语法高亮** - 支持多种编程语言 +- **文件操作** - 创建、重命名、删除文件和目录 + +#### Git 浏览器 + + +#### TaskMaster AI 集成 *(可选)* +- **可视化任务板** - 用于管理开发任务的看板风格界面 +- **PRD 解析器** - 创建产品需求文档并将其解析为结构化任务 +- **进度跟踪** - 实时状态更新和完成跟踪 + +#### 会话管理 +- **会话持久化** - 所有对话自动保存 +- **会话组织** - 按项目和 timestamp 分组会话 +- **会话操作** - 重命名、删除和导出对话历史 +- **跨设备同步** - 从任何设备访问会话 + +### 移动应用 +- **响应式设计** - 针对所有屏幕尺寸进行优化 +- **触摸友好界面** - 滑动手势和触摸导航 +- **移动导航** - 底部选项卡栏,方便拇指导航 +- **自适应布局** - 可折叠侧边栏和智能内容优先级 +- **添加到主屏幕快捷方式** - 添加快捷方式到主屏幕,应用程序将像 PWA 一样运行 + +## 架构 + +### 系统概览 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Frontend │ │ Backend │ │ Agent │ +│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │ +│ │ │ │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 后端 (Node.js + Express) +- **Express 服务器** - 具有静态文件服务的 RESTful API +- **WebSocket 服务器** - 用于聊天和项目刷新的通信 +- **Agent 集成 (Claude Code / Cursor CLI / Codex)** - 进程生成和管理 +- **文件系统 API** - 为项目公开文件浏览器 + +### 前端 (React + Vite) +- **React 18** - 带有 hooks 的现代组件架构 +- **CodeMirror** - 具有语法高亮的高级代码编辑器 + + + + +### 贡献 + +我们欢迎贡献!请遵循以下指南: + +#### 入门 +1. **Fork** 仓库 +2. **克隆** 您的 fork: `git clone ` +3. **安装** 依赖: `npm install` +4. **创建** 特性分支: `git checkout -b feature/amazing-feature` + +#### 开发流程 +1. **进行更改**,遵循现有代码风格 +2. **彻底测试** - 确保所有功能正常工作 +3. **运行质量检查**: `npm run lint && npm run format` +4. **提交**,遵循 [Conventional Commits](https://conventionalcommits.org/)的描述性消息 +5. **推送** 到您的分支: `git push origin feature/amazing-feature` +6. **提交** 拉取请求,包括: + - 更改的清晰描述 + - UI 更改的截图 + - 适用时的测试结果 + +#### 贡献内容 +- **错误修复** - 帮助我们提高稳定性 +- **新功能** - 增强功能(先在 issue 中讨论) +- **文档** - 改进指南和 API 文档 +- **UI/UX 改进** - 更好的用户体验 +- **性能优化** - 让它更快 + +## 故障排除 + +### 常见问题与解决方案 + + +#### "未找到 Claude 项目" +**问题**: UI 显示没有项目或项目列表为空 +**解决方案**: +- 确保已正确安装 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) +- 至少在一个项目目录中运行 `claude` 命令以进行初始化 +- 验证 `~/.claude/projects/` 目录存在并具有适当的权限 + +#### 文件浏览器问题 +**问题**: 文件无法加载、权限错误、空目录 +**解决方案**: +- 检查项目目录权限(在终端中使用 `ls -la`) +- 验证项目路径存在且可访问 +- 查看服务器控制台日志以获取详细错误消息 +- 确保您未尝试访问项目范围之外的系统目录 + + +## 许可证 + +GNU General Public License v3.0 - 详见 [LICENSE](LICENSE) 文件。 + +本项目是开源的,在 GPL v3 许可下可自由使用、修改和分发。 + +## 致谢 + +### 构建工具 +- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 的官方 CLI +- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 的官方 CLI +- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex +- **[React](https://react.dev/)** - 用户界面库 +- **[Vite](https://vitejs.dev/)** - 快速构建工具和开发服务器 +- **[Tailwind CSS](https://tailwindcss.com/)** - 实用优先的 CSS 框架 +- **[CodeMirror](https://codemirror.net/)** - 高级代码编辑器 +- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(可选)* - AI 驱动的项目管理和任务规划 + +## 支持与社区 + +### 保持更新 +- **Star** 此仓库以表示支持 +- **Watch** 以获取更新和新版本 +- **Follow** 项目以获取公告 + +### 赞助商 +- [Siteboon - AI powered website builder](https://siteboon.ai) +--- + +
+ 为 Claude Code、Cursor 和 Codex 社区精心打造。 +
\ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b7ab7c0..109cf7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,8 @@ "express": "^4.18.2", "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", + "i18next": "^25.7.4", + "i18next-browser-languagedetector": "^8.2.0", "jsonwebtoken": "^9.0.2", "katex": "^0.16.25", "lucide-react": "^0.515.0", @@ -49,8 +51,10 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", + "react-i18next": "^16.5.3", "react-markdown": "^10.1.0", "react-router-dom": "^6.8.1", + "react-syntax-highlighter": "^15.6.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", @@ -359,9 +363,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -5057,6 +5061,19 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/file-selector": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", @@ -5136,6 +5153,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5756,6 +5781,30 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -5837,6 +5886,46 @@ "ms": "^2.0.0" } }, + "node_modules/i18next": { + "version": "25.7.4", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.7.4.tgz", + "integrity": "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -6569,6 +6658,20 @@ "loose-envify": "cli.js" } }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8101,9 +8204,9 @@ } }, "node_modules/node-pty": { - "version": "1.1.0-beta9", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta9.tgz", - "integrity": "sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -8840,6 +8943,15 @@ "node": ">=10" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/proc-log": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", @@ -9086,6 +9198,33 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-i18next": { + "version": "16.5.3", + "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-16.5.3.tgz", + "integrity": "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -9161,6 +9300,23 @@ "react-dom": ">=16.8" } }, + "node_modules/react-syntax-highlighter": { + "version": "15.6.6", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", + "integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^3.6.0" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -9197,6 +9353,197 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/refractor": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", + "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", + "license": "MIT", + "dependencies": { + "hastscript": "^6.0.0", + "parse-entities": "^2.0.0", + "prismjs": "~1.27.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/refractor/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/refractor/node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/hast-util-parse-selector": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", + "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/refractor/node_modules/hastscript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", + "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/refractor/node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/prismjs": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", + "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/refractor/node_modules/property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/rehype-katex": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", @@ -11536,6 +11883,15 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -11708,6 +12064,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/package.json b/package.json index 81c8ded..4cb1f1a 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,8 @@ "express": "^4.18.2", "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", + "i18next": "^25.7.4", + "i18next-browser-languagedetector": "^8.2.0", "jsonwebtoken": "^9.0.2", "katex": "^0.16.25", "lucide-react": "^0.515.0", @@ -81,8 +83,10 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", + "react-i18next": "^16.5.3", "react-markdown": "^10.1.0", "react-router-dom": "^6.8.1", + "react-syntax-highlighter": "^15.6.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", diff --git a/server/index.js b/server/index.js index 25b25fd..2f19dcd 100755 --- a/server/index.js +++ b/server/index.js @@ -80,6 +80,20 @@ import { validateApiKey, authenticateToken, authenticateWebSocket } from './midd // File system watcher for projects folder let projectsWatcher = null; const connectedClients = new Set(); +let isGetProjectsRunning = false; // Flag to prevent reentrant calls + +// Broadcast progress to all connected WebSocket clients +function broadcastProgress(progress) { + const message = JSON.stringify({ + type: 'loading_progress', + ...progress + }); + connectedClients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); +} // Setup file system watcher for Claude projects folder using chokidar async function setupProjectsWatcher() { @@ -117,13 +131,19 @@ async function setupProjectsWatcher() { const debouncedUpdate = async (eventType, filePath) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { + // Prevent reentrant calls + if (isGetProjectsRunning) { + return; + } + try { + isGetProjectsRunning = true; // Clear project directory cache when files change clearProjectDirectoryCache(); // Get updated projects list - const updatedProjects = await getProjects(); + const updatedProjects = await getProjects(broadcastProgress); // Notify all connected clients about the project changes const updateMessage = JSON.stringify({ @@ -142,6 +162,8 @@ async function setupProjectsWatcher() { } catch (error) { console.error('[ERROR] Error handling project changes:', error); + } finally { + isGetProjectsRunning = false; } }, 300); // 300ms debounce (slightly faster than before) }; @@ -366,7 +388,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => { app.get('/api/projects', authenticateToken, async (req, res) => { try { - const projects = await getProjects(); + const projects = await getProjects(broadcastProgress); res.json(projects); } catch (error) { res.status(500).json({ error: error.message }); @@ -433,11 +455,12 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, } }); -// Delete project endpoint (only if empty) +// Delete project endpoint (force=true to delete with sessions) app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => { try { const { projectName } = req.params; - await deleteProject(projectName); + const force = req.query.force === 'true'; + await deleteProject(projectName, force); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); @@ -496,7 +519,13 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { name: item.name, type: 'directory' })) - .slice(0, 20); // Limit results + .sort((a, b) => { + const aHidden = a.name.startsWith('.'); + const bHidden = b.name.startsWith('.'); + if (aHidden && !bHidden) return 1; + if (!aHidden && bHidden) return -1; + return a.name.localeCompare(b.name); + }); // Add common directories if browsing home directory const suggestions = []; diff --git a/server/projects.js b/server/projects.js index ff7a8d1..b4606f8 100755 --- a/server/projects.js +++ b/server/projects.js @@ -379,22 +379,46 @@ async function extractProjectDirectory(projectName) { } } -async function getProjects() { +async function getProjects(progressCallback = null) { const claudeDir = path.join(os.homedir(), '.claude', 'projects'); const config = await loadProjectConfig(); const projects = []; const existingProjects = new Set(); - + let totalProjects = 0; + let processedProjects = 0; + let directories = []; + try { // Check if the .claude/projects directory exists await fs.access(claudeDir); - + // First, get existing Claude projects from the file system const entries = await fs.readdir(claudeDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - existingProjects.add(entry.name); + directories = entries.filter(e => e.isDirectory()); + + // Build set of existing project names for later + directories.forEach(e => existingProjects.add(e.name)); + + // Count manual projects not already in directories + const manualProjectsCount = Object.entries(config) + .filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name)) + .length; + + totalProjects = directories.length + manualProjectsCount; + + for (const entry of directories) { + processedProjects++; + + // Emit progress + if (progressCallback) { + progressCallback({ + phase: 'loading', + current: processedProjects, + total: totalProjects, + currentProject: entry.name + }); + } + const projectPath = path.join(claudeDir, entry.name); // Extract actual project directory from JSONL sessions @@ -460,20 +484,35 @@ async function getProjects() { status: 'error' }; } - - projects.push(project); - } + + projects.push(project); } } catch (error) { // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects if (error.code !== 'ENOENT') { console.error('Error reading projects directory:', error); } + // Calculate total for manual projects only (no directories exist) + totalProjects = Object.entries(config) + .filter(([name, cfg]) => cfg.manuallyAdded) + .length; } // Add manually configured projects that don't exist as folders yet for (const [projectName, projectConfig] of Object.entries(config)) { if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) { + processedProjects++; + + // Emit progress for manual projects + if (progressCallback) { + progressCallback({ + phase: 'loading', + current: processedProjects, + total: totalProjects, + currentProject: projectName + }); + } + // Use the original path if available, otherwise extract from potential sessions let actualProjectDir = projectConfig.originalPath; @@ -541,7 +580,16 @@ async function getProjects() { projects.push(project); } } - + + // Emit completion after all projects (including manual) are processed + if (progressCallback) { + progressCallback({ + phase: 'complete', + current: totalProjects, + total: totalProjects + }); + } + return projects; } @@ -978,25 +1026,56 @@ async function isProjectEmpty(projectName) { } } -// Delete an empty project -async function deleteProject(projectName) { +// Delete a project (force=true to delete even with sessions) +async function deleteProject(projectName, force = false) { const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); - + try { - // First check if the project is empty const isEmpty = await isProjectEmpty(projectName); - if (!isEmpty) { + if (!isEmpty && !force) { throw new Error('Cannot delete project with existing sessions'); } - - // Remove the project directory - await fs.rm(projectDir, { recursive: true, force: true }); - - // Remove from project config + const config = await loadProjectConfig(); + let projectPath = config[projectName]?.path || config[projectName]?.originalPath; + + // Fallback to extractProjectDirectory if projectPath is not in config + if (!projectPath) { + projectPath = await extractProjectDirectory(projectName); + } + + // Remove the project directory (includes all Claude sessions) + await fs.rm(projectDir, { recursive: true, force: true }); + + // Delete all Codex sessions associated with this project + if (projectPath) { + try { + const codexSessions = await getCodexSessions(projectPath, { limit: 0 }); + for (const session of codexSessions) { + try { + await deleteCodexSession(session.id); + } catch (err) { + console.warn(`Failed to delete Codex session ${session.id}:`, err.message); + } + } + } catch (err) { + console.warn('Failed to delete Codex sessions:', err.message); + } + + // Delete Cursor sessions directory if it exists + try { + const hash = crypto.createHash('md5').update(projectPath).digest('hex'); + const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash); + await fs.rm(cursorProjectDir, { recursive: true, force: true }); + } catch (err) { + // Cursor dir may not exist, ignore + } + } + + // Remove from project config delete config[projectName]; await saveProjectConfig(config); - + return true; } catch (error) { console.error(`Error deleting project ${projectName}:`, error); @@ -1007,17 +1086,17 @@ async function deleteProject(projectName) { // Add a project manually to the config (without creating folders) async function addProjectManually(projectPath, displayName = null) { const absolutePath = path.resolve(projectPath); - + try { // Check if the path exists await fs.access(absolutePath); } catch (error) { throw new Error(`Path does not exist: ${absolutePath}`); } - + // Generate project name (encode path for use as directory name) const projectName = absolutePath.replace(/\//g, '-'); - + // Check if project already exists in config const config = await loadProjectConfig(); const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); @@ -1028,13 +1107,13 @@ async function addProjectManually(projectPath, displayName = null) { // Allow adding projects even if the directory exists - this enables tracking // existing Claude Code or Cursor projects in the UI - + // Add to config as manually added project config[projectName] = { manuallyAdded: true, originalPath: absolutePath }; - + if (displayName) { config[projectName].displayName = displayName; } @@ -1166,7 +1245,8 @@ async function getCursorSessions(projectPath) { // Fetch Codex sessions for a given project path -async function getCodexSessions(projectPath) { +async function getCodexSessions(projectPath, options = {}) { + const { limit = 5 } = options; try { const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); const sessions = []; @@ -1231,8 +1311,8 @@ async function getCodexSessions(projectPath) { // Sort sessions by last activity (newest first) sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); - // Return only the first 5 sessions for performance - return sessions.slice(0, 5); + // Return limited sessions for performance (0 = unlimited for deletion) + return limit > 0 ? sessions.slice(0, limit) : sessions; } catch (error) { console.error('Error fetching Codex sessions:', error); diff --git a/src/App.jsx b/src/App.jsx index ed0ef58..371d490 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,7 +18,7 @@ * Handles both existing sessions (with real IDs) and new sessions (with temporary IDs). */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom'; import { Settings as SettingsIcon, Sparkles } from 'lucide-react'; import Sidebar from './components/Sidebar'; @@ -36,12 +36,15 @@ import ProtectedRoute from './components/ProtectedRoute'; import { useVersionCheck } from './hooks/useVersionCheck'; import useLocalStorage from './hooks/useLocalStorage'; import { api, authenticatedFetch } from './utils/api'; +import { I18nextProvider, useTranslation } from 'react-i18next'; +import i18n from './i18n/config.js'; // Main App component with routing function AppContent() { const navigate = useNavigate(); const { sessionId } = useParams(); + const { t } = useTranslation('common'); const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui'); const [showVersionModal, setShowVersionModal] = useState(false); @@ -53,6 +56,7 @@ function AppContent() { const [isMobile, setIsMobile] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [isLoadingProjects, setIsLoadingProjects] = useState(true); + const [loadingProgress, setLoadingProgress] = useState(null); // { phase, current, total, currentProject } const [isInputFocused, setIsInputFocused] = useState(false); const [showSettings, setShowSettings] = useState(false); const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); @@ -78,7 +82,10 @@ function AppContent() { const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); const { ws, sendMessage, messages } = useWebSocketContext(); - + + // Ref to track loading progress timeout for cleanup + const loadingProgressTimeoutRef = useRef(null); + // Detect if running as PWA const [isPWA, setIsPWA] = useState(false); @@ -170,7 +177,23 @@ function AppContent() { useEffect(() => { if (messages.length > 0) { const latestMessage = messages[messages.length - 1]; - + + // Handle loading progress updates + if (latestMessage.type === 'loading_progress') { + if (loadingProgressTimeoutRef.current) { + clearTimeout(loadingProgressTimeoutRef.current); + loadingProgressTimeoutRef.current = null; + } + setLoadingProgress(latestMessage); + if (latestMessage.phase === 'complete') { + loadingProgressTimeoutRef.current = setTimeout(() => { + setLoadingProgress(null); + loadingProgressTimeoutRef.current = null; + }, 500); + } + return; + } + if (latestMessage.type === 'projects_updated') { // External Session Update Detection: Check if the changed file is the current session's JSONL @@ -247,6 +270,13 @@ function AppContent() { } } } + + return () => { + if (loadingProgressTimeoutRef.current) { + clearTimeout(loadingProgressTimeoutRef.current); + loadingProgressTimeoutRef.current = null; + } + }; }, [messages, selectedProject, selectedSession, activeSessions]); const fetchProjects = async () => { @@ -550,6 +580,7 @@ function AppContent() { // Version Upgrade Modal Component const VersionUpgradeModal = () => { + const { t } = useTranslation('common'); const [isUpdating, setIsUpdating] = useState(false); const [updateOutput, setUpdateOutput] = useState(''); const [updateError, setUpdateError] = useState(''); @@ -610,7 +641,7 @@ function AppContent() {
-

Update Available

+

{t('versionUpdate.title')}

- {releaseInfo?.title || 'A new version is ready'} + {releaseInfo?.title || t('versionUpdate.newVersionReady')}

@@ -643,11 +674,11 @@ function AppContent() { {/* Version Info */}
- Current Version + {t('versionUpdate.currentVersion')} {currentVersion}
- Latest Version + {t('versionUpdate.latestVersion')} {latestVersion}
@@ -656,7 +687,7 @@ function AppContent() { {releaseInfo?.body && (
-

What's New:

+

{t('versionUpdate.whatsNew')}

{releaseInfo?.htmlUrl && ( - View full release + {t('versionUpdate.viewFullRelease')} @@ -682,7 +713,7 @@ function AppContent() { {/* Update Output */} {updateOutput && (
-

Update Progress:

+

{t('versionUpdate.updateProgress')}

{updateOutput}
@@ -692,14 +723,14 @@ function AppContent() { {/* Upgrade Instructions */} {!isUpdating && !updateOutput && (
-

Manual upgrade:

+

{t('versionUpdate.manualUpgrade')}

git checkout main && git pull && npm install

- Or click "Update Now" to run the update automatically. + {t('versionUpdate.manualUpgradeHint')}

)} @@ -710,7 +741,7 @@ function AppContent() { onClick={() => setShowVersionModal(false)} className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors" > - {updateOutput ? 'Close' : 'Later'} + {updateOutput ? t('versionUpdate.buttons.close') : t('versionUpdate.buttons.later')} {!updateOutput && ( <> @@ -720,7 +751,7 @@ function AppContent() { }} className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors" > - Copy Command + {t('versionUpdate.buttons.copyCommand')} @@ -765,6 +796,7 @@ function AppContent() { onSessionDelete={handleSessionDelete} onProjectDelete={handleProjectDelete} isLoading={isLoadingProjects} + loadingProgress={loadingProgress} onRefresh={handleSidebarRefresh} onShowSettings={() => setShowSettings(true)} updateAvailable={updateAvailable} @@ -783,8 +815,8 @@ function AppContent() { @@ -811,8 +843,8 @@ function AppContent() {
)} @@ -184,33 +186,33 @@ function ApiKeysSettings() {
-

API Keys

+

{t('apiKeys.title')}

- Generate API keys to access the external API from other applications. + {t('apiKeys.description')}

{showNewKeyForm && (
setNewKeyName(e.target.value)} className="mb-2" />
- +
@@ -218,7 +220,7 @@ function ApiKeysSettings() {
{apiKeys.length === 0 ? ( -

No API keys created yet.

+

{t('apiKeys.empty')}

) : ( apiKeys.map((key) => (
{key.key_name}
{key.api_key}
- Created: {new Date(key.created_at).toLocaleDateString()} - {key.last_used && ` • Last used: ${new Date(key.last_used).toLocaleDateString()}`} + {t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()} + {key.last_used && ` • ${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
@@ -239,7 +241,7 @@ function ApiKeysSettings() { variant={key.is_active ? 'outline' : 'secondary'} onClick={() => toggleApiKey(key.id, key.is_active)} > - {key.is_active ? 'Active' : 'Inactive'} + {key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}

- Add GitHub Personal Access Tokens to clone private repositories via the external API. + {t('apiKeys.github.description')}

{showNewTokenForm && (
setNewTokenName(e.target.value)} className="mb-2" @@ -286,7 +288,7 @@ function ApiKeysSettings() {
setNewGithubToken(e.target.value)} className="mb-2 pr-10" @@ -300,13 +302,13 @@ function ApiKeysSettings() {
- +
@@ -314,7 +316,7 @@ function ApiKeysSettings() {
{githubTokens.length === 0 ? ( -

No GitHub tokens added yet.

+

{t('apiKeys.github.empty')}

) : ( githubTokens.map((token) => (
{token.credential_name}
- Added: {new Date(token.created_at).toLocaleDateString()} + {t('apiKeys.github.added')} {new Date(token.created_at).toLocaleDateString()}
@@ -333,7 +335,7 @@ function ApiKeysSettings() { variant={token.is_active ? 'outline' : 'secondary'} onClick={() => toggleGithubToken(token.id, token.is_active)} > - {token.is_active ? 'Active' : 'Inactive'} + {token.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 909d718..aeb8705 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -21,6 +21,8 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { useDropzone } from 'react-dropzone'; import TodoList from './TodoList'; import ClaudeLogo from './ClaudeLogo.jsx'; @@ -28,11 +30,13 @@ import CursorLogo from './CursorLogo.jsx'; import CodexLogo from './CodexLogo.jsx'; import NextTaskBanner from './NextTaskBanner.jsx'; import { useTasksSettings } from '../contexts/TasksSettingsContext'; +import { useTranslation } from 'react-i18next'; import ClaudeStatus from './ClaudeStatus'; import TokenUsagePie from './TokenUsagePie'; import { MicButton } from './MicButton.jsx'; import { api, authenticatedFetch } from '../utils/api'; +import ThinkingModeSelector, { thinkingModes } from './ThinkingModeSelector.jsx'; import Fuse from 'fuse.js'; import CommandMenu from './CommandMenu'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants'; @@ -338,25 +342,31 @@ function grantClaudeToolPermission(entry) { } // Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) -const markdownComponents = { - code: ({ node, inline, className, children, ...props }) => { - const [copied, setCopied] = React.useState(false); - const raw = Array.isArray(children) ? children.join('') : String(children ?? ''); - const looksMultiline = /[\r\n]/.test(raw); - const inlineDetected = inline || (node && node.type === 'inlineCode'); - const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line - if (shouldInline) { - return ( - - {children} - - ); - } +const CodeBlock = ({ node, inline, className, children, ...props }) => { + const { t } = useTranslation('chat'); + const [copied, setCopied] = React.useState(false); + const raw = Array.isArray(children) ? children.join('') : String(children ?? ''); + const looksMultiline = /[\r\n]/.test(raw); + const inlineDetected = inline || (node && node.type === 'inlineCode'); + const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line + + // Inline code rendering + if (shouldInline) { + return ( + + {children} + + ); + } + + // Extract language from className (format: language-xxx) + const match = /language-(\w+)/.exec(className || ''); + const language = match ? match[1] : 'text'; const textToCopy = raw; const handleCopy = () => { @@ -392,21 +402,30 @@ const markdownComponents = { } catch {} }; + // Code block with syntax highlighting return (
+ {/* Language label */} + {language && language !== 'text' && ( +
+ {language} +
+ )} + + {/* Copy button */} -
-          
-            {children}
-          
-        
+ + {/* Syntax highlighted code */} + + {raw} +
); - }, + }; + +// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) +const markdownComponents = { + code: CodeBlock, blockquote: ({ children }) => (
{children} @@ -458,6 +495,7 @@ const markdownComponents = { // Memoized message component to prevent unnecessary re-renders const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => { + const { t } = useTranslation('chat'); const isGrouped = prevMessage && prevMessage.type === message.type && ((prevMessage.type === 'assistant') || (prevMessage.type === 'user') || @@ -560,7 +598,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
)}
- {message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? 'Codex' : 'Claude')} + {message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? t('messageTypes.cursor') : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))}
)} @@ -588,8 +626,8 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile const input = JSON.parse(message.toolInput); return ( - {input.pattern && pattern: {input.pattern}} - {input.path && in: {input.path}} + {input.pattern && {t('search.pattern')} {input.pattern}} + {input.path && {t('search.in')} {input.path}} ); } catch (e) { @@ -602,7 +640,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile href={`#tool-result-${message.toolId}`} className="flex-shrink-0 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1" > - results + {t('tools.searchResults')} @@ -646,7 +684,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile onShowSettings(); }} className="p-2 rounded-lg hover:bg-white/60 dark:hover:bg-gray-800/60 transition-all duration-200 group/btn backdrop-blur-sm" - title="Tool Settings" + title={t('tools.settings')} > @@ -1824,6 +1862,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => { // This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations. function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) { const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); + const { t } = useTranslation('chat'); const [input, setInput] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; @@ -1890,6 +1929,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const [slashPosition, setSlashPosition] = useState(-1); const [visibleMessageCount, setVisibleMessageCount] = useState(100); const [claudeStatus, setClaudeStatus] = useState(null); + const [thinkingMode, setThinkingMode] = useState('none'); const [provider, setProvider] = useState(() => { return localStorage.getItem('selected-provider') || 'claude'; }); @@ -4270,6 +4310,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess e.preventDefault(); if (!input.trim() || isLoading || !selectedProject) return; + // Apply thinking mode prefix if selected + let messageContent = input; + const selectedThinkingMode = thinkingModes.find(mode => mode.id === thinkingMode); + if (selectedThinkingMode && selectedThinkingMode.prefix) { + messageContent = `${selectedThinkingMode.prefix}: ${input}`; + } + // Upload images first if any let uploadedImages = []; if (attachedImages.length > 0) { @@ -4358,7 +4405,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Send Cursor command (always use cursor-command; include resume/sessionId when replying) sendMessage({ type: 'cursor-command', - command: input, + command: messageContent, sessionId: effectiveSessionId, options: { // Prefer fullPath (actual cwd for project), fallback to path @@ -4375,7 +4422,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Send Codex command sendMessage({ type: 'codex-command', - command: input, + command: messageContent, sessionId: effectiveSessionId, options: { cwd: selectedProject.fullPath || selectedProject.path, @@ -4390,7 +4437,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Send Claude command (existing code) sendMessage({ type: 'claude-command', - command: input, + command: messageContent, options: { projectPath: selectedProject.path, cwd: selectedProject.fullPath, @@ -4409,6 +4456,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess setUploadingImages(new Map()); setImageErrors(new Map()); setIsTextareaExpanded(false); + setThinkingMode('none'); // Reset thinking mode after sending // Reset textarea height if (textareaRef.current) { @@ -4419,7 +4467,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess if (selectedProject) { safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); } - }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]); + }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom, thinkingMode]); const handleGrantToolPermission = useCallback((suggestion) => { if (!suggestion || provider !== 'claude') { @@ -4792,16 +4840,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
-

Loading session messages...

+

{t('session.loading.sessionMessages')}

) : chatMessages.length === 0 ? (
{!selectedSession && !currentSessionId && (
-

Choose Your AI Assistant

+

{t('providerSelection.title')}

- Select a provider to start a new conversation + {t('providerSelection.description')}

@@ -4823,7 +4871,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess

Claude

-

by Anthropic

+

{t('providerSelection.providerInfo.anthropic')}

{provider === 'claude' && ( @@ -4855,7 +4903,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess

Cursor

-

AI Code Editor

+

{t('providerSelection.providerInfo.cursorEditor')}

{provider === 'cursor' && ( @@ -4887,7 +4935,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess

Codex

-

by OpenAI

+

{t('providerSelection.providerInfo.openai')}

{provider === 'codex' && ( @@ -4905,7 +4953,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess {/* Model Selection - Always reserve space to prevent jumping */}
{provider === 'claude' ? ( setNewKeyName(e.target.value)} className="mb-2" />
- +
@@ -237,7 +239,7 @@ function CredentialsSettings() {
{apiKeys.length === 0 ? ( -

No API keys created yet.

+

{t('apiKeys.empty')}

) : ( apiKeys.map((key) => (
{key.key_name}
{key.api_key}
- Created: {new Date(key.created_at).toLocaleDateString()} - {key.last_used && ` • Last used: ${new Date(key.last_used).toLocaleDateString()}`} + {t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()} + {key.last_used && ` • ${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
@@ -258,7 +260,7 @@ function CredentialsSettings() { variant={key.is_active ? 'outline' : 'secondary'} onClick={() => toggleApiKey(key.id, key.is_active)} > - {key.is_active ? 'Active' : 'Inactive'} + {key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}

- Add GitHub Personal Access Tokens to clone private repositories. You can also pass tokens directly in API requests without storing them. + {t('apiKeys.github.descriptionAlt')}

{showNewGithubForm && (
setNewGithubName(e.target.value)} /> @@ -305,7 +307,7 @@ function CredentialsSettings() {
setNewGithubToken(e.target.value)} className="pr-10" @@ -320,20 +322,20 @@ function CredentialsSettings() {
setNewGithubDescription(e.target.value)} />
- +
@@ -343,14 +345,14 @@ function CredentialsSettings() { rel="noopener noreferrer" className="text-xs text-primary hover:underline block" > - How to create a GitHub Personal Access Token → + {t('apiKeys.github.form.howToCreate')}
)}
{githubCredentials.length === 0 ? ( -

No GitHub tokens added yet.

+

{t('apiKeys.github.empty')}

) : ( githubCredentials.map((credential) => (
{credential.description}
)}
- Added: {new Date(credential.created_at).toLocaleDateString()} + {t('apiKeys.github.added')} {new Date(credential.created_at).toLocaleDateString()}
@@ -372,7 +374,7 @@ function CredentialsSettings() { variant={credential.is_active ? 'outline' : 'secondary'} onClick={() => toggleGithubCredential(credential.id, credential.is_active)} > - {credential.is_active ? 'Active' : 'Inactive'} + {credential.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')} @@ -375,7 +377,7 @@ function FileTree({ selectedProject }) { size="sm" className="h-8 w-8 p-0" onClick={() => changeViewMode('compact')} - title="Compact view" + title={t('fileTree.compactView')} > @@ -384,7 +386,7 @@ function FileTree({ selectedProject }) { size="sm" className="h-8 w-8 p-0" onClick={() => changeViewMode('detailed')} - title="Detailed view" + title={t('fileTree.detailedView')} > @@ -396,7 +398,7 @@ function FileTree({ selectedProject }) { setSearchQuery(e.target.value)} className="pl-8 pr-8 h-8 text-sm" @@ -407,7 +409,7 @@ function FileTree({ selectedProject }) { size="sm" className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-accent" onClick={() => setSearchQuery('')} - title="Clear search" + title={t('fileTree.clearSearch')} > @@ -419,23 +421,23 @@ function FileTree({ selectedProject }) { {viewMode === 'detailed' && filteredFiles.length > 0 && (
-
Name
-
Size
-
Modified
-
Permissions
+
{t('fileTree.name')}
+
{t('fileTree.size')}
+
{t('fileTree.modified')}
+
{t('fileTree.permissions')}
)} - + {files.length === 0 ? (
-

No files found

+

{t('fileTree.noFilesFound')}

- Check if the project path is accessible + {t('fileTree.checkProjectPath')}

) : filteredFiles.length === 0 && searchQuery ? ( @@ -443,9 +445,9 @@ function FileTree({ selectedProject }) {
-

No matches found

+

{t('fileTree.noMatchesFound')}

- Try a different search term or clear the search + {t('fileTree.tryDifferentSearch')}

) : ( diff --git a/src/components/GitSettings.jsx b/src/components/GitSettings.jsx index 91b0902..cdce8e4 100644 --- a/src/components/GitSettings.jsx +++ b/src/components/GitSettings.jsx @@ -3,8 +3,10 @@ import { Button } from './ui/button'; import { Input } from './ui/input'; import { GitBranch, Check } from 'lucide-react'; import { authenticatedFetch } from '../utils/api'; +import { useTranslation } from 'react-i18next'; function GitSettings() { + const { t } = useTranslation('settings'); const [gitName, setGitName] = useState(''); const [gitEmail, setGitEmail] = useState(''); const [gitConfigLoading, setGitConfigLoading] = useState(false); @@ -61,17 +63,17 @@ function GitSettings() {
-

Git Configuration

+

{t('git.title')}

- Configure your git identity for commits. These settings will be applied globally via git config --global + {t('git.description')}

- Your name for git commits + {t('git.name.help')}

- Your email for git commits + {t('git.email.help')}

@@ -110,13 +112,13 @@ function GitSettings() { onClick={saveGitConfig} disabled={gitConfigSaving || !gitName || !gitEmail} > - {gitConfigSaving ? 'Saving...' : 'Save Configuration'} + {gitConfigSaving ? t('git.actions.saving') : t('git.actions.save')} {saveStatus === 'success' && (
- Saved successfully + {t('git.status.success')}
)}
diff --git a/src/components/LanguageSelector.jsx b/src/components/LanguageSelector.jsx new file mode 100644 index 0000000..bef7cf9 --- /dev/null +++ b/src/components/LanguageSelector.jsx @@ -0,0 +1,74 @@ +/** + * Language Selector Component + * + * A dropdown component for selecting the application language. + * Automatically updates the i18n language and persists to localStorage. + * + * Props: + * @param {boolean} compact - If true, uses compact style (default: false) + */ + +import { useTranslation } from 'react-i18next'; +import { Languages } from 'lucide-react'; +import { languages } from '../i18n/languages'; + +function LanguageSelector({ compact = false }) { + const { i18n, t } = useTranslation('settings'); + + const handleLanguageChange = (event) => { + const newLanguage = event.target.value; + i18n.changeLanguage(newLanguage); + }; + + // Compact style for QuickSettingsPanel + if (compact) { + return ( +
+ + + {t('account.language')} + + +
+ ); + } + + // Full style for Settings page + return ( +
+
+
+
+ {t('account.languageLabel')} +
+
+ {t('account.languageDescription')} +
+
+ +
+
+ ); +} + +export default LanguageSelector; diff --git a/src/components/LoginForm.jsx b/src/components/LoginForm.jsx index f2a490a..1482ad7 100644 --- a/src/components/LoginForm.jsx +++ b/src/components/LoginForm.jsx @@ -1,32 +1,34 @@ import React, { useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; import { MessageSquare } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; const LoginForm = () => { + const { t } = useTranslation('auth'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); - + const { login } = useAuth(); const handleSubmit = async (e) => { e.preventDefault(); setError(''); - + if (!username || !password) { - setError('Please enter both username and password'); + setError(t('errors.requiredFields')); return; } - + setIsLoading(true); - + const result = await login(username, password); - + if (!result.success) { setError(result.error); } - + setIsLoading(false); }; @@ -41,9 +43,9 @@ const LoginForm = () => {
-

Welcome Back

+

{t('login.title')}

- Sign in to your Claude Code UI account + {t('login.description')}

@@ -51,7 +53,7 @@ const LoginForm = () => {
{ value={username} onChange={(e) => setUsername(e.target.value)} className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="Enter your username" + placeholder={t('login.placeholders.username')} required disabled={isLoading} /> @@ -67,7 +69,7 @@ const LoginForm = () => {
{ value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="Enter your password" + placeholder={t('login.placeholders.password')} required disabled={isLoading} /> @@ -92,7 +94,7 @@ const LoginForm = () => { disabled={isLoading} className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200" > - {isLoading ? 'Signing in...' : 'Sign In'} + {isLoading ? t('login.loading') : t('login.submit')} diff --git a/src/components/MainContent.jsx b/src/components/MainContent.jsx index 58a8749..50949a0 100644 --- a/src/components/MainContent.jsx +++ b/src/components/MainContent.jsx @@ -12,6 +12,7 @@ */ import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import ChatInterface from './ChatInterface'; import FileTree from './FileTree'; import CodeEditor from './CodeEditor'; @@ -58,6 +59,7 @@ function MainContent({ sendByCtrlEnter, // Send by Ctrl+Enter mode for East Asian language input externalMessageUpdate // Trigger for external CLI updates to current session }) { + const { t } = useTranslation(); const [editingFile, setEditingFile] = useState(null); const [selectedTask, setSelectedTask] = useState(null); const [showTaskDetail, setShowTaskDetail] = useState(false); @@ -238,8 +240,8 @@ function MainContent({ }} />
-

Loading Claude Code UI

-

Setting up your workspace...

+

{t('mainContent.loading')}

+

{t('mainContent.settingUpWorkspace')}

@@ -271,13 +273,13 @@ function MainContent({ -

Choose Your Project

+

{t('mainContent.chooseProject')}

- Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history. + {t('mainContent.selectProjectDescription')}

- 💡 Tip: {isMobile ? 'Tap the menu button above to access projects' : 'Create a new project by clicking the folder icon in the sidebar'} + 💡 {t('mainContent.tip')}: {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')}

@@ -331,7 +333,7 @@ function MainContent({ ) : activeTab === 'chat' && !selectedSession ? (

- New Session + {t('mainContent.newSession')}

{selectedProject.displayName} @@ -340,8 +342,8 @@ function MainContent({ ) : (

- {activeTab === 'files' ? 'Project Files' : - activeTab === 'git' ? 'Source Control' : + {activeTab === 'files' ? t('mainContent.projectFiles') : + activeTab === 'git' ? t('tabs.git') : (activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' : 'Project'}

@@ -357,7 +359,7 @@ function MainContent({ {/* Modern Tab Navigation - Right Side */}
- + - + - + - + {shouldShowTasksTab && ( - + diff --git a/src/components/ProjectCreationWizard.jsx b/src/components/ProjectCreationWizard.jsx index 38564dc..2109e56 100644 --- a/src/components/ProjectCreationWizard.jsx +++ b/src/components/ProjectCreationWizard.jsx @@ -1,13 +1,15 @@ import React, { useState, useEffect } from 'react'; -import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle } from 'lucide-react'; +import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff } from 'lucide-react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { api } from '../utils/api'; +import { useTranslation } from 'react-i18next'; const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { + const { t } = useTranslation(); // Wizard state const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm - const [workspaceType, setWorkspaceType] = useState(null); // 'existing' or 'new' + const [workspaceType, setWorkspaceType] = useState('existing'); // 'existing' or 'new' - default to 'existing' // Form state const [workspacePath, setWorkspacePath] = useState(''); @@ -23,6 +25,11 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const [loadingTokens, setLoadingTokens] = useState(false); const [pathSuggestions, setPathSuggestions] = useState([]); const [showPathDropdown, setShowPathDropdown] = useState(false); + const [showFolderBrowser, setShowFolderBrowser] = useState(false); + const [browserCurrentPath, setBrowserCurrentPath] = useState('~'); + const [browserFolders, setBrowserFolders] = useState([]); + const [loadingFolders, setLoadingFolders] = useState(false); + const [showHiddenFolders, setShowHiddenFolders] = useState(false); // Load available GitHub tokens when needed useEffect(() => { @@ -88,13 +95,13 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { if (step === 1) { if (!workspaceType) { - setError('Please select whether you have an existing workspace or want to create a new one'); + setError(t('projectWizard.errors.selectType')); return; } setStep(2); } else if (step === 2) { if (!workspacePath.trim()) { - setError('Please provide a workspace path'); + setError(t('projectWizard.errors.providePath')); return; } @@ -133,7 +140,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const data = await response.json(); if (!response.ok) { - throw new Error(data.error || 'Failed to create workspace'); + throw new Error(data.error || t('projectWizard.errors.failedToCreate')); } // Success! @@ -144,7 +151,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { onClose(); } catch (error) { console.error('Error creating workspace:', error); - setError(error.message || 'Failed to create workspace'); + setError(error.message || t('projectWizard.errors.failedToCreate')); } finally { setIsCreating(false); } @@ -155,6 +162,37 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { setShowPathDropdown(false); }; + const openFolderBrowser = async () => { + setShowFolderBrowser(true); + await loadBrowserFolders('~'); + }; + + const loadBrowserFolders = async (path) => { + try { + setLoadingFolders(true); + setBrowserCurrentPath(path); + const response = await api.browseFilesystem(path); + const data = await response.json(); + setBrowserFolders(data.suggestions || []); + } catch (error) { + console.error('Error loading folders:', error); + } finally { + setLoadingFolders(false); + } + }; + + const selectFolder = (folderPath, advanceToConfirm = false) => { + setWorkspacePath(folderPath); + setShowFolderBrowser(false); + if (advanceToConfirm) { + setStep(3); + } + }; + + const navigateToFolder = async (folderPath) => { + await loadBrowserFolders(folderPath); + }; + return (
@@ -165,7 +203,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {

- Create New Project + {t('projectWizard.title')}

- {s === 1 ? 'Type' : s === 2 ? 'Configure' : 'Confirm'} + {s === 1 ? t('projectWizard.steps.type') : s === 2 ? t('projectWizard.steps.configure') : t('projectWizard.steps.confirm')}
{s < 3 && ( @@ -227,7 +265,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {

- Do you already have a workspace, or would you like to create a new one? + {t('projectWizard.step1.question')}

{/* Existing Workspace */} @@ -245,10 +283,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
- Existing Workspace + {t('projectWizard.step1.existing.title')}

- I already have a workspace on my server and just need to add it to the project list + {t('projectWizard.step1.existing.description')}

@@ -269,10 +307,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
- New Workspace + {t('projectWizard.step1.new.title')}

- Create a new workspace, optionally clone from a GitHub repository + {t('projectWizard.step1.new.description')}

@@ -288,35 +326,46 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { {/* Workspace Path */}
-
- setWorkspacePath(e.target.value)} - placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'} - className="w-full" - /> - {showPathDropdown && pathSuggestions.length > 0 && ( -
- {pathSuggestions.map((suggestion, index) => ( - - ))} -
- )} +
+
+ setWorkspacePath(e.target.value)} + placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'} + className="w-full" + /> + {showPathDropdown && pathSuggestions.length > 0 && ( +
+ {pathSuggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+

{workspaceType === 'existing' - ? 'Full path to your existing workspace directory' - : 'Full path where the new workspace will be created'} + ? t('projectWizard.step2.existingHelp') + : t('projectWizard.step2.newHelp')}

@@ -325,7 +374,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { <>
{ className="w-full" />

- Leave empty to create an empty workspace, or provide a GitHub URL to clone + {t('projectWizard.step2.githubHelp')}

@@ -346,10 +395,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
- GitHub Authentication (Optional) + {t('projectWizard.step2.githubAuth')}

- Only required for private repositories. Public repos can be cloned without authentication. + {t('projectWizard.step2.githubAuthHelp')}

@@ -357,7 +406,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { {loadingTokens ? (
- Loading stored tokens... + {t('projectWizard.step2.loadingTokens')}
) : availableTokens.length > 0 ? ( <> @@ -371,7 +420,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300' }`} > - Stored Token + {t('projectWizard.step2.storedToken')}
{tokenMode === 'stored' ? (
{ className="w-full" />

- This token will be used only for this operation + {t('projectWizard.step2.tokenHelp')}

) : null} @@ -439,23 +488,23 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {

- 💡 Public repositories don't require authentication. You can skip providing a token if cloning a public repo. + {t('projectWizard.step2.publicRepoInfo')}

setNewGithubToken(e.target.value)} - placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)" + placeholder={t('projectWizard.step2.tokenPublicPlaceholder')} className="w-full" />

- No stored tokens available. You can add tokens in Settings → API Keys for easier reuse. + {t('projectWizard.step2.noTokensHelp')}

@@ -472,17 +521,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {

- Review Your Configuration + {t('projectWizard.step3.reviewConfig')}

- Workspace Type: + {t('projectWizard.step3.workspaceType')} - {workspaceType === 'existing' ? 'Existing Workspace' : 'New Workspace'} + {workspaceType === 'existing' ? t('projectWizard.step3.existingWorkspace') : t('projectWizard.step3.newWorkspace')}
- Path: + {t('projectWizard.step3.path')} {workspacePath} @@ -490,19 +539,19 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { {workspaceType === 'new' && githubUrl && ( <>
- Clone From: + {t('projectWizard.step3.cloneFrom')} {githubUrl}
- Authentication: + {t('projectWizard.step3.authentication')} {tokenMode === 'stored' && selectedGithubToken - ? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}` + ? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}` : tokenMode === 'new' && newGithubToken - ? 'Using provided token' - : 'No authentication'} + ? t('projectWizard.step3.usingProvidedToken') + : t('projectWizard.step3.noAuthentication')}
@@ -513,10 +562,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {

{workspaceType === 'existing' - ? 'The workspace will be added to your project list and will be available for Claude/Cursor sessions.' + ? t('projectWizard.step3.existingInfo') : githubUrl - ? 'A new workspace will be created and the repository will be cloned from GitHub.' - : 'An empty workspace directory will be created at the specified path.'} + ? t('projectWizard.step3.newWithClone') + : t('projectWizard.step3.newEmpty')}

@@ -531,11 +580,11 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { disabled={isCreating} > {step === 1 ? ( - 'Cancel' + t('projectWizard.buttons.cancel') ) : ( <> - Back + {t('projectWizard.buttons.back')} )} @@ -547,22 +596,137 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { {isCreating ? ( <> - Creating... + {t('projectWizard.buttons.creating')} ) : step === 3 ? ( <> - Create Project + {t('projectWizard.buttons.createProject')} ) : ( <> - Next + {t('projectWizard.buttons.next')} )}
+ + {/* Folder Browser Modal */} + {showFolderBrowser && ( +
+
+ {/* Browser Header */} +
+
+
+ +
+

+ Select Folder +

+
+
+ + +
+
+ + {/* Folder List */} +
+ {loadingFolders ? ( +
+ +
+ ) : browserFolders.length === 0 ? ( +
+ No folders found +
+ ) : ( +
+ {/* Parent Directory */} + {browserCurrentPath !== '~' && browserCurrentPath !== '/' && ( + + )} + + {/* Folders */} + {browserFolders + .filter(folder => showHiddenFolders || !folder.name.startsWith('.')) + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) + .map((folder, index) => ( +
+ + +
+ ))} +
+ )} +
+ + {/* Browser Footer with Current Path */} +
+
+ Path: + + {browserCurrentPath} + +
+
+ + +
+
+
+
+ )}
); }; diff --git a/src/components/QuickSettingsPanel.jsx b/src/components/QuickSettingsPanel.jsx index 75f8f24..6da0289 100644 --- a/src/components/QuickSettingsPanel.jsx +++ b/src/components/QuickSettingsPanel.jsx @@ -15,8 +15,10 @@ import { Languages, GripVertical } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import DarkModeToggle from './DarkModeToggle'; import { useTheme } from '../contexts/ThemeContext'; +import LanguageSelector from './LanguageSelector'; const QuickSettingsPanel = ({ isOpen, @@ -33,6 +35,7 @@ const QuickSettingsPanel = ({ onSendByCtrlEnterChange, isMobile }) => { + const { t } = useTranslation('settings'); const [localIsOpen, setLocalIsOpen] = useState(isOpen); const [whisperMode, setWhisperMode] = useState(() => { return localStorage.getItem('whisperMode') || 'default'; @@ -230,8 +233,8 @@ const QuickSettingsPanel = ({ isDragging ? 'cursor-grabbing' : 'cursor-pointer' } touch-none`} style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }} - aria-label={isDragging ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'} - title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'} + aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : localIsOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')} + title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')} > {isDragging ? ( @@ -253,7 +256,7 @@ const QuickSettingsPanel = ({

- Quick Settings + {t('quickSettings.title')}

@@ -261,25 +264,30 @@ const QuickSettingsPanel = ({
{/* Appearance Settings */}
-

Appearance

- +

{t('quickSettings.sections.appearance')}

+
{isDarkMode ? : } - Dark Mode + {t('quickSettings.darkMode')}
+ + {/* Language Selector */} +
+ +
{/* Tool Display Settings */}
-

Tool Display

- +

{t('quickSettings.sections.toolDisplay')}

+