Compare commits

..

11 Commits

Author SHA1 Message Date
Haileyesus
c235b05e1d feat: add file tree upload progress
Users need a visible upload path from the explorer itself, not only drag and
 drop behavior with no progress feedback. Routing picker and drop uploads
 through one XHR-backed hook keeps progress, validation, refresh, and success
 counts consistent for every upload source.

The 200MB limit is mirrored in the client, multer, and nginx template so large
 uploads fail predictably instead of being blocked by whichever layer sees the
 request first. The server also returns explicit requested and uploaded counts
 so partial or multi-file batches can render accurate status text.
2026-06-08 14:52:09 +03:00
viper151
dd77649053 chore(release): v1.33.2 2026-06-08 05:58:08 +00:00
Simos Mikelatos
af3a28abc7 Remove fast project display names option 2026-06-07 08:55:16 +00:00
Simos Mikelatos
371ff034e4 Add fast project display names option 2026-06-07 08:46:31 +00:00
Simos Mikelatos
3b4d6885aa Add lightweight projects query options 2026-06-07 08:10:28 +00:00
Simos Mikelatos
bc9d2dd830 Merge pull request #839 from siteboon/fix/claude-token-cache-usage 2026-06-05 21:48:44 +02:00
妖怪不丸
c21a9f4561 feat(i18n): add Traditional Chinese (zh-TW) locale (#773)
* feat(i18n): add Traditional Chinese (zh-TW) locale

* fix(i18n): localize remaining English strings and fix README fence (zh-TW)

* fix(i18n): track zh-TW tasks.json (was excluded by .gitignore)

---------

Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-05 21:45:49 +03:00
Haileyesus
ed9cdf0114 fix: include Claude cache tokens in usage 2026-06-05 21:38:05 +03:00
Simos Mikelatos
b39997c429 Merge pull request #838 from siteboon/fix/do-not-show-model-description-in-chat-view 2026-06-05 20:31:55 +02:00
Haileyesus
d638a8982c fix: do not show model description in chat view 2026-06-05 21:28:08 +03:00
Simos Mikelatos
f238050b85 feat(chat): open cost modal from token usage 2026-06-05 17:33:22 +00:00
36 changed files with 2285 additions and 143 deletions

View File

@@ -3,6 +3,18 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
### New Features
* **chat:** open cost modal from token usage ([f238050](https://github.com/siteboon/claudecodeui/commit/f238050b85c3b99a702a8635059735e1a3b3a4f4))
* **i18n:** add Traditional Chinese (zh-TW) locale ([#773](https://github.com/siteboon/claudecodeui/issues/773)) ([c21a9f4](https://github.com/siteboon/claudecodeui/commit/c21a9f45610eb1eeb650d8e6cf8650e798f77f6f))
### Bug Fixes
* do not show model description in chat view ([d638a89](https://github.com/siteboon/claudecodeui/commit/d638a8982c7f75b08fc7f65f01d6d54989c790d1))
* include Claude cache tokens in usage ([ed9cdf0](https://github.com/siteboon/claudecodeui/commit/ed9cdf01145fa0d063580bb76d30cfa7ee67af86))
## [1.33.1](https://github.com/siteboon/claudecodeui/compare/v1.33.0...v1.33.1) (2026-06-05)
### New Features

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
---

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
---

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>简体中文</b> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---

242
README.zh-TW.md Normal file
View File

@@ -0,0 +1,242 @@
<div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI又名 Claude Code UI</h1>
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a><a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a><a href="https://developers.openai.com/codex">Codex</a><a href="https://geminicli.com/">Gemini-CLI</a> 的桌面和行動裝置 UI。可在本機或遠端使用從任何地方查看您的專案與工作階段。</p>
</div>
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文件</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 回報</a> · <a href="CONTRIBUTING.md">貢獻指南</a>
</p>
<p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社群"></a>
<br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <b>繁體中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
## 截圖
<div align="center">
<table>
<tr>
<td align="center">
<h3>桌面檢視</h3>
<img src="public/screenshots/desktop-main.png" alt="桌面介面" width="400">
<br>
<em>顯示專案總覽和聊天的主介面</em>
</td>
<td align="center">
<h3>行動裝置體驗</h3>
<img src="public/screenshots/mobile-chat.png" alt="行動裝置介面" width="250">
<br>
<em>具有觸控導覽的響應式行動裝置設計</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>CLI 選擇</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI 選擇" width="400">
<br>
<em>在 Claude Code、Gemini、Cursor CLI 與 Codex 之間進行選擇</em>
</td>
</tr>
</table>
</div>
## 功能
- **響應式設計** — 在桌面、平板和行動裝置上無縫運作,讓您隨時隨地使用 Agents
- **互動聊天介面** — 內建聊天 UI輕鬆與 Agents 交流
- **整合 Shell 終端機** — 透過內建 shell 功能直接存取 Agents CLI
- **檔案瀏覽器** — 互動式檔案樹,支援語法醒目提示與即時編輯
- **Git 瀏覽器** — 檢視、暫存並提交變更,還可切換分支
- **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄
- **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 整合** *(選用)* — 結合 AI 任務規劃、PRD 分析與工作流程自動化,實現進階專案管理
- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表見 [`shared/modelConstants.js`](shared/modelConstants.js)
## 快速開始
### CloudCLI Cloud推薦
無需本機設定即可快速啟動。提供可透過網路瀏覽器、行動應用程式、API 或慣用的 IDE 存取的完全容器化託管開發環境。
**[立即開始 CloudCLI Cloud](https://cloudcli.ai)**
### 自架(開源)
#### npm
啟動 CloudCLI UI只需一行 `npx`(需要 Node.js v22+
```bash
npx @cloudcli-ai/cloudcli
```
或進行全域安裝,便於日常使用:
```bash
npm install -g @cloudcli-ai/cloudcli
cloudcli
```
開啟 `http://localhost:3001`,系統會自動發現所有現有工作階段。
更多設定選項、PM2、遠端伺服器設定等請參閱 **[文件 →](https://cloudcli.ai/docs)**。
#### Docker Sandboxes實驗性
在隔離的沙箱中執行代理,具有虛擬機管理程式等級的隔離。預設啟動 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
```bash
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
支援 Claude Code、Codex 和 Gemini CLI。詳情請參閱[沙箱文件](docker/)。
---
## 哪個選項更適合你?
CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它,也可以使用提供團隊功能與深入整合的 CloudCLI Cloud。
| | CloudCLI UI自架 | CloudCLI Cloud |
|---|---|---|
| **適合對象** | 需要為本機代理工作階段提供完整 UI 的開發者 | 需要部署在雲端,隨時從任何地方存取代理的團隊與開發者 |
| **存取方式** | 透過 `[yourip]:port` 在瀏覽器中存取 | 瀏覽器、任意 IDE、REST API、n8n |
| **設定** | `npx @cloudcli-ai/cloudcli` | 無需設定 |
| **機器需保持開機嗎** | 是 | 否 |
| **行動裝置存取** | 網路內任意瀏覽器 | 任意裝置(原生應用程式即將推出) |
| **可用工作階段** | 自動發現 `~/.claude` 中的所有工作階段 | 雲端環境內的工作階段 |
| **支援的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI |
| **檔案瀏覽與 Git** | 內建於 UI | 內建於 UI |
| **MCP 設定** | UI 管理,與本機 `~/.claude` 設定同步 | UI 管理 |
| **IDE 存取** | 本機 IDE | 任何連線到雲端環境的 IDE |
| **REST API** | 是 | 是 |
| **n8n 節點** | 否 | 是 |
| **團隊共享** | 否 | 是 |
| **平台費用** | 免費開源 | 起價 $7/月 |
> 兩種方式都使用你自己的 AI 訂閱Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。
---
## 安全與工具設定
**🔒 重要提示**:所有 Claude Code 工具預設**停用**,可防止潛在的有害操作自動執行。
### 啟用工具
1. **開啟工具設定** — 點擊側邊欄齒輪圖示
2. **選擇性啟用** — 僅啟用所需工具
3. **套用設定** — 偏好設定儲存在本機
<div align="center">
![工具設定彈出視窗](public/screenshots/tools-modal.png)
*工具設定介面 — 只啟用你需要的內容*
</div>
**建議做法**:先啟用基礎工具,再根據需要新增其他工具。隨時可以調整。
---
## 外掛
CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Node.js 後端的分頁。在 Settings > Plugins 中直接從 Git 儲存庫安裝外掛,或自行開發。
### 可用外掛
| 外掛 | 描述 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
### 自行建構
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — Fork 該儲存庫以建構自己的外掛。範例包括前端渲染、即時上下文更新和 RPC 通訊。
**[外掛文件 →](https://cloudcli.ai/docs/plugin-overview)** — 提供外掛 API、清單格式、安全模型等完整指南。
---
## 常見問題
<details>
<summary>與 Claude Code Remote Control 有何不同?</summary>
Claude Code Remote Control 讓你傳送訊息到本機終端機中已經執行的工作階段。該方式要求你的機器保持開機,終端機保持開啟,中斷網路後約 10 分鐘工作階段會逾時。
CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — MCP 伺服器、權限、設定、工作階段與 Claude Code 完全一致。
- **涵蓋全部工作階段** — CloudCLI UI 會自動掃描 `~/.claude` 資料夾中的每個工作階段。Remote Control 只暴露目前活動的工作階段。
- **設定統一** — 在 CloudCLI UI 中修改的 MCP、工具權限等設定會立即寫入 Claude Code。
- **支援更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。
- **完整 UI** — 除了聊天介面還包括檔案瀏覽器、Git 整合、MCP 管理和 Shell 終端機。
- **CloudCLI Cloud 持續運作於雲端** — 關閉本機裝置也不會中斷代理執行,無需監控終端機。
</details>
<details>
<summary>需要額外購買 AI 訂閱嗎?</summary>
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 $7/月起提供託管環境。
</details>
<details>
<summary>能在手機上使用 CloudCLI UI 嗎?</summary>
可以。自架時,在你的裝置上執行伺服器,然後在網路中的任意瀏覽器開啟 `[yourip]:port`。CloudCLI Cloud 可從任意裝置存取,內建原生應用程式也在開發中。
</details>
<details>
<summary>UI 中的變更會影響本機 Claude Code 設定嗎?</summary>
會的。自架模式下CloudCLI UI 讀取並寫入 Claude Code 使用的 `~/.claude` 設定。透過 UI 新增的 MCP 伺服器會立即在 Claude Code 中可見。
</details>
---
## 社群與支援
- **[文件](https://cloudcli.ai/docs)** — 安裝、設定、功能與疑難排解指南
- **[Discord](https://discord.gg/buxwujPNRE)** — 取得協助並與社群交流
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 回報 Bug 與建議功能
- **[貢獻指南](CONTRIBUTING.md)** — 如何參與專案貢獻
## 授權條款
GNU 通用公共授權條款 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
- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI
- **[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 驅動的專案管理與任務規劃
### 贊助商
- [Siteboon - AI powered website builder](https://siteboon.ai)
---
<div align="center">
<strong>為 Claude Code、Cursor 和 Codex 社群精心打造。</strong>
</div>

View File

@@ -72,7 +72,7 @@ http {
set $cloudcli_upstream http://127.0.0.1:3001;
# Allow larger file uploads through the code editor/project file APIs.
client_max_body_size 100m;
client_max_body_size 200m;
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.33.1",
"version": "1.33.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cloudcli-ai/cloudcli",
"version": "1.33.1",
"version": "1.33.2",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.33.1",
"version": "1.33.2",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",

View File

@@ -304,7 +304,11 @@ function extractTokenBudget(sdkMessage) {
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
if (messageUsage && typeof messageUsage === 'object') {
const inputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
const cacheTokens = cacheCreationTokens + cacheReadTokens;
const inputTokens = directInputTokens + cacheTokens;
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
const totalUsed = inputTokens + outputTokens;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
@@ -314,6 +318,9 @@ function extractTokenBudget(sdkMessage) {
total: contextWindow,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
breakdown: {
input: inputTokens,
output: outputTokens,

View File

@@ -84,9 +84,17 @@ const __dirname = getModuleDir(import.meta.url);
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
const MAX_FILE_UPLOAD_SIZE_MB = 200;
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
const MAX_FILE_UPLOAD_COUNT = 20;
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
function readUsageNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
const app = express();
const server = http.createServer(app);
@@ -892,27 +900,27 @@ const uploadFilesHandler = async (req, res) => {
}
}),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit
files: 20 // Max 20 files at once
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
files: MAX_FILE_UPLOAD_COUNT
}
});
// Use multer middleware
uploadMiddleware.array('files', 20)(req, res, async (err) => {
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
if (err) {
console.error('Multer error:', err);
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
}
return res.status(500).json({ error: err.message });
}
try {
const { projectId } = req.params;
const { targetPath, relativePaths } = req.body;
const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
// Parse relative paths if provided (for folder uploads)
let filePaths = [];
@@ -936,6 +944,11 @@ const uploadFilesHandler = async (req, res) => {
return res.status(400).json({ error: 'No files provided' });
}
const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10);
const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0
? parsedRequestedFileCount
: req.files.length;
// Resolve the project directory through the DB using the new projectId.
const projectRoot = await projectsDb.getProjectPathById(projectId);
if (!projectRoot) {
@@ -1014,8 +1027,10 @@ const uploadFilesHandler = async (req, res) => {
res.json({
success: true,
files: uploadedFiles,
uploadedCount: uploadedFiles.length,
requestedFileCount,
targetPath: resolvedTargetDir,
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
});
} catch (error) {
console.error('Error uploading files:', error);
@@ -1386,6 +1401,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
// Find the latest assistant message with usage data (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
@@ -1397,8 +1414,11 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const usage = entry.message.usage;
// Use token counts from latest assistant message only
inputTokens = usage.input_tokens || 0;
outputTokens = usage.output_tokens || 0;
const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens);
cacheReadTokens = readUsageNumber(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens);
cacheCreationTokens = readUsageNumber(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? usage.cacheCreationTokens);
inputTokens = directInputTokens + cacheReadTokens + cacheCreationTokens;
outputTokens = readUsageNumber(usage.output_tokens ?? usage.outputTokens);
break; // Stop after finding the latest assistant message
}
@@ -1409,12 +1429,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
}
const totalUsed = inputTokens + outputTokens;
const cacheTokens = cacheReadTokens + cacheCreationTokens;
res.json({
used: totalUsed,
total: contextWindow,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
breakdown: {
input: inputTokens,
output: outputTokens

View File

@@ -67,8 +67,17 @@ function resolveRouteErrorMessage(error: unknown): string {
router.get(
'/',
asyncHandler(async (_req, res) => {
const projects = await getProjectsWithSessions();
asyncHandler(async (req, res) => {
const skipSynchronization =
readQueryStringValue(req.query.skipSynchronization).trim() === '1' ||
readQueryStringValue(req.query.skipSync).trim() === '1';
const sessionsLimit = readOptionalNumericQueryValue(req.query.sessionsLimit) ?? undefined;
const sessionsOffset = readOptionalNumericQueryValue(req.query.sessionsOffset) ?? undefined;
const projects = await getProjectsWithSessions({
skipSynchronization,
sessionsLimit,
sessionsOffset,
});
res.json(projects);
}),
);

View File

@@ -592,12 +592,14 @@ class ResponseCollector {
}
}
const inputTokens = totalInput + totalCacheRead + totalCacheCreation;
return {
inputTokens: totalInput,
inputTokens,
outputTokens: totalOutput,
cacheReadTokens: totalCacheRead,
cacheCreationTokens: totalCacheCreation,
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
totalTokens: inputTokens + totalOutput
};
}
}

View File

@@ -268,16 +268,35 @@ Custom commands can be created in:
tokenUsage.contextWindow ??
0,
) || 0;
const inputTokensRaw =
const normalizedInputValue =
tokenUsage.inputTokens ??
tokenUsage.input ??
tokenUsage.cumulativeInputTokens ??
tokenUsage.breakdown?.input ??
tokenUsage.promptTokens;
const directInputTokens =
Number(
tokenUsage.inputTokens ??
tokenUsage.input ??
normalizedInputValue ??
tokenUsage.input_tokens ??
tokenUsage.cumulativeInputTokens ??
tokenUsage.breakdown?.input ??
tokenUsage.promptTokens ??
0
) || 0;
const cacheReadTokens =
Number(
tokenUsage.cacheReadTokens ??
tokenUsage.cache_read_input_tokens ??
tokenUsage.cacheReadInputTokens ??
0,
) || 0;
const cacheCreationTokens =
Number(
tokenUsage.cacheCreationTokens ??
tokenUsage.cache_creation_input_tokens ??
tokenUsage.cacheCreationInputTokens ??
0,
) || 0;
const inputTokens = normalizedInputValue == null
? directInputTokens + cacheReadTokens + cacheCreationTokens
: directInputTokens;
const outputTokens =
Number(
tokenUsage.outputTokens ??
@@ -288,8 +307,9 @@ Custom commands can be created in:
tokenUsage.completionTokens ??
0,
) || 0;
const hasTokenBreakdown = inputTokensRaw > 0 || outputTokens > 0;
const used = reportedUsed || inputTokensRaw + outputTokens;
const computedUsed = inputTokens + outputTokens;
const hasTokenBreakdown = computedUsed > 0;
const used = Math.max(reportedUsed, computedUsed);
return {
type: "builtin",
@@ -302,7 +322,7 @@ Custom commands can be created in:
...(hasTokenBreakdown
? {
tokenBreakdown: {
input: inputTokensRaw,
input: inputTokens,
output: outputTokens,
},
}

View File

@@ -311,7 +311,7 @@ export function useChatComposerState({
}, [addMessage]);
const executeCommand = useCallback(
async (command: SlashCommand, rawInput?: string) => {
async (command: SlashCommand, rawInput?: string, options?: { preserveInput?: boolean }) => {
if (!command || !selectedProject) {
return;
}
@@ -368,8 +368,10 @@ export function useChatComposerState({
const result = (await response.json()) as CommandExecutionResult;
if (result.type === 'builtin') {
handleBuiltInCommand(result);
setInput('');
inputValueRef.current = '';
if (!options?.preserveInput) {
setInput('');
inputValueRef.current = '';
}
} else if (result.type === 'custom') {
await handleCustomCommand(result);
}
@@ -400,6 +402,19 @@ export function useChatComposerState({
],
);
const showCostModal = useCallback(() => {
executeCommand(
{
name: '/cost',
description: 'Display token usage information',
namespace: 'builtin',
metadata: { type: 'builtin' },
} as SlashCommand,
'/cost',
{ preserveInput: true },
);
}, [executeCommand]);
const {
slashCommands,
slashCommandsCount,
@@ -1049,5 +1064,6 @@ export function useChatComposerState({
isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
};
}

View File

@@ -178,6 +178,7 @@ function ChatInterface({
isInputFocused: _isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
} = useChatComposerState({
selectedProject,
selectedSession,
@@ -368,6 +369,7 @@ function ChatInterface({
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())}

View File

@@ -58,6 +58,7 @@ interface ChatComposerProps {
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
tokenBudget: Record<string, unknown> | null;
onShowTokenUsage: () => void;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
@@ -111,6 +112,7 @@ export default function ChatComposer({
permissionMode,
onModeSwitch,
tokenBudget,
onShowTokenUsage,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
@@ -353,7 +355,7 @@ export default function ChatComposer({
</div>
</button>
<TokenUsageSummary usage={tokenBudget} />
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
<PromptInputButton
tooltip={{ content: t('input.showAllCommands') }}

View File

@@ -277,11 +277,15 @@ export default function ProviderSelectionEmptyState({
>
<div className="min-w-0 flex-1">
<div className="truncate">{model.label}</div>
{model.description && (
{/*
// * Temporarly commented out because the description of models from claude
// * was a bit inconsistent. Will return it back when it becomes more consistent.
*/}
{/* {model.description && (
<div className="truncate text-xs text-muted-foreground">
{model.description}
</div>
)}
)} */}
</div>
{isSelected && (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />

View File

@@ -2,6 +2,7 @@ import { ActivityIcon } from 'lucide-react';
type TokenUsageSummaryProps = {
usage: Record<string, unknown> | null;
onClick?: () => void;
};
const formatTokenCount = (value: number) => {
@@ -29,7 +30,7 @@ const readUsageNumber = (value: unknown) => {
return Number.isFinite(parsed) ? parsed : 0;
};
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryProps) {
const breakdown =
usage?.breakdown && typeof usage.breakdown === 'object'
? usage.breakdown as Record<string, unknown>
@@ -39,15 +40,18 @@ export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
return (
<div
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground sm:gap-2 sm:px-2.5"
<button
type="button"
onClick={onClick}
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
title={`${usedTokens.toLocaleString()} tokens used`}
aria-label="Show token usage"
>
<span className="grid h-5 w-5 place-items-center rounded-md bg-primary/10 text-primary">
<ActivityIcon className="h-3.5 w-3.5" />
</span>
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
</div>
</button>
);
}

View File

@@ -6,6 +6,14 @@ export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
export const MAX_FILE_UPLOAD_SIZE_MB = 200;
export const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
export const MAX_FILE_UPLOAD_SIZE_LABEL = `${MAX_FILE_UPLOAD_SIZE_MB}MB`;
export const MAX_FILE_UPLOAD_COUNT = 20;
export const IMAGE_FILE_EXTENSIONS = new Set([
'png',
'jpg',

View File

@@ -1,6 +1,13 @@
import { useCallback, useState, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { IS_PLATFORM } from '../../../constants/config';
import type { Project } from '../../../types/app';
import { api } from '../../../utils/api';
import {
MAX_FILE_UPLOAD_COUNT,
MAX_FILE_UPLOAD_SIZE_BYTES,
MAX_FILE_UPLOAD_SIZE_LABEL,
} from '../constants/constants';
type UseFileTreeUploadOptions = {
selectedProject: Project | null;
@@ -8,6 +15,141 @@ type UseFileTreeUploadOptions = {
showToast: (message: string, type: 'success' | 'error') => void;
};
export type FileTreeUploadProgressState = {
status: 'uploading' | 'complete' | 'error';
progress: number;
fileCount: number;
uploadedCount?: number;
fileName?: string;
targetPath?: string;
error?: string;
};
type UploadResponse = {
error?: string;
message?: string;
files?: unknown[];
uploadedCount?: number;
requestedFileCount?: number;
};
const COMPLETE_PROGRESS_CLEAR_DELAY_MS = 1400;
const ERROR_PROGRESS_CLEAR_DELAY_MS = 3200;
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
const getRelativePath = (file: File) => {
const fileWithRelativePath = file as File & { webkitRelativePath?: string };
return fileWithRelativePath.webkitRelativePath || file.name;
};
const getFileDisplayName = (file: File) => {
const relativePath = getRelativePath(file);
return relativePath.split(/[\\/]/).pop() || file.name;
};
const validateFilesForUpload = (files: File[]): string | null => {
if (files.length > MAX_FILE_UPLOAD_COUNT) {
return `You can upload up to ${MAX_FILE_UPLOAD_COUNT} files at once.`;
}
const oversizedFile = files.find((file) => file.size > MAX_FILE_UPLOAD_SIZE_BYTES);
if (oversizedFile) {
return `${getFileDisplayName(oversizedFile)} is larger than ${MAX_FILE_UPLOAD_SIZE_LABEL}.`;
}
return null;
};
const parseUploadResponse = (xhr: XMLHttpRequest): UploadResponse => {
if (!xhr.responseText) {
return {};
}
try {
return JSON.parse(xhr.responseText) as UploadResponse;
} catch {
return {};
}
};
const formatUploadSuccessMessage = (uploadedCount: number, requestedFileCount: number) => {
if (uploadedCount !== requestedFileCount) {
return `Uploaded ${uploadedCount} of ${requestedFileCount} ${pluralizeFiles(requestedFileCount)}`;
}
return `Uploaded ${uploadedCount} ${pluralizeFiles(uploadedCount)} successfully`;
};
const buildUploadFormData = (files: File[], targetPath: string) => {
const formData = new FormData();
const relativePaths: string[] = [];
formData.append('targetPath', targetPath);
formData.append('requestedFileCount', String(files.length));
files.forEach((file) => {
const relativePath = getRelativePath(file);
const cleanFile = new File([file], relativePath.split(/[\\/]/).pop() || file.name, {
type: file.type,
lastModified: file.lastModified,
});
formData.append('files', cleanFile);
relativePaths.push(relativePath);
});
formData.append('relativePaths', JSON.stringify(relativePaths));
return formData;
};
const uploadFormDataWithProgress = (
projectId: string,
formData: FormData,
onProgress: (progress: number) => void,
) =>
new Promise<UploadResponse>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`);
const token = localStorage.getItem('auth-token');
if (!IS_PLATFORM && token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) {
return;
}
// Keep 100% for the server response so the UI can distinguish transfer
// completion from the final write/refresh step.
onProgress(Math.min(99, Math.round((event.loaded / event.total) * 100)));
};
xhr.onload = () => {
const refreshedToken = xhr.getResponseHeader('X-Refreshed-Token');
if (refreshedToken) {
localStorage.setItem('auth-token', refreshedToken);
}
const payload = parseUploadResponse(xhr);
if (xhr.status >= 200 && xhr.status < 300) {
resolve(payload);
return;
}
reject(new Error(payload.error || payload.message || `Upload failed with status ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('Upload failed. Check your connection and try again.'));
xhr.onabort = () => reject(new Error('Upload canceled.'));
xhr.send(formData);
});
// Helper function to read all files from a directory entry recursively
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
const files: File[] = [];
@@ -57,6 +199,48 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry,
return files;
};
const collectDroppedFiles = async (dataTransfer: DataTransfer) => {
const files: File[] = [];
// Use DataTransferItemList for folder support
const { items } = dataTransfer;
if (items) {
for (const item of Array.from(items)) {
if (item.kind !== 'file') {
continue;
}
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (!entry) {
const file = item.getAsFile();
if (file) {
files.push(file);
}
continue;
}
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => {
(entry as FileSystemFileEntry).file(resolve, reject);
});
files.push(file);
} else if (entry.isDirectory) {
// Pass the directory name as basePath so files include the folder path
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
files.push(...dirFiles);
}
}
return files;
}
// Fallback for browsers that don't support webkitGetAsEntry
for (const file of Array.from(dataTransfer.files)) {
files.push(file);
}
return files;
};
export const useFileTreeUpload = ({
selectedProject,
onRefresh,
@@ -65,20 +249,150 @@ export const useFileTreeUpload = ({
const [isDragOver, setIsDragOver] = useState(false);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [operationLoading, setOperationLoading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<FileTreeUploadProgressState | null>(null);
const treeRef = useRef<HTMLDivElement>(null);
const clearProgressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
const clearProgressTimer = useCallback(() => {
if (clearProgressTimerRef.current) {
clearTimeout(clearProgressTimerRef.current);
clearProgressTimerRef.current = null;
}
}, []);
const scheduleProgressClear = useCallback(
(delay: number) => {
clearProgressTimer();
clearProgressTimerRef.current = setTimeout(() => {
setUploadProgress(null);
clearProgressTimerRef.current = null;
}, delay);
},
[clearProgressTimer],
);
useEffect(() => clearProgressTimer, [clearProgressTimer]);
const setUploadError = useCallback(
(message: string, fileCount: number, targetPath = '', fileName?: string, progress = 0) => {
setUploadProgress({
status: 'error',
progress,
fileCount,
fileName,
targetPath,
error: message,
});
scheduleProgressClear(ERROR_PROGRESS_CLEAR_DELAY_MS);
},
[scheduleProgressClear],
);
const uploadFiles = useCallback(
async (files: File[], targetPath = '') => {
if (files.length === 0) {
setDropTarget(null);
return;
}
const fileName = files.length === 1 ? getFileDisplayName(files[0]) : undefined;
if (!selectedProject) {
const message = 'Select a project before uploading files.';
showToast(message, 'error');
setUploadError(message, files.length, targetPath, fileName);
return;
}
const validationError = validateFilesForUpload(files);
if (validationError) {
showToast(validationError, 'error');
setUploadError(validationError, files.length, targetPath, fileName);
return;
}
clearProgressTimer();
setOperationLoading(true);
setUploadProgress({
status: 'uploading',
progress: 0,
fileCount: files.length,
fileName,
targetPath,
});
let latestProgress = 0;
try {
const response = await uploadFormDataWithProgress(
selectedProject.projectId,
buildUploadFormData(files, targetPath),
(progress) => {
latestProgress = progress;
setUploadProgress((current) =>
current && current.status === 'uploading'
? { ...current, progress }
: current,
);
},
);
const uploadedCount =
typeof response.uploadedCount === 'number' ? response.uploadedCount : response.files?.length ?? files.length;
const requestedFileCount =
typeof response.requestedFileCount === 'number' ? response.requestedFileCount : files.length;
setUploadProgress({
status: 'complete',
progress: 100,
fileCount: requestedFileCount,
uploadedCount,
fileName,
targetPath,
});
showToast(formatUploadSuccessMessage(uploadedCount, requestedFileCount), 'success');
scheduleProgressClear(COMPLETE_PROGRESS_CLEAR_DELAY_MS);
onRefresh();
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
console.error('Upload error:', err);
showToast(message, 'error');
setUploadError(message, files.length, targetPath, fileName, latestProgress);
} finally {
setOperationLoading(false);
setDropTarget(null);
}
},
[
clearProgressTimer,
onRefresh,
scheduleProgressClear,
selectedProject,
setUploadError,
showToast,
],
);
const handleFileSelect = useCallback(
async (fileList: FileList | File[]) => {
await uploadFiles(Array.from(fileList), '');
},
[uploadFiles],
);
const handleDragEnter = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set isDragOver to false if we're leaving the entire tree
@@ -88,103 +402,35 @@ export const useFileTreeUpload = ({
}
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const handleDrop = useCallback(
async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const targetPath = dropTarget || '';
setOperationLoading(true);
const targetPath = dropTarget || '';
try {
const files: File[] = [];
// Use DataTransferItemList for folder support
const items = e.dataTransfer.items;
if (items) {
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (entry) {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => {
(entry as FileSystemFileEntry).file(resolve, reject);
});
files.push(file);
} else if (entry.isDirectory) {
// Pass the directory name as basePath so files include the folder path
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
files.push(...dirFiles);
}
}
}
}
} else {
// Fallback for browsers that don't support webkitGetAsEntry
const fileList = e.dataTransfer.files;
for (const file of Array.from(fileList)) {
files.push(file);
}
}
if (files.length === 0) {
setOperationLoading(false);
try {
const files = await collectDroppedFiles(e.dataTransfer);
await uploadFiles(files, targetPath);
} catch (err) {
const message = err instanceof Error ? err.message : 'Could not read dropped files';
console.error('Upload error:', err);
showToast(message, 'error');
setUploadError(message, 0, targetPath);
setDropTarget(null);
return;
}
},
[dropTarget, setUploadError, showToast, uploadFiles],
);
const formData = new FormData();
formData.append('targetPath', targetPath);
// Store relative paths separately since FormData strips path info from File.name
const relativePaths: string[] = [];
files.forEach((file) => {
// Create a new file with just the filename (without path) for FormData
// but store the relative path separately
const cleanFile = new File([file], file.name.split('/').pop()!, {
type: file.type,
lastModified: file.lastModified
});
formData.append('files', cleanFile);
relativePaths.push(file.name); // Keep the full relative path
});
// Send relative paths as a JSON array
formData.append('relativePaths', JSON.stringify(relativePaths));
const response = await api.post(
// File upload endpoint is keyed by DB projectId post-migration.
`/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
formData
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
showToast(
`Uploaded ${files.length} file(s)`,
'success'
);
onRefresh();
} catch (err) {
console.error('Upload error:', err);
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
} finally {
setOperationLoading(false);
setDropTarget(null);
}
}, [dropTarget, selectedProject, onRefresh, showToast]);
const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => {
e.preventDefault();
e.stopPropagation();
setDropTarget(itemPath);
}, []);
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => {
e.preventDefault();
e.stopPropagation();
setDropTarget(itemPath);
@@ -194,7 +440,9 @@ export const useFileTreeUpload = ({
isDragOver,
dropTarget,
operationLoading,
uploadProgress,
treeRef,
handleFileSelect,
handleDragEnter,
handleDragOver,
handleDragLeave,

View File

@@ -1,6 +1,7 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
import { cn } from '../../../lib/utils';
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
@@ -13,10 +14,12 @@ import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
import { Project } from '../../../types/app';
import { ScrollArea, Input } from '../../../shared/view/ui';
import FileTreeBody from './FileTreeBody';
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
import FileTreeHeader from './FileTreeHeader';
import FileTreeLoadingState from './FileTreeLoadingState';
import FileTreeUploadProgress from './FileTreeUploadProgress';
import ImageViewer from './ImageViewer';
@@ -66,6 +69,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
onRefresh: refreshFiles,
showToast,
});
const operationLoading = operations.operationLoading || upload.operationLoading;
// Focus input when creating new item
useEffect(() => {
@@ -146,14 +150,19 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
onViewModeChange={changeViewMode}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onUploadFiles={upload.handleFileSelect}
onNewFile={() => operations.handleStartCreate('', 'file')}
onNewFolder={() => operations.handleStartCreate('', 'directory')}
onRefresh={refreshFiles}
onCollapseAll={collapseAll}
loading={loading}
operationLoading={operations.operationLoading}
operationLoading={operationLoading}
isUploading={upload.uploadProgress?.status === 'uploading'}
uploadProgress={upload.uploadProgress?.progress ?? null}
/>
<FileTreeUploadProgress upload={upload.uploadProgress} />
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
<ScrollArea className="flex-1 px-2 py-1">
@@ -184,7 +193,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
}, 100);
}}
className="h-6 flex-1 text-sm"
disabled={operations.operationLoading}
disabled={operationLoading}
/>
</div>
)}
@@ -213,7 +222,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
handleConfirmRename={operations.handleConfirmRename}
handleCancelRename={operations.handleCancelRename}
renameInputRef={renameInputRef}
operationLoading={operations.operationLoading}
operationLoading={operationLoading}
/>
</ScrollArea>
@@ -251,17 +260,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
<div className="flex justify-end gap-2">
<button
onClick={operations.handleCancelDelete}
disabled={operations.operationLoading}
disabled={operationLoading}
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={operations.handleConfirmDelete}
disabled={operations.operationLoading}
disabled={operationLoading}
className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
>
{operations.operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{t('fileTree.delete.confirm', 'Delete')}
</button>
</div>

View File

@@ -1,7 +1,11 @@
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';
import { useRef } from 'react';
import type { ChangeEvent } from 'react';
import { ChevronDown, Eye, FileText, FolderPlus, List, Loader2, RefreshCw, Search, TableProperties, Upload, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../shared/view/ui';
import { cn } from '../../../lib/utils';
import { MAX_FILE_UPLOAD_SIZE_LABEL } from '../constants/constants';
import type { FileTreeViewMode } from '../types/types';
type FileTreeHeaderProps = {
@@ -12,11 +16,14 @@ type FileTreeHeaderProps = {
// Toolbar actions
onNewFile?: () => void;
onNewFolder?: () => void;
onUploadFiles?: (files: FileList) => void;
onRefresh?: () => void;
onCollapseAll?: () => void;
// Loading state
loading?: boolean;
operationLoading?: boolean;
isUploading?: boolean;
uploadProgress?: number | null;
};
export default function FileTreeHeader({
@@ -26,12 +33,24 @@ export default function FileTreeHeader({
onSearchQueryChange,
onNewFile,
onNewFolder,
onUploadFiles,
onRefresh,
onCollapseAll,
loading,
operationLoading,
isUploading,
uploadProgress,
}: FileTreeHeaderProps) {
const { t } = useTranslation();
const uploadInputRef = useRef<HTMLInputElement>(null);
const handleUploadInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const { files } = event.target;
if (files && files.length > 0) {
onUploadFiles?.(files);
}
event.target.value = '';
};
return (
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
@@ -40,6 +59,50 @@ export default function FileTreeHeader({
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
<div className="flex items-center gap-0.5">
{/* Action buttons */}
{onUploadFiles && (
<>
<input
ref={uploadInputRef}
type="file"
multiple
className="hidden"
onChange={handleUploadInputChange}
tabIndex={-1}
aria-hidden="true"
/>
<Button
variant="ghost"
size="sm"
className="relative h-7 w-7 p-0"
onClick={() => uploadInputRef.current?.click()}
title={
isUploading
? t('fileTree.uploadingFiles', 'Uploading files')
: t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
size: MAX_FILE_UPLOAD_SIZE_LABEL,
})
}
aria-label={t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
size: MAX_FILE_UPLOAD_SIZE_LABEL,
})}
disabled={operationLoading}
>
{isUploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{isUploading && typeof uploadProgress === 'number' && (
<span className="absolute bottom-0.5 left-1/2 h-0.5 w-4 -translate-x-1/2 overflow-hidden rounded-full bg-primary/20">
<span
className="block h-full rounded-full bg-primary transition-[width] duration-150"
style={{ width: `${uploadProgress}%` }}
/>
</span>
)}
</Button>
</>
)}
{onNewFile && (
<Button
variant="ghost"

View File

@@ -0,0 +1,90 @@
import { AlertCircle, CheckCircle2, Upload } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import type { FileTreeUploadProgressState } from '../hooks/useFileTreeUpload';
type FileTreeUploadProgressProps = {
upload: FileTreeUploadProgressState | null;
};
const clampProgress = (progress: number) => Math.min(100, Math.max(0, progress));
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
export default function FileTreeUploadProgress({ upload }: FileTreeUploadProgressProps) {
const { t } = useTranslation();
if (!upload) {
return null;
}
const progress = clampProgress(upload.progress);
const isUploading = upload.status === 'uploading';
const isComplete = upload.status === 'complete';
const isError = upload.status === 'error';
const fileSummary =
upload.fileCount === 1 && upload.fileName
? upload.fileName
: `${upload.fileCount} ${pluralizeFiles(upload.fileCount)}`;
const title = isUploading
? t('fileTree.uploadingFiles', 'Uploading files')
: isComplete
? t('fileTree.uploadComplete', 'Upload complete')
: t('fileTree.uploadFailed', 'Upload failed');
const detail = isError
? upload.error
: isComplete && typeof upload.uploadedCount === 'number'
? t('fileTree.uploadedCount', 'Uploaded {{uploaded}} of {{total}} {{label}}', {
uploaded: upload.uploadedCount,
total: upload.fileCount,
label: pluralizeFiles(upload.fileCount),
})
: fileSummary;
const Icon = isError ? AlertCircle : isComplete ? CheckCircle2 : Upload;
return (
<div
className={cn(
'border-b px-3 py-2 transition-colors',
isError
? 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
: isComplete
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
: 'border-primary/20 bg-primary/10 text-foreground',
)}
>
<div className="flex min-h-[36px] items-center gap-2">
<div
className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md',
isError ? 'bg-red-500/15' : isComplete ? 'bg-emerald-500/15' : 'bg-primary/15',
)}
>
<Icon className={cn('h-3.5 w-3.5', isUploading && 'animate-pulse')} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-xs font-medium">{title}</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{isUploading ? `${progress}%` : isComplete ? t('common.done', 'Done') : t('common.failed', 'Failed')}
</span>
</div>
<div className="mt-1 truncate text-[11px] text-muted-foreground">{detail}</div>
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-background/80">
<div
className={cn(
'h-full rounded-full transition-[width] duration-200',
isError ? 'bg-red-500' : isComplete ? 'bg-emerald-500' : 'bg-primary',
)}
style={{ width: `${isError ? Math.max(progress, 8) : progress}%` }}
/>
</div>
</div>
);
}

View File

@@ -84,6 +84,15 @@ import itCodeEditor from './locales/it/codeEditor.json';
// eslint-disable-next-line import-x/order
import itTasks from './locales/it/tasks.json';
import zhTWCommon from './locales/zh-TW/common.json';
import zhTWSettings from './locales/zh-TW/settings.json';
import zhTWAuth from './locales/zh-TW/auth.json';
import zhTWSidebar from './locales/zh-TW/sidebar.json';
import zhTWChat from './locales/zh-TW/chat.json';
import zhTWCodeEditor from './locales/zh-TW/codeEditor.json';
// eslint-disable-next-line import-x/order
import zhTWTasks from './locales/zh-TW/tasks.json';
// Import supported languages configuration
import { languages } from './languages.js';
@@ -178,6 +187,15 @@ i18n
codeEditor: itCodeEditor,
tasks: itTasks,
},
'zh-TW': {
common: zhTWCommon,
settings: zhTWSettings,
auth: zhTWAuth,
sidebar: zhTWSidebar,
chat: zhTWChat,
codeEditor: zhTWCodeEditor,
tasks: zhTWTasks,
},
},
// Default language

View File

@@ -24,6 +24,11 @@ export const languages = [
label: 'Simplified Chinese',
nativeName: '简体中文',
},
{
value: 'zh-TW',
label: 'Traditional Chinese',
nativeName: '繁體中文',
},
{
value: 'ja',
label: 'Japanese',

View File

@@ -0,0 +1,37 @@
{
"login": {
"title": "歡迎回來",
"description": "登入您的 CloudCLI 帳戶",
"username": "使用者名稱",
"password": "密碼",
"submit": "登入",
"loading": "登入中...",
"errors": {
"invalidCredentials": "使用者名稱或密碼無效",
"requiredFields": "請填寫所有欄位",
"networkError": "網路錯誤,請重試。"
},
"placeholders": {
"username": "輸入您的使用者名稱",
"password": "輸入您的密碼"
}
},
"register": {
"title": "建立帳戶",
"username": "使用者名稱",
"password": "密碼",
"confirmPassword": "確認密碼",
"submit": "建立帳戶",
"loading": "建立帳戶中...",
"errors": {
"passwordMismatch": "密碼不一致",
"usernameTaken": "使用者名稱已被使用",
"weakPassword": "密碼強度太弱"
}
},
"logout": {
"title": "登出",
"confirm": "確定要登出嗎?",
"button": "登出"
}
}

View File

@@ -0,0 +1,257 @@
{
"codeBlock": {
"copy": "複製",
"copied": "已複製",
"copyCode": "複製程式碼"
},
"copyMessage": {
"copy": "複製訊息",
"copied": "訊息已複製",
"selectFormat": "選擇複製格式",
"copyAsMarkdown": "複製為 Markdown",
"copyAsText": "複製為純文字"
},
"messageTypes": {
"user": "U",
"error": "錯誤",
"tool": "工具",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
"gemini": "Gemini"
},
"tools": {
"settings": "工具設定",
"error": "工具錯誤",
"result": "工具結果",
"viewParams": "查看輸入參數",
"viewRawParams": "查看原始參數",
"viewDiff": "查看編輯差異",
"creatingFile": "建立新檔案:",
"updatingTodo": "更新待辦事項",
"read": "讀取",
"readFile": "讀取檔案",
"updateTodo": "更新待辦清單",
"readTodo": "讀取待辦清單",
"searchResults": "結果"
},
"search": {
"found": "找到 {{count}} 個{{type}}",
"file": "檔案",
"files": "檔案",
"pattern": "模式:",
"in": "在:"
},
"fileOperations": {
"updated": "檔案更新成功",
"created": "檔案建立成功",
"written": "檔案寫入成功",
"diff": "差異",
"newFile": "新檔案",
"viewContent": "查看檔案內容",
"viewFullOutput": "查看完整輸出({{count}} 個字元)",
"contentDisplayed": "檔案內容顯示在上方的差異檢視中"
},
"interactive": {
"title": "互動式提示",
"waiting": "等待您在 CLI 中回應",
"instruction": "請在 Claude 執行的終端機中選擇一個選項。",
"selectedOption": "✓ Claude 選擇了選項 {{number}}",
"instructionDetail": "在 CLI 中,您可以使用方向鍵或輸入數字來互動式地選擇此選項。"
},
"thinking": {
"title": "思考中...",
"emoji": "💭 思考中..."
},
"json": {
"response": "JSON 回應"
},
"permissions": {
"grant": "授予 {{tool}} 權限",
"added": "權限已新增",
"addTo": "將 {{entry}} 加入允許的工具。",
"retry": "權限已儲存。重試請求以使用該工具。",
"error": "無法更新權限。請重試。",
"openSettings": "開啟設定"
},
"todo": {
"updated": "待辦清單已成功更新",
"current": "目前待辦清單"
},
"plan": {
"viewPlan": "📋 查看實作計畫",
"title": "實作計畫"
},
"usageLimit": {
"resetAt": "Claude 使用限制已達到。您的限制將在 **{{time}} {{timezone}}** - {{date}} 重置"
},
"codex": {
"permissionMode": "權限模式",
"modes": {
"default": "預設模式",
"auto": "自動模式",
"acceptEdits": "編輯模式",
"bypassPermissions": "無限制模式",
"plan": "計畫模式"
},
"descriptions": {
"default": "只有受信任的指令ls、cat、grep、git status 等)自動執行。其他指令將被略過。可以寫入工作區。",
"auto": "由模型分類器針對每次工具呼叫決定核准或拒絕。免手動操作,但比 Bypass 安全——仍可能發生拒絕。",
"acceptEdits": "工作區內的所有指令自動執行。完全自動模式,具有沙箱執行功能。",
"bypassPermissions": "完全的系統存取,無限制。所有指令自動執行,具有完整的磁碟和網路存取權限。請謹慎使用。",
"plan": "計畫模式 - 不執行任何指令"
},
"technicalDetails": "技術細節"
},
"input": {
"placeholder": "輸入 / 叫用指令,@ 選取檔案,或向 {{provider}} 提問...",
"placeholderDefault": "輸入您的訊息...",
"disabled": "輸入已停用",
"attachFiles": "附加檔案",
"attachImages": "附加圖片",
"send": "傳送",
"stop": "停止",
"hintText": {
"ctrlEnter": "Ctrl+Enter 傳送 • Shift+Enter 換行 • Tab 切換模式 • / 斜線指令",
"enter": "Enter 傳送 • Shift+Enter 換行 • Tab 切換模式 • / 斜線指令"
},
"clickToChangeMode": "點擊變更權限模式(或在輸入框中按 Tab",
"showAllCommands": "顯示所有指令",
"clearInput": "清空輸入",
"scrollToBottom": "捲動到底部"
},
"thinkingMode": {
"selector": {
"title": "思考模式",
"description": "延伸思考讓 Claude 有更多時間來評估替代方案",
"active": "啟用",
"tip": "更高的思考模式需要更多時間,但提供更徹底的分析"
},
"modes": {
"none": {
"name": "標準",
"description": "一般 Claude 回應",
"prefix": ""
},
"think": {
"name": "思考",
"description": "基本延伸思考",
"prefix": "思考"
},
"thinkHard": {
"name": "深入思考",
"description": "更徹底的評估",
"prefix": "深入思考"
},
"thinkHarder": {
"name": "更深入思考",
"description": "考慮替代方案的深度分析",
"prefix": "更深入思考"
},
"ultrathink": {
"name": "超級思考",
"description": "最大思考預算",
"prefix": "超級思考"
}
},
"buttonTitle": "思考模式:{{mode}}"
},
"providerSelection": {
"title": "選擇您的 AI 助手",
"description": "選擇一個提供者以開始新對話",
"selectModel": "選擇模型",
"providerInfo": {
"anthropic": "由 Anthropic 提供",
"openai": "由 OpenAI 提供",
"cursorEditor": "AI 程式碼編輯器",
"google": "由 Google 提供"
},
"readyPrompt": {
"claude": "準備好使用 {{model}} 的 Claude。請在下方開始輸入您的訊息。",
"cursor": "準備好使用 {{model}} 的 Cursor。請在下方開始輸入您的訊息。",
"codex": "準備好使用 {{model}} 的 Codex。請在下方開始輸入您的訊息。",
"gemini": "準備好使用 {{model}} 的 Gemini。請在下方開始輸入您的訊息。",
"default": "請在上方選擇一個提供者以開始"
},
"pressToSearch": "按 <kbd>{{shortcut}}</kbd> 搜尋工作階段、檔案和提交"
},
"session": {
"continue": {
"title": "繼續您的對話",
"description": "詢問有關程式碼的問題、要求修改或取得開發任務的協助"
},
"loading": {
"olderMessages": "正在載入較早的訊息...",
"sessionMessages": "正在載入工作階段訊息..."
},
"messages": {
"showingOf": "顯示 {{shown}} / {{total}} 則訊息",
"scrollToLoad": "向上捲動以載入更多",
"showingLast": "顯示最近 {{count}} 則訊息(共 {{total}} 則)",
"loadEarlier": "載入較早的訊息",
"loadAll": "載入全部訊息",
"loadingAll": "正在載入全部訊息...",
"allLoaded": "全部訊息已載入",
"perfWarning": "已載入全部訊息 - 捲動可能變慢。點擊「捲動到底部」恢復效能。"
}
},
"shell": {
"selectProject": {
"title": "選擇專案",
"description": "選擇一個專案以在該目錄中開啟互動式 Shell"
},
"status": {
"newSession": "新工作階段",
"initializing": "初始化中...",
"restarting": "重新啟動中..."
},
"actions": {
"disconnect": "中斷連線",
"disconnectTitle": "中斷 Shell 連線",
"restart": "重新啟動",
"restartTitle": "重新啟動 Shell請先中斷連線",
"connect": "在 Shell 中繼續",
"connectTitle": "連線到 Shell"
},
"loading": "正在載入終端機...",
"connecting": "正在連線到 Shell...",
"startSession": "啟動新的 Claude 工作階段",
"resumeSession": "恢復工作階段:{{displayName}}...",
"runCommand": "在 {{projectName}} 中執行 {{command}}",
"startCli": "在 {{projectName}} 中啟動 Claude CLI",
"defaultCommand": "指令"
},
"claudeStatus": {
"actions": {
"thinking": "思考中",
"processing": "處理中",
"analyzing": "分析中",
"working": "執行中",
"computing": "運算中",
"reasoning": "推理中"
},
"state": {
"live": "進行中",
"paused": "已暫停"
},
"elapsed": {
"seconds": "{{count}} 秒",
"minutesSeconds": "{{minutes}} 分 {{seconds}} 秒",
"label": "已過 {{time}}",
"startingNow": "正要開始"
},
"controls": {
"stopGeneration": "停止生成",
"pressEscToStop": "隨時按 Esc 即可停止"
},
"providers": {
"assistant": "助理"
}
},
"projectSelection": {
"startChatWithProvider": "選擇一個專案以開始與 {{provider}} 聊天"
},
"tasks": {
"nextTaskPrompt": "開始下一個任務"
}
}

View File

@@ -0,0 +1,34 @@
{
"toolbar": {
"changes": "個變更",
"previousChange": "上一個變更",
"nextChange": "下一個變更",
"hideDiff": "隱藏差異醒目提示",
"showDiff": "顯示差異醒目提示",
"settings": "編輯器設定",
"collapse": "收合編輯器",
"expand": "展開編輯器到全寬"
},
"loading": "正在載入 {{fileName}}...",
"header": {
"showingChanges": "顯示變更"
},
"actions": {
"download": "下載檔案",
"save": "儲存",
"saving": "儲存中...",
"saved": "已儲存!",
"exitFullscreen": "離開全螢幕",
"fullscreen": "全螢幕",
"close": "關閉"
},
"footer": {
"lines": "行數:",
"characters": "字元數:",
"shortcuts": "按 Ctrl+S 儲存 • Esc 關閉"
},
"binaryFile": {
"title": "二進位檔案",
"message": "檔案「{{fileName}}」無法在文字編輯器中顯示,因為它是二進位檔案。"
}
}

View File

@@ -0,0 +1,267 @@
{
"buttons": {
"save": "儲存",
"cancel": "取消",
"delete": "刪除",
"create": "建立",
"edit": "編輯",
"close": "關閉",
"confirm": "確認",
"submit": "送出",
"retry": "重試",
"refresh": "重新整理",
"search": "搜尋",
"clear": "清除",
"copy": "複製",
"download": "下載",
"upload": "上傳",
"browse": "瀏覽"
},
"tabs": {
"chat": "聊天",
"shell": "終端機",
"files": "檔案",
"git": "版本控制",
"tasks": "任務"
},
"status": {
"loading": "載入中...",
"success": "成功",
"error": "錯誤",
"failed": "失敗",
"pending": "待處理",
"completed": "已完成",
"inProgress": "進行中"
},
"messages": {
"savedSuccessfully": "儲存成功",
"deletedSuccessfully": "刪除成功",
"updatedSuccessfully": "更新成功",
"operationFailed": "操作失敗",
"networkError": "網路錯誤,請檢查您的連線。",
"unauthorized": "未授權,請登入。",
"notFound": "找不到",
"invalidInput": "輸入無效",
"requiredField": "此欄位為必填",
"unknownError": "發生未知錯誤"
},
"navigation": {
"settings": "設定",
"home": "首頁",
"back": "返回",
"next": "下一步",
"previous": "上一步",
"logout": "登出"
},
"common": {
"language": "語言",
"theme": "佈景主題",
"darkMode": "深色模式",
"lightMode": "淺色模式",
"name": "名稱",
"description": "描述",
"enabled": "已啟用",
"disabled": "已停用",
"optional": "選填",
"version": "版本",
"select": "選取",
"selectAll": "全選",
"deselectAll": "取消全選"
},
"time": {
"justNow": "剛剛",
"minutesAgo": "{{count}} 分鐘前",
"hoursAgo": "{{count}} 小時前",
"daysAgo": "{{count}} 天前",
"yesterday": "昨天"
},
"fileOperations": {
"newFile": "新增檔案",
"newFolder": "新增資料夾",
"rename": "重新命名",
"move": "移動",
"copyPath": "複製路徑",
"openInEditor": "在編輯器中開啟"
},
"mainContent": {
"loading": "正在載入 CloudCLI",
"settingUpWorkspace": "正在設定您的工作區...",
"chooseProject": "選擇您的專案",
"selectProjectDescription": "從側邊欄選擇一個專案以開始使用 Claude 進行程式開發。每個專案包含您的聊天紀錄和檔案歷史。",
"tip": "提示",
"createProjectMobile": "點擊上方的選單按鈕以存取專案",
"createProjectDesktop": "點擊側邊欄中的資料夾圖示以建立新專案",
"newSession": "新工作階段",
"untitledSession": "未命名工作階段",
"projectFiles": "專案檔案"
},
"fileTree": {
"loading": "正在載入檔案...",
"files": "檔案",
"simpleView": "簡易檢視",
"compactView": "精簡檢視",
"detailedView": "詳細檢視",
"searchPlaceholder": "搜尋檔案和資料夾...",
"clearSearch": "清除搜尋",
"name": "名稱",
"size": "大小",
"modified": "修改時間",
"permissions": "權限",
"noFilesFound": "找不到檔案",
"checkProjectPath": "請檢查專案路徑是否可存取",
"noMatchesFound": "找不到符合項目",
"tryDifferentSearch": "嘗試不同的搜尋詞或清除搜尋",
"justNow": "剛剛",
"minAgo": "{{count}} 分鐘前",
"hoursAgo": "{{count}} 小時前",
"daysAgo": "{{count}} 天前",
"newFile": "新增檔案 (Cmd+N)",
"newFolder": "新增資料夾 (Cmd+Shift+N)",
"refresh": "重新整理",
"collapseAll": "全部收合",
"context": {
"rename": "重新命名",
"delete": "刪除",
"copyPath": "複製路徑",
"download": "下載",
"newFile": "新增檔案",
"newFolder": "新增資料夾",
"refresh": "重新整理",
"menuLabel": "檔案右鍵選單",
"loading": "載入中..."
}
},
"projectWizard": {
"title": "建立新專案",
"steps": {
"type": "類型",
"configure": "設定",
"confirm": "確認"
},
"step1": {
"question": "您已經有工作區,還是想建立一個新的工作區?",
"existing": {
"title": "現有工作區",
"description": "我的伺服器上已經有工作區,只需要將其加入專案列表"
},
"new": {
"title": "新建工作區",
"description": "建立新工作區,可選擇從 GitHub 儲存庫複製"
}
},
"step2": {
"existingPath": "工作區路徑",
"newPath": "工作區路徑",
"existingPlaceholder": "/path/to/existing/workspace",
"newPlaceholder": "/path/to/new/workspace",
"existingHelp": "您現有工作區目錄的完整路徑",
"newHelp": "工作區目錄的完整路徑",
"githubUrl": "GitHub URL選填",
"githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "選填:提供 GitHub URL 以複製儲存庫",
"githubAuth": "GitHub 身分驗證(選填)",
"githubAuthHelp": "僅私有儲存庫需要。公開儲存庫無需身分驗證即可複製。",
"loadingTokens": "正在載入已儲存的權杖...",
"storedToken": "已儲存的權杖",
"newToken": "新權杖",
"nonePublic": "無(公開)",
"selectToken": "選取權杖",
"selectTokenPlaceholder": "-- 選取權杖 --",
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tokenHelp": "此權杖僅用於此操作",
"publicRepoInfo": "公開儲存庫不需要身分驗證。如果複製公開儲存庫,可以略過提供權杖。",
"noTokensHelp": "沒有可用的已儲存權杖。您可以在 設定 → API 金鑰 中新增權杖以便重複使用。",
"optionalTokenPublic": "GitHub 權杖(公開儲存庫可選)",
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx公開儲存庫可留空"
},
"step3": {
"reviewConfig": "檢閱您的設定",
"existingWorkspace": "現有工作區",
"newWorkspace": "新建工作區",
"path": "路徑:",
"cloneFrom": "複製自:",
"authentication": "身分驗證:",
"usingStoredToken": "使用已儲存的權杖:",
"usingProvidedToken": "使用提供的權杖",
"noAuthentication": "無身分驗證",
"sshKey": "SSH 金鑰",
"existingInfo": "工作區將加入您的專案列表,並可用於 Claude/Cursor 工作階段。",
"newWithClone": "儲存庫將從此資料夾複製。",
"newEmpty": "工作區將加入您的專案列表,並可用於 Claude/Cursor 工作階段。",
"cloningRepository": "正在複製儲存庫..."
},
"buttons": {
"cancel": "取消",
"back": "返回",
"next": "下一步",
"createProject": "建立專案",
"creating": "建立中...",
"cloning": "正在複製..."
},
"errors": {
"selectType": "請選擇您已有現有工作區還是想建立新工作區",
"providePath": "請提供工作區路徑",
"failedToCreate": "建立工作區失敗",
"failedToCreateFolder": "建立資料夾失敗"
}
},
"notifications": {
"genericTool": "工具",
"codes": {
"generic": {
"info": {
"title": "通知"
}
},
"permission": {
"required": {
"title": "需要處理",
"body": "{{toolName}} 正在等待你的決定。"
}
},
"run": {
"stopped": {
"title": "執行已停止",
"body": "原因:{{reason}}"
},
"failed": {
"title": "執行失敗"
}
},
"agent": {
"notification": {
"title": "Agent 通知"
}
}
}
},
"versionUpdate": {
"title": "有可用更新",
"newVersionReady": "新版本已準備就緒",
"currentVersion": "目前版本",
"latestVersion": "最新版本",
"whatsNew": "新功能:",
"viewFullRelease": "查看完整發行說明",
"updateProgress": "更新進度:",
"manualUpgrade": "手動升級:",
"npmUpgradeCommand": "npm install -g @cloudcli-ai/cloudcli@latest",
"manualUpgradeHint": "或點擊「立即更新」以自動執行更新。",
"updateCompleted": "更新成功完成!",
"restartServer": "請重新啟動伺服器以套用變更。",
"updateFailed": "更新失敗",
"buttons": {
"close": "關閉",
"later": "稍後",
"copyCommand": "複製指令",
"updateNow": "立即更新",
"updating": "更新中..."
},
"ariaLabels": {
"closeModal": "關閉版本升級對話框",
"showSidebar": "顯示側邊欄",
"settings": "設定",
"updateAvailable": "有可用更新",
"closeSidebar": "關閉側邊欄"
}
}
}

View File

@@ -0,0 +1,484 @@
{
"title": "設定",
"tabs": {
"account": "帳戶",
"permissions": "權限",
"mcpServers": "MCP 伺服器",
"appearance": "外觀"
},
"account": {
"title": "帳戶",
"language": "語言",
"languageLabel": "顯示語言",
"languageDescription": "選擇您偏好的介面語言",
"username": "使用者名稱",
"email": "電子郵件",
"profile": "個人檔案",
"changePassword": "變更密碼"
},
"mcp": {
"title": "MCP 伺服器",
"addServer": "新增伺服器",
"editServer": "編輯伺服器",
"deleteServer": "刪除伺服器",
"serverName": "伺服器名稱",
"serverType": "伺服器類型",
"config": "設定",
"testConnection": "測試連線",
"status": "狀態",
"connected": "已連線",
"disconnected": "未連線",
"scope": {
"label": "範圍",
"user": "使用者",
"project": "專案"
}
},
"appearance": {
"title": "外觀",
"theme": "佈景主題",
"codeEditor": "程式碼編輯器",
"editorTheme": "編輯器佈景主題",
"wordWrap": "自動換行",
"showMinimap": "顯示縮圖",
"lineNumbers": "行號",
"fontSize": "字型大小"
},
"actions": {
"saveChanges": "儲存變更",
"resetToDefaults": "重設為預設值",
"cancelChanges": "取消變更"
},
"quickSettings": {
"title": "快速設定",
"sections": {
"appearance": "外觀",
"toolDisplay": "工具顯示",
"viewOptions": "檢視選項",
"inputSettings": "輸入設定"
},
"darkMode": "深色模式",
"autoExpandTools": "自動展開工具",
"showRawParameters": "顯示原始參數",
"showThinking": "顯示思考過程",
"autoScrollToBottom": "自動捲動到底部",
"sendByCtrlEnter": "使用 Ctrl+Enter 傳送",
"sendByCtrlEnterDescription": "啟用後,按 Ctrl+Enter 傳送訊息,而不是僅按 Enter。這對於使用輸入法的使用者可以避免意外傳送。",
"dragHandle": {
"dragging": "正在拖曳手柄",
"closePanel": "關閉設定面板",
"openPanel": "開啟設定面板",
"draggingStatus": "正在拖曳...",
"toggleAndMove": "點擊切換,拖曳移動"
}
},
"terminalShortcuts": {
"title": "終端機快速鍵",
"sectionKeys": "按鍵",
"sectionNavigation": "導覽",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "向上箭頭",
"arrowDown": "向下箭頭",
"scrollDown": "捲動到底部",
"handle": {
"closePanel": "關閉快速鍵面板",
"openPanel": "開啟快速鍵面板"
}
},
"mainTabs": {
"label": "設定",
"agents": "智慧代理",
"appearance": "外觀",
"git": "Git",
"apiTokens": "API 和權杖",
"tasks": "任務",
"notifications": "通知",
"plugins": "外掛",
"about": "關於"
},
"notifications": {
"title": "通知",
"description": "控制你希望接收的通知事件。",
"webPush": {
"title": "Web 推播通知",
"enable": "啟用推播通知",
"disable": "關閉推播通知",
"enabled": "推播通知已啟用",
"loading": "更新中...",
"unsupported": "此瀏覽器不支援推播通知。",
"denied": "推播通知已被封鎖,請在瀏覽器設定中允許。"
},
"events": {
"title": "事件類型",
"actionRequired": "需要處理",
"stop": "執行已停止",
"error": "執行失敗"
}
},
"appearanceSettings": {
"darkMode": {
"label": "深色模式",
"description": "切換淺色和深色佈景主題"
},
"projectSorting": {
"label": "專案排序",
"description": "專案在側邊欄中的排列方式",
"alphabetical": "依字母順序",
"recentActivity": "最近活動"
},
"codeEditor": {
"title": "程式碼編輯器",
"theme": {
"label": "編輯器佈景主題",
"description": "程式碼編輯器的預設佈景主題"
},
"wordWrap": {
"label": "自動換行",
"description": "在編輯器中預設啟用自動換行"
},
"showMinimap": {
"label": "顯示縮圖",
"description": "在差異檢視中顯示縮圖以便於導覽"
},
"lineNumbers": {
"label": "顯示行號",
"description": "在編輯器中顯示行號"
},
"fontSize": {
"label": "字型大小",
"description": "編輯器字型大小px"
}
}
},
"mcpForm": {
"title": {
"add": "新增 MCP 伺服器",
"edit": "編輯 MCP 伺服器"
},
"importMode": {
"form": "表單輸入",
"json": "JSON 匯入"
},
"scope": {
"label": "範圍",
"userGlobal": "使用者(全域)",
"projectLocal": "專案(本機)",
"userDescription": "使用者範圍:在您機器上的所有專案中可用",
"projectDescription": "本機範圍:僅在選定專案中可用",
"cannotChange": "編輯現有伺服器時無法變更範圍"
},
"fields": {
"serverName": "伺服器名稱",
"transportType": "傳輸類型",
"command": "指令",
"arguments": "參數(每行一個)",
"jsonConfig": "JSON 設定",
"url": "URL",
"envVars": "環境變數KEY=值,每行一個)",
"headers": "標頭KEY=值,每行一個)",
"selectProject": "選取專案..."
},
"placeholders": {
"serverName": "我的服務"
},
"validation": {
"missingType": "缺少必填欄位type",
"stdioRequiresCommand": "stdio 類型需要 command 欄位",
"httpRequiresUrl": "{{type}} 類型需要 url 欄位",
"invalidJson": "無效的 JSON 格式",
"jsonHelp": "貼上您的 MCP 伺服器設定JSON 格式)。範例格式:",
"jsonExampleStdio": "• stdio: {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
"jsonExampleHttp": "• http/sse: {\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}"
},
"configDetails": "設定詳細資訊(來自 {{configFile}}",
"projectPath": "路徑:{{path}}",
"actions": {
"cancel": "取消",
"saving": "儲存中...",
"addServer": "新增伺服器",
"updateServer": "更新伺服器"
}
},
"saveStatus": {
"success": "設定儲存成功!",
"error": "儲存設定失敗",
"saving": "儲存中..."
},
"footerActions": {
"save": "儲存設定",
"cancel": "取消"
},
"git": {
"title": "Git 設定",
"description": "設定您的 git 提交身分。這些設定將透過 git config --global 全域套用",
"name": {
"label": "Git 名稱",
"help": "您的 git 提交名稱"
},
"email": {
"label": "Git 電子郵件",
"help": "您的 git 提交電子郵件"
},
"actions": {
"save": "儲存設定",
"saving": "儲存中..."
},
"status": {
"success": "儲存成功"
}
},
"apiKeys": {
"title": "API 金鑰",
"description": "產生 API 金鑰以從其他應用程式存取外部 API。",
"newKey": {
"alertTitle": "⚠️ 儲存您的 API 金鑰",
"alertMessage": "這是您唯一一次看到此金鑰。請妥善保存。",
"iveSavedIt": "我已儲存"
},
"form": {
"placeholder": "API 金鑰名稱(例如:正式伺服器)",
"createButton": "建立",
"cancelButton": "取消"
},
"newButton": "新增 API 金鑰",
"empty": "尚未建立 API 金鑰。",
"list": {
"created": "建立時間:",
"lastUsed": "最後使用:"
},
"confirmDelete": "確定要刪除此 API 金鑰嗎?",
"status": {
"active": "啟用",
"inactive": "未啟用"
},
"github": {
"title": "GitHub 權杖",
"description": "新增 GitHub 個人存取權杖以透過外部 API 複製私有儲存庫。",
"descriptionAlt": "新增 GitHub 個人存取權杖以複製私有儲存庫。您也可以直接在 API 請求中傳遞權杖而無需儲存。",
"addButton": "新增權杖",
"form": {
"namePlaceholder": "權杖名稱(例如:個人儲存庫)",
"tokenPlaceholder": "GitHub 個人存取權杖ghp_...",
"descriptionPlaceholder": "描述(選填)",
"addButton": "新增權杖",
"cancelButton": "取消",
"howToCreate": "如何建立 GitHub 個人存取權杖 →"
},
"empty": "尚未新增 GitHub 權杖。",
"added": "新增時間:",
"confirmDelete": "確定要刪除此 GitHub 權杖嗎?"
},
"apiDocsLink": "API 文件",
"documentation": {
"title": "外部 API 文件",
"description": "了解如何使用外部 API 從您的應用程式觸發 Claude/Cursor 工作階段。",
"viewLink": "查看 API 文件 →"
},
"loading": "載入中...",
"version": {
"updateAvailable": "有可用更新v{{version}}"
}
},
"tasks": {
"checking": "正在檢查 TaskMaster 安裝...",
"notInstalled": {
"title": "未安裝 TaskMaster AI CLI",
"description": "需要 TaskMaster CLI 才能使用任務管理功能。安裝它以開始使用:",
"installCommand": "npm install -g task-master-ai",
"viewOnGitHub": "在 GitHub 上查看",
"afterInstallation": "安裝後:",
"steps": {
"restart": "重新啟動此應用程式",
"autoAvailable": "TaskMaster 功能將自動啟用",
"initCommand": "在專案目錄中使用 task-master init"
}
},
"settings": {
"enableLabel": "啟用 TaskMaster 整合",
"enableDescription": "在整個介面中顯示 TaskMaster 任務、橫幅和側邊欄指示器"
}
},
"agents": {
"authStatus": {
"checking": "檢查中...",
"connected": "已連線",
"notConnected": "未連線",
"disconnected": "已中斷連線",
"checkingAuth": "正在檢查驗證狀態...",
"loggedInAs": "登入為 {{email}}",
"authenticatedUser": "已驗證使用者"
},
"account": {
"claude": {
"description": "Anthropic Claude AI 助手"
},
"cursor": {
"description": "Cursor AI 驅動的程式碼編輯器"
},
"codex": {
"description": "OpenAI Codex AI 助手"
},
"gemini": {
"description": "Google Gemini AI 助手"
}
},
"connectionStatus": "連線狀態",
"login": {
"title": "登入",
"reAuthenticate": "重新驗證",
"description": "登入您的 {{agent}} 帳戶以啟用 AI 功能",
"reAuthDescription": "使用其他帳戶登入或重新整理憑證",
"button": "登入",
"reLoginButton": "重新登入"
},
"error": "錯誤:{{error}}"
},
"permissions": {
"title": "權限設定",
"skipPermissions": {
"label": "略過權限提示(請謹慎使用)",
"claudeDescription": "等同於 --dangerously-skip-permissions 旗標",
"cursorDescription": "等同於 Cursor CLI 中的 -f 旗標"
},
"allowedTools": {
"title": "允許的工具",
"description": "無需權限提示即可自動使用的工具",
"placeholder": "例如:\"Bash(git log:*)\" 或 \"Write\"",
"quickAdd": "快速新增常用工具:",
"empty": "未設定允許的工具"
},
"blockedTools": {
"title": "停用的工具",
"description": "無需權限提示即可自動停用的工具",
"placeholder": "例如:\"Bash(rm:*)\"",
"empty": "未設定停用的工具"
},
"allowedCommands": {
"title": "允許的 Shell 指令",
"description": "無需權限提示即可自動執行的 Shell 指令",
"placeholder": "例如:\"Shell(ls)\" 或 \"Shell(git status)\"",
"quickAdd": "快速新增常用指令:",
"empty": "未設定允許的指令"
},
"blockedCommands": {
"title": "封鎖的 Shell 指令",
"description": "自動封鎖的 Shell 指令",
"placeholder": "例如:\"Shell(rm -rf)\" 或 \"Shell(sudo)\"",
"empty": "未設定封鎖的指令"
},
"toolExamples": {
"title": "工具模式範例:",
"bashGitLog": "- 允許所有 git log 指令",
"bashGitDiff": "- 允許所有 git diff 指令",
"write": "- 允許所有 Write 工具使用",
"bashRm": "- 封鎖所有 rm 指令(危險)"
},
"shellExamples": {
"title": "Shell 指令範例:",
"ls": "- 允許 ls 指令",
"gitStatus": "- 允許 git status",
"npmInstall": "- 允許 npm install",
"rmRf": "- 封鎖遞迴刪除"
},
"codex": {
"permissionMode": "權限模式",
"description": "控制 Codex 如何處理檔案修改和指令執行",
"modes": {
"default": {
"title": "預設",
"description": "只有受信任的指令ls、cat、grep、git status 等)會自動執行。其他指令將被略過。可以寫入工作區。"
},
"acceptEdits": {
"title": "接受編輯",
"description": "所有指令在工作區內自動執行。具有沙箱執行的全自動模式。"
},
"bypassPermissions": {
"title": "略過權限",
"description": "完全系統存取,無任何限制。所有指令自動執行,具有完整的磁碟和網路存取權限。請謹慎使用。"
}
},
"technicalDetails": "技術詳情",
"technicalInfo": {
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted。受信任的指令cat、cd、grep、head、ls、pwd、tail、git status/log/diff/show、find不帶 -exec等。",
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never。所有指令在專案目錄內自動執行。",
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never。完全系統存取權限僅在可信環境中使用。",
"overrideNote": "您可以使用聊天介面中的模式按鈕按工作階段覆蓋此設定。"
}
},
"actions": {
"add": "新增"
}
},
"mcpServers": {
"title": "MCP 伺服器",
"description": {
"claude": "Model Context Protocol 伺服器為 Claude 提供額外的工具和資料來源",
"cursor": "Model Context Protocol 伺服器為 Cursor 提供額外的工具和資料來源",
"codex": "Model Context Protocol 伺服器為 Codex 提供額外的工具和資料來源"
},
"addButton": "新增 MCP 伺服器",
"empty": "未設定 MCP 伺服器",
"serverType": "類型",
"scope": {
"local": "本機",
"user": "使用者"
},
"config": {
"command": "指令",
"url": "URL",
"args": "參數",
"environment": "環境變數"
},
"tools": {
"title": "工具",
"count": "{{count}}",
"more": "還有 {{count}} 個"
},
"actions": {
"edit": "編輯伺服器",
"delete": "刪除伺服器"
},
"help": {
"title": "關於 Codex MCP",
"description": "Codex 支援基於 stdio 的 MCP 伺服器。您可以新增伺服器,透過額外的工具和資源來擴充 Codex 的功能。"
}
},
"pluginSettings": {
"title": "外掛",
"description": "透過自訂外掛擴充介面。從 git 安裝或直接將資料夾放入 ~/.claude-code-ui/plugins/",
"installPlaceholder": "https://github.com/user/my-plugin",
"installButton": "安裝",
"installing": "安裝中…",
"securityWarning": "僅安裝您已審查過原始碼或信任作者的外掛。",
"scanningPlugins": "正在掃描外掛…",
"noPluginsInstalled": "未安裝外掛",
"pullLatest": "從 git 拉取最新內容",
"noGitRemote": "無 git 遠端儲存庫 — 無法更新",
"uninstallPlugin": "解除安裝外掛",
"confirmUninstall": "再次點擊確認",
"confirmUninstallMessage": "移除 {{name}}?此操作無法復原。",
"cancel": "取消",
"remove": "移除",
"updateFailed": "更新失敗",
"installFailed": "安裝失敗",
"uninstallFailed": "解除安裝失敗",
"toggleFailed": "切換失敗",
"buildYourOwn": "建立您自己的外掛",
"starter": "入門範本",
"docs": "文件",
"starterPlugin": {
"name": "專案統計",
"badge": "入門",
"description": "查看專案的檔案數、程式碼行數、檔案類型分佈以及最近活動。",
"install": "安裝"
},
"morePlugins": "更多",
"enable": "啟用",
"disable": "停用",
"installAriaLabel": "外掛 git 儲存庫 URL",
"tab": "分頁",
"runningStatus": "執行中"
}
}

View File

@@ -0,0 +1,136 @@
{
"projects": {
"title": "專案",
"newProject": "新增專案",
"deleteProject": "移除專案",
"renameProject": "重新命名專案",
"noProjects": "找不到專案",
"loadingProjects": "載入專案中...",
"searchPlaceholder": "搜尋專案...",
"projectNamePlaceholder": "專案名稱",
"starred": "星號標記",
"all": "全部",
"untitledSession": "未命名工作階段",
"newSession": "新工作階段",
"codexSession": "Codex 工作階段",
"fetchingProjects": "正在取得您的 Claude 專案和工作階段",
"projects": "專案",
"noMatchingProjects": "找不到符合的專案",
"tryDifferentSearch": "嘗試調整您的搜尋詞",
"runClaudeCli": "在專案目錄中執行 Claude CLI 以開始使用"
},
"app": {
"title": "CloudCLI",
"subtitle": "AI 程式開發助手"
},
"sessions": {
"title": "工作階段",
"newSession": "新增工作階段",
"deleteSession": "刪除工作階段",
"renameSession": "重新命名工作階段",
"noSessions": "暫無工作階段",
"loadingSessions": "載入工作階段中...",
"unnamed": "未命名",
"loading": "載入中...",
"showMore": "顯示更多工作階段"
},
"tooltips": {
"viewEnvironments": "查看環境",
"hideSidebar": "隱藏側邊欄",
"createProject": "建立新專案",
"refresh": "重新整理專案和工作階段 (Ctrl+R)",
"renameProject": "重新命名專案 (F2)",
"deleteProject": "從側邊欄移除專案 (Delete)",
"addToFavorites": "加入收藏",
"removeFromFavorites": "從收藏移除",
"editSessionName": "手動編輯工作階段名稱",
"deleteSession": "永久刪除此工作階段",
"save": "儲存",
"cancel": "取消",
"clearSearch": "清除搜尋",
"openCommandPalette": "開啟指令面板"
},
"navigation": {
"chat": "聊天",
"files": "檔案",
"git": "Git",
"terminal": "終端機",
"tasks": "任務"
},
"actions": {
"refresh": "重新整理",
"settings": "設定",
"collapseAll": "全部收合",
"expandAll": "全部展開",
"cancel": "取消",
"save": "儲存",
"delete": "刪除",
"rename": "重新命名",
"joinCommunity": "加入社群",
"reportIssue": "回報問題",
"starOnGithub": "在 GitHub 上加星"
},
"branding": {
"openSource": "開源"
},
"status": {
"active": "使用中",
"inactive": "非使用中",
"thinking": "思考中...",
"error": "錯誤",
"aborted": "已中止",
"unknown": "未知"
},
"time": {
"justNow": "剛剛",
"oneMinuteAgo": "1 分鐘前",
"minutesAgo": "{{count}} 分鐘前",
"oneHourAgo": "1 小時前",
"hoursAgo": "{{count}} 小時前",
"oneDayAgo": "1 天前",
"daysAgo": "{{count}} 天前"
},
"messages": {
"deleteConfirm": "確定要刪除嗎?",
"renameSuccess": "重新命名成功",
"deleteSuccess": "刪除成功",
"errorOccurred": "發生錯誤",
"deleteSessionConfirm": "確定要刪除此工作階段嗎?此操作無法復原。",
"deleteProjectConfirm": "從側邊欄移除此專案?您的專案檔案、記憶和工作階段資料不會被刪除。",
"enterProjectPath": "請輸入專案路徑",
"deleteSessionFailed": "刪除工作階段失敗,請重試。",
"deleteSessionError": "刪除工作階段時出錯,請重試。",
"renameSessionFailed": "重新命名工作階段失敗,請重試。",
"renameSessionError": "重新命名工作階段時出錯,請重試。",
"deleteProjectFailed": "移除專案失敗,請重試。",
"deleteProjectError": "移除專案時出錯,請重試。",
"createProjectFailed": "建立專案失敗,請重試。",
"createProjectError": "建立專案時出錯,請重試。"
},
"version": {
"updateAvailable": "有可用更新"
},
"search": {
"modeProjects": "專案",
"modeConversations": "對話",
"conversationsPlaceholder": "搜尋對話內容...",
"searching": "搜尋中...",
"noResults": "找不到結果",
"tryDifferentQuery": "嘗試不同的搜尋詞",
"matches_one": "{{count}} 個符合",
"matches_other": "{{count}} 個符合",
"projectsScanned_one": "{{count}} 個專案已掃描",
"projectsScanned_other": "{{count}} 個專案已掃描"
},
"deleteConfirmation": {
"deleteProject": "移除專案",
"deleteSession": "刪除工作階段",
"confirmDelete": "您想如何處理",
"sessionCount_one": "此專案包含 {{count}} 個對話。",
"sessionCount_other": "此專案包含 {{count}} 個對話。",
"removeFromSidebar": "僅從側邊欄移除",
"deleteAllData": "永久刪除所有資料",
"allConversationsDeleted": "專案將從側邊欄中移除。您的檔案、記憶和工作階段資料將會保留。",
"cannotUndo": "您可以稍後重新新增此專案。"
}
}

View File

@@ -0,0 +1,142 @@
{
"notConfigured": {
"title": "TaskMaster AI 尚未設定",
"description": "TaskMaster 協助將複雜的專案分解為可管理的任務,搭配 AI 驅動的輔助功能",
"whatIsTitle": "🎯 什麼是 TaskMaster",
"features": {
"aiPowered": "AI 驅動的任務管理:將複雜專案分解為可管理的子任務",
"prdTemplates": "PRD 範本:從產品需求文件產生任務",
"dependencyTracking": "相依性追蹤:了解任務關係和執行順序",
"progressVisualization": "進度視覺化:看板和詳細的任務分析",
"cliIntegration": "CLI 整合:使用 taskmaster 指令進行進階工作流程"
},
"initializeButton": "初始化 TaskMaster AI"
},
"gettingStarted": {
"title": "開始使用 TaskMaster",
"subtitle": "TaskMaster 已初始化!以下是接下來要做的事:",
"steps": {
"createPRD": {
"title": "建立產品需求文件PRD",
"description": "討論您的專案構想並建立描述您想建立什麼的 PRD。",
"addButton": "新增 PRD",
"existingPRDs": "現有的 PRD"
},
"generateTasks": {
"title": "從 PRD 產生任務",
"description": "一旦您有了 PRD請 AI 助手解析它TaskMaster 將自動將其分解為可管理的任務,包含實作細節。"
},
"analyzeTasks": {
"title": "分析並展開任務",
"description": "請 AI 助手分析任務複雜度,並將其展開為詳細的子任務以便於實作。"
},
"startBuilding": {
"title": "開始建構",
"description": "請 AI 助手開始處理任務、更新狀態,並在專案演進時新增任務。"
}
},
"tip": "💡 提示:從 PRD 開始可以充分利用 TaskMaster 的 AI 驅動任務產生功能"
},
"setupModal": {
"title": "TaskMaster 設定",
"subtitle": "{{projectName}} 的互動式 CLI",
"willStart": "TaskMaster 初始化將自動開始",
"completed": "TaskMaster 設定完成!您現在可以關閉此視窗。",
"closeButton": "關閉",
"closeContinueButton": "關閉並繼續"
},
"helpGuide": {
"title": "開始使用 TaskMaster",
"subtitle": "您的高效任務管理指南",
"examples": {
"parsePRD": "💬 範例:\n「我剛用 Claude Task Master 初始化了一個新專案。我有一個 PRD 在 .taskmaster/docs/prd.txt。你能幫我解析它並設定初始任務嗎」",
"expandTask": "💬 範例:\n「任務 5 看起來很複雜。你能把它分解成子任務嗎?」",
"addTask": "💬 範例:\n「請新增一個任務來實作使用 Cloudinary 的使用者個人頭像上傳功能,研究最佳方法。」"
},
"moreExamples": "查看更多範例和使用模式 →",
"proTips": {
"title": "💡 專業提示",
"search": "使用搜尋列快速找到特定任務",
"views": "使用檢視切換在看板、清單和網格檢視之間切換",
"filters": "使用篩選器聚焦特定任務狀態或優先順序",
"details": "點擊任何任務以查看詳細資訊和管理子任務"
},
"learnMore": {
"title": "📚 了解更多",
"description": "TaskMaster AI 是為開發者打造的進階任務管理系統。取得文件、範例並為專案做出貢獻。",
"githubButton": "在 GitHub 上查看"
}
},
"search": {
"placeholder": "搜尋任務..."
},
"filters": {
"button": "篩選",
"status": "狀態",
"priority": "優先順序",
"sortBy": "排序依據",
"allStatuses": "所有狀態",
"allPriorities": "所有優先順序",
"showing": "顯示 {{filtered}} / {{total}} 個任務",
"clearFilters": "清除篩選"
},
"sort": {
"id": "ID",
"status": "狀態",
"priority": "優先順序",
"idAsc": "ID遞增",
"idDesc": "ID遞減",
"titleAsc": "標題A-Z",
"titleDesc": "標題Z-A",
"statusAsc": "狀態(待處理優先)",
"statusDesc": "狀態(已完成優先)",
"priorityAsc": "優先順序(高優先)",
"priorityDesc": "優先順序(低優先)"
},
"views": {
"kanban": "看板檢視",
"list": "清單檢視",
"grid": "網格檢視"
},
"kanban": {
"pending": "📋 待辦",
"inProgress": "🚀 進行中",
"done": "✅ 已完成",
"blocked": "🚫 已封鎖",
"deferred": "⏳ 已延後",
"cancelled": "❌ 已取消",
"noTasksYet": "尚無任務",
"tasksWillAppear": "任務將顯示在這裡",
"moveTasksHere": "開始後將任務移到這裡",
"completedTasksHere": "已完成的任務顯示在這裡",
"statusTasksHere": "此狀態的任務將顯示在這裡"
},
"buttons": {
"help": "TaskMaster 入門指南",
"prds": "PRD",
"addPRD": "新增 PRD",
"addTask": "新增任務",
"createNewPRD": "建立新 PRD",
"prdsAvailable": "{{count}} 個 PRD 可用"
},
"prd": {
"modified": "修改時間:{{date}}"
},
"statuses": {
"pending": "待處理",
"in-progress": "進行中",
"done": "已完成",
"blocked": "已封鎖",
"deferred": "已延後",
"cancelled": "已取消"
},
"priorities": {
"high": "高",
"medium": "中",
"low": "低"
},
"noMatchingTasks": {
"title": "沒有符合篩選條件的任務",
"description": "嘗試調整您的搜尋或篩選條件。"
}
}