Compare commits

..

1 Commits

Author SHA1 Message Date
Haileyesus
cdfd576ae7 chore: use montserrat as app font 2026-06-02 14:40:43 +03:00
107 changed files with 2366 additions and 4126 deletions

View File

@@ -3,74 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file.
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
### New Features
* adding Fable 5 in claude code ([ce327b6](https://github.com/siteboon/claudecodeui/commit/ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5))
## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09)
### New Features
* add file tree upload progress ([c235b05](https://github.com/siteboon/claudecodeui/commit/c235b05e1d3b626667dba4043b685512e3cd3d5d))
* signal when chat runs complete ([d70dc07](https://github.com/siteboon/claudecodeui/commit/d70dc077bfbbfcf2ff4fa5514fabf7b4485861fa))
### Bug Fixes
* address notification review feedback ([602e6ad](https://github.com/siteboon/claudecodeui/commit/602e6ad4acba612a7ea66fb3bc7485054f5675ee))
* align prism plugin name and id with manifest.json ([ca8fd0e](https://github.com/siteboon/claudecodeui/commit/ca8fd0ee235b6a3210157bd0d9af83024d4a2248))
* **chat:** re-anchor initial scroll across lazy content reflow ([33a4e72](https://github.com/siteboon/claudecodeui/commit/33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948))
* keep editor toolbar in view on long unwrapped lines ([beae8c6](https://github.com/siteboon/claudecodeui/commit/beae8c6513daa7518b9de40d8bfde3bf08e7bc87))
* **sandbox:** prevent server SIGHUP on sbx exec exit ([#792](https://github.com/siteboon/claudecodeui/issues/792)) ([f4a1614](https://github.com/siteboon/claudecodeui/commit/f4a1614a0a4ab4b65e8368d5e4221f015cb7555d)), closes [#791](https://github.com/siteboon/claudecodeui/issues/791)
* slash command suggestions trigger at any / in input, not only at start ([#843](https://github.com/siteboon/claudecodeui/issues/843)) ([f7c0024](https://github.com/siteboon/claudecodeui/commit/f7c0024fe15057ad049c71e15e88adb482a4497f))
* update naming convention ([3cd8995](https://github.com/siteboon/claudecodeui/commit/3cd89956ba06f0fc3e17d349b0c50baab4012658))
### Maintenance
* add prism plugin ([01dbe2a](https://github.com/siteboon/claudecodeui/commit/01dbe2a8bfcb3b265995f01f905b218d5f576f7b))
## [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
* **chat:** auto-detect text direction for RTL languages ([#729](https://github.com/siteboon/claudecodeui/issues/729)) ([fa9eaf5](https://github.com/siteboon/claudecodeui/commit/fa9eaf5573a6f870a19fb62ab430ffd87c466582))
### Bug Fixes
* file tree concurrency ([#828](https://github.com/siteboon/claudecodeui/issues/828)) ([ebb0e59](https://github.com/siteboon/claudecodeui/commit/ebb0e59e8023c0a8040d168a5adffb7102e80561))
* load claude models directly from provider ([cdcac18](https://github.com/siteboon/claudecodeui/commit/cdcac182d458a24908777568979c8e756f94428c))
* plugin svg icon sanitization ([#817](https://github.com/siteboon/claudecodeui/issues/817)) ([d9e9df1](https://github.com/siteboon/claudecodeui/commit/d9e9df183f462c88c3b60975eb8254faa9168717))
* recognize claude auth token env ([#818](https://github.com/siteboon/claudecodeui/issues/818)) ([43c33d5](https://github.com/siteboon/claudecodeui/commit/43c33d5cb1b41835dfe3bccd450c5a9c2441509b))
* redact websocket auth token in logs ([#827](https://github.com/siteboon/claudecodeui/issues/827)) ([14ddbc7](https://github.com/siteboon/claudecodeui/commit/14ddbc7c57a01da9fb65fd87d8588532b11833fa))
* remove thinking mode ([#835](https://github.com/siteboon/claudecodeui/issues/835)) ([2149b87](https://github.com/siteboon/claudecodeui/commit/2149b8776b7ebfec0eace413f4fc527ccb2324c0))
* **shell:** disconnect and restart buttons ([#831](https://github.com/siteboon/claudecodeui/issues/831)) ([ef2fd48](https://github.com/siteboon/claudecodeui/commit/ef2fd48b46452d4b9e2bf1f5e3c30fafe19f27f2))
* show Claude tool result errors ([bb8db58](https://github.com/siteboon/claudecodeui/commit/bb8db5815c2d20ee4fbfa02d14c886a56ef352e0))
* **vite:** proxy /plugin-ws WebSocket requests to the backend in dev ([#757](https://github.com/siteboon/claudecodeui/issues/757)) ([96b16b4](https://github.com/siteboon/claudecodeui/commit/96b16b42e4f807d04ec743a5a4117a37a3f5e0d9))
* **websocket:** add 30s server-side heartbeat to prevent proxy idle disconnects ([#770](https://github.com/siteboon/claudecodeui/issues/770)) ([2edfef2](https://github.com/siteboon/claudecodeui/commit/2edfef2e3f4271c29ae8670df9dd382a9eef7c3c)), closes [#769](https://github.com/siteboon/claudecodeui/issues/769)
* **websocket:** reset unmountedRef on each effect re-run so token refresh reconnects ([#721](https://github.com/siteboon/claudecodeui/issues/721)) ([f082cdc](https://github.com/siteboon/claudecodeui/commit/f082cdc63bd0de90f8b3da1df6071e91ab545831))
### Documentation
* add nginx subpath deployment template ([#820](https://github.com/siteboon/claudecodeui/issues/820)) ([3ec76b5](https://github.com/siteboon/claudecodeui/commit/3ec76b5bb15a13cec41056f4c9b9c425195022fa))
### Maintenance
* update Claude fallback models ([94785bf](https://github.com/siteboon/claudecodeui/commit/94785bfa579d1f39a2bee0f9dd0f09fd0243bc79))
* update package-lock.json ([c90b341](https://github.com/siteboon/claudecodeui/commit/c90b34108e86a3effdb5c6979ea7b1692d2b9da0))
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
### 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.zh-TW.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.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -62,7 +62,7 @@
- **Sitzungsverwaltung** Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen
- **Plugin-System** CloudCLI mit eigenen Plugins erweitern neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung
- **Modell-Kompatibilität** Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle zur Laufzeit über `GET /api/providers/:provider/models`)
- **Modell-Kompatibilität** Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`public/modelConstants.js`](public/modelConstants.js))
## Schnellstart

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.zh-TW.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> · <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.zh-TW.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.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -60,7 +60,7 @@
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인)
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`public/modelConstants.js`에서 전체 지원 모델 확인)
## 빠른 시작

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.zh-TW.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.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -62,7 +62,7 @@
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](public/modelConstants.js) for the full list of supported models)
## Quick Start

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.zh-TW.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.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -62,7 +62,7 @@
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`)
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`public/modelConstants.js`](public/modelConstants.js) для полного списка поддерживаемых моделей)
## Быстрый старт

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.zh-TW.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.ja.md">日本語</a> · <b>Türkçe</b></i></div>
---
@@ -62,7 +62,7 @@
- **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et
- **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için `GET /api/providers/:provider/models` API'sine bak)
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için [`public/modelConstants.js`](public/modelConstants.js) dosyasına bak)
## Hızlı Başlangıç

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.zh-TW.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> · <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>
---
@@ -60,7 +60,7 @@
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js)
## 快速开始

View File

@@ -1,242 +0,0 @@
<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 模型家族(完整支援列表可透過 `GET /api/providers/:provider/models` 介面取得)
## 快速開始
### 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

@@ -1,218 +0,0 @@
# CloudCLI UI Nginx subpath deployment template.
#
# Purpose:
# Serve CloudCLI UI from a path prefix such as:
# http://localhost/ai/
# https://example.com/ai/
#
# CloudCLI itself still runs at the root of its own HTTP server, for example:
# http://127.0.0.1:3001/
#
# Nginx receives public requests under /ai, strips that prefix, and forwards the
# remaining path to CloudCLI. For example:
# /ai/ -> /
# /ai/session/abc -> /session/abc
# /ai/assets/index.js -> /assets/index.js
#
# Important Nginx limitation:
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
# The configurable variables below are still useful for proxy/filter values,
# but if you change /ai to a different subpath, also update every line marked:
# [SUBPATH LITERAL]
#
# To use a different subpath, replace these literal matchers:
# location = /ai
# location ^~ /ai/
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
#
# Recommended deployment shape:
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
# are also proxied because the current frontend still calls those endpoints
# with root-relative URLs.
worker_processes 1;
events {
# Maximum simultaneous connections handled by each worker process.
# The default is enough for local testing and small self-hosted deployments.
worker_connections 1024;
}
http {
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
# This map gives us the right Connection header for both cases:
# Upgrade present -> "upgrade"
# Upgrade absent -> "close"
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
# add ssl_certificate / ssl_certificate_key lines.
listen 80 default_server;
# Use your real hostname in production, for example:
# server_name cloudcli.example.com;
server_name localhost 127.0.0.1;
# ---- User settings -------------------------------------------------
#
# Public path prefix where users access CloudCLI.
# Do not add a trailing slash.
#
# This variable can be used in redirects and response rewrites. It
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
# lines too if you change it.
set $cloudcli_subpath /ai;
# Private upstream URL where the CloudCLI server is listening.
# For a default local server this is usually http://127.0.0.1:3001.
set $cloudcli_upstream http://127.0.0.1:3001;
# Allow larger file uploads through the code editor/project file APIs.
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.
location = /ai {
return 301 $cloudcli_subpath/;
}
# Main prefixed CloudCLI UI route.
#
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
# $cloudcli_subpath.
location ^~ /ai/ {
# Strip the public subpath before proxying. CloudCLI expects to see
# root paths such as /, /session/:id, /assets/..., /manifest.json.
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
# Forward the rewritten request to the private CloudCLI server.
proxy_pass $cloudcli_upstream;
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
# browser reaches a socket endpoint under the subpath.
proxy_http_version 1.1;
# Preserve useful request metadata for logs and future app support.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
# WebSocket upgrade headers. Harmless for normal HTTP requests.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Long-running agent and terminal sessions can stay open for a long
# time, so avoid closing idle proxied connections too aggressively.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Disable gzip from the upstream response so sub_filter can inspect
# and rewrite HTML/JSON/JS response bodies.
proxy_set_header Accept-Encoding "";
# Rewrite browser-visible root-relative URLs so the runtime can
# discover that the app is mounted under the subpath.
#
# Examples:
# href="/manifest.json" -> href="/ai/manifest.json"
# src="/assets/app.js" -> src="/ai/assets/app.js"
#
# These rewrites are important for React Router basename detection.
sub_filter_once off;
sub_filter_types
application/json
application/manifest+json
application/javascript
text/javascript;
sub_filter 'href="/' 'href="$cloudcli_subpath/';
sub_filter 'src="/' 'src="$cloudcli_subpath/';
# The production HTML and JS register the service worker at /sw.js.
# Rewrite that registration so the worker is served from /ai/sw.js.
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
# The manifest and service worker contain root-relative paths too.
# Rewriting them keeps PWA metadata and cached manifest requests
# under the same public subpath.
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
}
# Root API proxy.
#
# The current CloudCLI frontend calls APIs with root-relative URLs such
# as /api/auth/login. Keep this location unless the frontend becomes
# fully prefix-aware for API requests.
location ^~ /api/ {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Main app WebSocket proxy.
#
# The frontend opens /ws for realtime chat/session/task updates.
location /ws {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Shell WebSocket proxy.
#
# The browser terminal uses /shell. It requires the same WebSocket
# upgrade handling as /ws.
location /shell {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Optional health endpoint proxy used by the frontend version checker.
location = /health {
proxy_pass $cloudcli_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
}
}
}

View File

@@ -5,6 +5,9 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<title>CloudCLI UI</title>
<!-- PWA Manifest -->

244
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.34.0",
"version": "1.33.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cloudcli-ai/cloudcli",
"version": "1.34.0",
"version": "1.33.0",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
@@ -139,33 +139,35 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.165.tgz",
"integrity": "sha512-wEUJNTAWkE6KMV35abqGi30lwhZz+jQLMtLh4SuTN2Hllzsysq8kmQFgcWulza3FLHG/GHzGHPi0+Sp2fb8xlw==",
"version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.116.tgz",
"integrity": "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw==",
"license": "SEE LICENSE IN README.md",
"dependencies": {
"@anthropic-ai/sdk": "^0.81.0",
"@modelcontextprotocol/sdk": "^1.29.0"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.165",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.165",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.165",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.165",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.165",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.165",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.165",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.165"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.0.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.165.tgz",
"integrity": "sha512-obVodJmppNc6lgcM6Y5y3VCQLrYO2curOXrRaziKtjxYbuZP7kYsUhnonMvGoVAQh3uHKz2tivQDeztvWe3f9w==",
"version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.116.tgz",
"integrity": "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g==",
"cpu": [
"arm64"
],
@@ -176,9 +178,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.165.tgz",
"integrity": "sha512-0jc1tlYLXzPvZIkHKGHzsEEKq2YqTS8oHSNFroqLgbhrIk1Zy05ZXbciI289VDAe1Fq2a+qcUhkXct8Parx1Rg==",
"version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.116.tgz",
"integrity": "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw==",
"cpu": [
"x64"
],
@@ -189,9 +191,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.165.tgz",
"integrity": "sha512-t87HgDPPaRYMTTB5cqA0M36Fyq4DOny89yk71BMgA8hAzhOjV9bla8pMVZTuX3xYYPjsa/TOmxSzwI8GZLf4Aw==",
"version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.116.tgz",
"integrity": "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg==",
"cpu": [
"arm64"
],
@@ -202,9 +204,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.165.tgz",
"integrity": "sha512-Rccmr5chZdZJVRvoB0nildB5PTKX+amatUho9JIcNOf1iX/6ej39fwf8q9W1MRHYP7AEc4t9GrSAGLcn7/JO4w==",
"version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.116.tgz",
"integrity": "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA==",
"cpu": [
"arm64"
],
@@ -215,9 +217,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.165.tgz",
"integrity": "sha512-Y8fEW0zKBn0XZI5AOQWHep0Srz0qsCauynTWkhsC6J2vSPxkTiOxv2hmb7qdfiNlFn0k1etCWVFoRkhhFJzGfg==",
"version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.116.tgz",
"integrity": "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ==",
"cpu": [
"x64"
],
@@ -228,9 +230,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.165.tgz",
"integrity": "sha512-Y9Acr1RmydfEX+t+3mFn0K9VOx6nfyo08QuQH9R6ap1YYZWuobze++pNUY/rzwbQjXqcbjORtPKbO/kLQtSr9w==",
"version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.116.tgz",
"integrity": "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ==",
"cpu": [
"x64"
],
@@ -241,9 +243,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.165.tgz",
"integrity": "sha512-4Q01L3xaDDCvlOhABf2MnO7v7yJxKwwDyiMr+DaneUSvuh1qH0YE7qErSYLf6D9VfH8TdRwKZXwQplVVwCoHWw==",
"version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.116.tgz",
"integrity": "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA==",
"cpu": [
"arm64"
],
@@ -254,9 +256,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.165.tgz",
"integrity": "sha512-Y0uOx7b7ZnkguvFFI5T5fSLnRA/e0uvMC++gSnyz6XMpNekgWc3+Mny7Dv2NO22nKbV2YiFsj6MkYYFEd51BDw==",
"version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.116.tgz",
"integrity": "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw==",
"cpu": [
"x64"
],
@@ -267,14 +269,12 @@
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.100.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz",
"integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==",
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz",
"integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==",
"license": "MIT",
"peer": true,
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
@@ -1837,7 +1837,6 @@
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.14.1"
},
@@ -2602,7 +2601,6 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -2643,7 +2641,6 @@
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
@@ -2653,11 +2650,10 @@
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -2674,7 +2670,6 @@
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
@@ -2699,7 +2694,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -2713,7 +2707,6 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.6.0"
}
@@ -2723,7 +2716,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -2767,7 +2759,6 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
@@ -2789,7 +2780,6 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -2799,7 +2789,6 @@
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
@@ -2820,7 +2809,6 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -2836,15 +2824,13 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -2854,7 +2840,6 @@
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -2867,7 +2852,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -2884,17 +2868,15 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"side-channel": "^1.1.0"
},
@@ -2910,7 +2892,6 @@
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
@@ -2926,7 +2907,6 @@
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
@@ -2953,7 +2933,6 @@
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"peer": true,
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
@@ -2973,42 +2952,22 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"peer": true,
"dependencies": {
"content-type": "^2.0.0",
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.6"
}
},
"node_modules/@napi-rs/wasm-runtime": {
@@ -4325,13 +4284,6 @@
"url": "https://ko-fi.com/dangreen"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT",
"peer": true
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
@@ -5465,7 +5417,6 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
@@ -5479,11 +5430,10 @@
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -5499,8 +5449,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
@@ -8566,7 +8515,6 @@
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"peer": true,
"dependencies": {
"eventsource-parser": "^3.0.1"
},
@@ -8575,11 +8523,10 @@
}
},
"node_modules/eventsource-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
"integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
}
@@ -8687,13 +8634,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"license": "MIT",
"peer": true,
"dependencies": {
"ip-address": "^10.2.0"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@@ -8809,13 +8755,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense",
"peer": true
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -9873,11 +9812,10 @@
"license": "CC0-1.0"
},
"node_modules/hono": {
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"version": "4.12.14",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10249,9 +10187,9 @@
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -10694,8 +10632,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/is-regex": {
"version": "1.2.1",
@@ -10966,11 +10903,10 @@
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
@@ -11032,7 +10968,6 @@
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
@@ -11052,8 +10987,7 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
@@ -14045,7 +13979,6 @@
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.20.0"
}
@@ -15346,7 +15279,6 @@
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
@@ -15363,7 +15295,6 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
@@ -16422,17 +16353,6 @@
"node": ">=12.0.0"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -17143,8 +17063,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.4.0",
@@ -19030,7 +18949,6 @@
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peer": true,
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.34.0",
"version": "1.33.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",
@@ -11,6 +11,7 @@
"server/",
"shared/",
"public/api-docs.html",
"public/modelConstants.js",
"dist/",
"dist-server/",
"scripts/",
@@ -66,7 +67,7 @@
"author": "CloudCLI UI Contributors",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",

View File

@@ -820,49 +820,31 @@ data: {"type":"done"}</code></pre>
</div>
</div>
<script>
<script type="module">
// Import model constants
import { PROVIDERS } from './modelConstants.js';
// Dynamic URL replacement
const apiUrl = window.location.origin;
document.querySelectorAll('.api-url').forEach(el => {
el.textContent = apiUrl;
});
// Populate model documentation from the live provider API
const PROVIDER_ORDER = [
{ id: 'claude', name: 'Anthropic' },
{ id: 'codex', name: 'OpenAI' },
{ id: 'gemini', name: 'Google' },
{ id: 'cursor', name: 'Cursor' },
{ id: 'opencode', name: 'OpenCode' },
];
async function populateModels() {
// Dynamically populate model documentation
window.addEventListener('DOMContentLoaded', () => {
const modelCell = document.getElementById('model-options-cell');
if (!modelCell) return;
if (modelCell) {
const providerModels = PROVIDERS.map(provider => {
const models = provider.models.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
return `<strong>${provider.name}:</strong> ${models} (default: <code>${provider.models.DEFAULT}</code>)`;
}).join('<br><br>');
const token = localStorage.getItem('auth-token');
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const results = await Promise.allSettled(
PROVIDER_ORDER.map(({ id }) =>
fetch(`/api/providers/${id}/models`, { headers }).then(r => r.json())
)
);
const providerModels = results.map((result, i) => {
const { name } = PROVIDER_ORDER[i];
if (result.status === 'rejected' || !result.value?.data?.models) {
return `<strong>${name}:</strong> <em>unavailable</em>`;
}
const { OPTIONS, DEFAULT } = result.value.data.models;
const models = OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
return `<strong>${name}:</strong> ${models} (default: <code>${DEFAULT}</code>)`;
}).join('<br><br>');
modelCell.innerHTML = `Model identifier for the AI provider:<br><br>${providerModels}`;
}
document.addEventListener('DOMContentLoaded', populateModels);
modelCell.innerHTML = `
Model identifier for the AI provider:<br><br>
${providerModels}
`;
}
});
// Tab switching
window.showTab = function(tabName) {

841
public/modelConstants.js Normal file
View File

@@ -0,0 +1,841 @@
/**
* Documentation Model Definitions
* Used by README links and the public API docs.
*/
/**
* Claude (Anthropic) Models
*/
export const CLAUDE_MODELS = {
OPTIONS: [
{
value: "default",
label: "Default (recommended)",
description: "Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok",
},
{
value: "sonnet",
label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
},
{
value: "sonnet[1m]",
label: "Sonnet (1M context)",
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
},
{
value: "haiku",
label: "Haiku",
description: "Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok",
},
],
DEFAULT: "default",
};
/**
* Cursor Models
*/
export const CURSOR_MODELS = {
OPTIONS: [
{ value: "auto", label: "auto", description: "Auto" },
{
value: "composer-2-fast",
label: "composer-2-fast",
description: "Composer 2 Fast",
},
{
value: "composer-2",
label: "composer-2",
description: "Composer 2",
},
{
value: "gpt-5.3-codex-low",
label: "gpt-5.3-codex-low",
description: "Codex 5.3 Low",
},
{
value: "gpt-5.3-codex-low-fast",
label: "gpt-5.3-codex-low-fast",
description: "Codex 5.3 Low Fast",
},
{
value: "gpt-5.3-codex",
label: "gpt-5.3-codex",
description: "Codex 5.3",
},
{
value: "gpt-5.3-codex-fast",
label: "gpt-5.3-codex-fast",
description: "Codex 5.3 Fast",
},
{
value: "gpt-5.3-codex-high",
label: "gpt-5.3-codex-high",
description: "Codex 5.3 High",
},
{
value: "gpt-5.3-codex-high-fast",
label: "gpt-5.3-codex-high-fast",
description: "Codex 5.3 High Fast",
},
{
value: "gpt-5.3-codex-xhigh",
label: "gpt-5.3-codex-xhigh",
description: "Codex 5.3 Extra High",
},
{
value: "gpt-5.3-codex-xhigh-fast",
label: "gpt-5.3-codex-xhigh-fast",
description: "Codex 5.3 Extra High Fast",
},
{ value: "gpt-5.2", label: "gpt-5.2", description: "GPT-5.2" },
{
value: "gpt-5.2-codex-low",
label: "gpt-5.2-codex-low",
description: "Codex 5.2 Low",
},
{
value: "gpt-5.2-codex-low-fast",
label: "gpt-5.2-codex-low-fast",
description: "Codex 5.2 Low Fast",
},
{
value: "gpt-5.2-codex",
label: "gpt-5.2-codex",
description: "Codex 5.2",
},
{
value: "gpt-5.2-codex-fast",
label: "gpt-5.2-codex-fast",
description: "Codex 5.2 Fast",
},
{
value: "gpt-5.2-codex-high",
label: "gpt-5.2-codex-high",
description: "Codex 5.2 High",
},
{
value: "gpt-5.2-codex-high-fast",
label: "gpt-5.2-codex-high-fast",
description: "Codex 5.2 High Fast",
},
{
value: "gpt-5.2-codex-xhigh",
label: "gpt-5.2-codex-xhigh",
description: "Codex 5.2 Extra High",
},
{
value: "gpt-5.2-codex-xhigh-fast",
label: "gpt-5.2-codex-xhigh-fast",
description: "Codex 5.2 Extra High Fast",
},
{
value: "gpt-5.1-codex-max-low",
label: "gpt-5.1-codex-max-low",
description: "Codex 5.1 Max Low",
},
{
value: "gpt-5.1-codex-max-low-fast",
label: "gpt-5.1-codex-max-low-fast",
description: "Codex 5.1 Max Low Fast",
},
{
value: "gpt-5.1-codex-max-medium",
label: "gpt-5.1-codex-max-medium",
description: "Codex 5.1 Max",
},
{
value: "gpt-5.1-codex-max-medium-fast",
label: "gpt-5.1-codex-max-medium-fast",
description: "Codex 5.1 Max Medium Fast",
},
{
value: "gpt-5.1-codex-max-high",
label: "gpt-5.1-codex-max-high",
description: "Codex 5.1 Max High",
},
{
value: "gpt-5.1-codex-max-high-fast",
label: "gpt-5.1-codex-max-high-fast",
description: "Codex 5.1 Max High Fast",
},
{
value: "gpt-5.1-codex-max-xhigh",
label: "gpt-5.1-codex-max-xhigh",
description: "Codex 5.1 Max Extra High",
},
{
value: "gpt-5.1-codex-max-xhigh-fast",
label: "gpt-5.1-codex-max-xhigh-fast",
description: "Codex 5.1 Max Extra High Fast",
},
{
value: "composer-2.5",
label: "composer-2.5",
description: "Composer 2.5",
},
{
value: "gpt-5.5-high",
label: "gpt-5.5-high",
description: "GPT-5.5 1M High",
},
{
value: "gpt-5.5-high-fast",
label: "gpt-5.5-high-fast",
description: "GPT-5.5 High Fast",
},
{
value: "claude-opus-4-7-thinking-high",
label: "claude-opus-4-7-thinking-high",
description: "Opus 4.7 1M High Thinking",
},
{
value: "gpt-5.4-high",
label: "gpt-5.4-high",
description: "GPT-5.4 1M High",
},
{
value: "gpt-5.4-high-fast",
label: "gpt-5.4-high-fast",
description: "GPT-5.4 High Fast",
},
{
value: "claude-4.6-opus-high-thinking",
label: "claude-4.6-opus-high-thinking",
description: "Opus 4.6 1M Thinking",
},
{
value: "claude-4.6-opus-high-thinking-fast",
label: "claude-4.6-opus-high-thinking-fast",
description: "Opus 4.6 1M Thinking Fast",
},
{
value: "composer-2.5-fast",
label: "composer-2.5-fast",
description: "Composer 2.5 Fast",
},
{
value: "gpt-5.5-none",
label: "gpt-5.5-none",
description: "GPT-5.5 1M None",
},
{
value: "gpt-5.5-none-fast",
label: "gpt-5.5-none-fast",
description: "GPT-5.5 None Fast",
},
{
value: "gpt-5.5-low",
label: "gpt-5.5-low",
description: "GPT-5.5 1M Low",
},
{
value: "gpt-5.5-low-fast",
label: "gpt-5.5-low-fast",
description: "GPT-5.5 Low Fast",
},
{
value: "gpt-5.5-medium",
label: "gpt-5.5-medium",
description: "GPT-5.5 1M",
},
{
value: "gpt-5.5-medium-fast",
label: "gpt-5.5-medium-fast",
description: "GPT-5.5 Fast",
},
{
value: "gpt-5.5-extra-high",
label: "gpt-5.5-extra-high",
description: "GPT-5.5 1M Extra High",
},
{
value: "gpt-5.5-extra-high-fast",
label: "gpt-5.5-extra-high-fast",
description: "GPT-5.5 Extra High Fast",
},
{
value: "claude-4.6-sonnet-medium",
label: "claude-4.6-sonnet-medium",
description: "Sonnet 4.6 1M",
},
{
value: "claude-4.6-sonnet-medium-thinking",
label: "claude-4.6-sonnet-medium-thinking",
description: "Sonnet 4.6 1M Thinking",
},
{
value: "claude-opus-4-7-low",
label: "claude-opus-4-7-low",
description: "Opus 4.7 1M Low",
},
{
value: "claude-opus-4-7-low-fast",
label: "claude-opus-4-7-low-fast",
description: "Opus 4.7 1M Low Fast",
},
{
value: "claude-opus-4-7-medium",
label: "claude-opus-4-7-medium",
description: "Opus 4.7 1M Medium",
},
{
value: "claude-opus-4-7-medium-fast",
label: "claude-opus-4-7-medium-fast",
description: "Opus 4.7 1M Medium Fast",
},
{
value: "claude-opus-4-7-high",
label: "claude-opus-4-7-high",
description: "Opus 4.7 1M High",
},
{
value: "claude-opus-4-7-high-fast",
label: "claude-opus-4-7-high-fast",
description: "Opus 4.7 1M High Fast",
},
{
value: "claude-opus-4-7-xhigh",
label: "claude-opus-4-7-xhigh",
description: "Opus 4.7 1M",
},
{
value: "claude-opus-4-7-xhigh-fast",
label: "claude-opus-4-7-xhigh-fast",
description: "Opus 4.7 1M Fast",
},
{
value: "claude-opus-4-7-max",
label: "claude-opus-4-7-max",
description: "Opus 4.7 1M Max",
},
{
value: "claude-opus-4-7-max-fast",
label: "claude-opus-4-7-max-fast",
description: "Opus 4.7 1M Max Fast",
},
{
value: "claude-opus-4-7-thinking-low",
label: "claude-opus-4-7-thinking-low",
description: "Opus 4.7 1M Low Thinking",
},
{
value: "claude-opus-4-7-thinking-low-fast",
label: "claude-opus-4-7-thinking-low-fast",
description: "Opus 4.7 1M Low Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-medium",
label: "claude-opus-4-7-thinking-medium",
description: "Opus 4.7 1M Medium Thinking",
},
{
value: "claude-opus-4-7-thinking-medium-fast",
label: "claude-opus-4-7-thinking-medium-fast",
description: "Opus 4.7 1M Medium Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-high-fast",
label: "claude-opus-4-7-thinking-high-fast",
description: "Opus 4.7 1M High Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-xhigh",
label: "claude-opus-4-7-thinking-xhigh",
description: "Opus 4.7 1M Thinking",
},
{
value: "claude-opus-4-7-thinking-xhigh-fast",
label: "claude-opus-4-7-thinking-xhigh-fast",
description: "Opus 4.7 1M Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-max",
label: "claude-opus-4-7-thinking-max",
description: "Opus 4.7 1M Max Thinking",
},
{
value: "claude-opus-4-7-thinking-max-fast",
label: "claude-opus-4-7-thinking-max-fast",
description: "Opus 4.7 1M Max Thinking Fast",
},
{
value: "grok-build-0.1",
label: "grok-build-0.1",
description: "Grok Build 0.1 1M",
},
{
value: "gpt-5.4-low",
label: "gpt-5.4-low",
description: "GPT-5.4 1M Low",
},
{
value: "gpt-5.4-medium",
label: "gpt-5.4-medium",
description: "GPT-5.4 1M",
},
{
value: "gpt-5.4-medium-fast",
label: "gpt-5.4-medium-fast",
description: "GPT-5.4 Fast",
},
{
value: "gpt-5.4-xhigh",
label: "gpt-5.4-xhigh",
description: "GPT-5.4 1M Extra High",
},
{
value: "gpt-5.4-xhigh-fast",
label: "gpt-5.4-xhigh-fast",
description: "GPT-5.4 Extra High Fast",
},
{
value: "claude-4.6-opus-high",
label: "claude-4.6-opus-high",
description: "Opus 4.6 1M",
},
{
value: "claude-4.6-opus-max",
label: "claude-4.6-opus-max",
description: "Opus 4.6 1M Max",
},
{
value: "claude-4.6-opus-max-thinking",
label: "claude-4.6-opus-max-thinking",
description: "Opus 4.6 1M Max Thinking",
},
{
value: "claude-4.6-opus-max-thinking-fast",
label: "claude-4.6-opus-max-thinking-fast",
description: "Opus 4.6 1M Max Thinking Fast",
},
{
value: "claude-4.5-opus-high",
label: "claude-4.5-opus-high",
description: "Opus 4.5",
},
{
value: "claude-4.5-opus-high-thinking",
label: "claude-4.5-opus-high-thinking",
description: "Opus 4.5 Thinking",
},
{
value: "gpt-5.2-low",
label: "gpt-5.2-low",
description: "GPT-5.2 Low",
},
{
value: "gpt-5.2-low-fast",
label: "gpt-5.2-low-fast",
description: "GPT-5.2 Low Fast",
},
{
value: "gpt-5.2-fast",
label: "gpt-5.2-fast",
description: "GPT-5.2 Fast",
},
{
value: "gpt-5.2-high",
label: "gpt-5.2-high",
description: "GPT-5.2 High",
},
{
value: "gpt-5.2-high-fast",
label: "gpt-5.2-high-fast",
description: "GPT-5.2 High Fast",
},
{
value: "gpt-5.2-xhigh",
label: "gpt-5.2-xhigh",
description: "GPT-5.2 Extra High",
},
{
value: "gpt-5.2-xhigh-fast",
label: "gpt-5.2-xhigh-fast",
description: "GPT-5.2 Extra High Fast",
},
{
value: "gemini-3.1-pro",
label: "gemini-3.1-pro",
description: "Gemini 3.1 Pro",
},
{
value: "gpt-5.4-mini-none",
label: "gpt-5.4-mini-none",
description: "GPT-5.4 Mini None",
},
{
value: "gpt-5.4-mini-low",
label: "gpt-5.4-mini-low",
description: "GPT-5.4 Mini Low",
},
{
value: "gpt-5.4-mini-medium",
label: "gpt-5.4-mini-medium",
description: "GPT-5.4 Mini",
},
{
value: "gpt-5.4-mini-high",
label: "gpt-5.4-mini-high",
description: "GPT-5.4 Mini High",
},
{
value: "gpt-5.4-mini-xhigh",
label: "gpt-5.4-mini-xhigh",
description: "GPT-5.4 Mini Extra High",
},
{
value: "gpt-5.4-nano-none",
label: "gpt-5.4-nano-none",
description: "GPT-5.4 Nano None",
},
{
value: "gpt-5.4-nano-low",
label: "gpt-5.4-nano-low",
description: "GPT-5.4 Nano Low",
},
{
value: "gpt-5.4-nano-medium",
label: "gpt-5.4-nano-medium",
description: "GPT-5.4 Nano",
},
{
value: "gpt-5.4-nano-high",
label: "gpt-5.4-nano-high",
description: "GPT-5.4 Nano High",
},
{
value: "gpt-5.4-nano-xhigh",
label: "gpt-5.4-nano-xhigh",
description: "GPT-5.4 Nano Extra High",
},
{
value: "grok-4.3",
label: "grok-4.3",
description: "Grok 4.3 1M",
},
{
value: "claude-4.5-sonnet",
label: "claude-4.5-sonnet",
description: "Sonnet 4.5",
},
{
value: "claude-4.5-sonnet-thinking",
label: "claude-4.5-sonnet-thinking",
description: "Sonnet 4.5 Thinking",
},
{
value: "gpt-5.1-low",
label: "gpt-5.1-low",
description: "GPT-5.1 Low",
},
{
value: "gpt-5.1",
label: "gpt-5.1",
description: "GPT-5.1",
},
{
value: "gpt-5.1-high",
label: "gpt-5.1-high",
description: "GPT-5.1 High",
},
{
value: "gemini-3-flash",
label: "gemini-3-flash",
description: "Gemini 3 Flash",
},
{
value: "gemini-3.5-flash",
label: "gemini-3.5-flash",
description: "Gemini 3.5 Flash",
},
{
value: "gpt-5.1-codex-mini-low",
label: "gpt-5.1-codex-mini-low",
description: "Codex 5.1 Mini Low",
},
{
value: "gpt-5.1-codex-mini",
label: "gpt-5.1-codex-mini",
description: "Codex 5.1 Mini",
},
{
value: "gpt-5.1-codex-mini-high",
label: "gpt-5.1-codex-mini-high",
description: "Codex 5.1 Mini High",
},
{
value: "claude-4-sonnet",
label: "claude-4-sonnet",
description: "Sonnet 4",
},
{
value: "claude-4-sonnet-thinking",
label: "claude-4-sonnet-thinking",
description: "Sonnet 4 Thinking",
},
{
value: "gpt-5-mini",
label: "gpt-5-mini",
description: "GPT-5 Mini",
},
{
value: "kimi-k2.5",
label: "kimi-k2.5",
description: "Kimi K2.5",
},
],
DEFAULT: "composer-2.5-fast",
};
/**
* Codex (OpenAI) Models
*/
export const CODEX_MODELS = {
OPTIONS: [
{ value: "gpt-5.5", label: "gpt-5.5" },
{ value: "gpt-5.4", label: "gpt-5.4" },
{ value: "gpt-5.4-mini", label: "gpt-5.4-mini" },
{ value: "gpt-5.3-codex", label: "gpt-5.3-codex" },
{ value: "gpt-5.2", label: "gpt-5.2" },
],
DEFAULT: "gpt-5.4",
};
/**
* Gemini Models
*/
export const GEMINI_MODELS = {
OPTIONS: [
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
{ value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
{
value: "gemini-2.0-flash-thinking-exp",
label: "Gemini 2.0 Flash Thinking",
},
],
DEFAULT: "gemini-3.1-pro-preview",
};
/**
* OpenCode Models
*
* OpenCode model ids include the upstream provider prefix.
*/
export const OPENCODE_MODELS = {
OPTIONS: [
{
value: "opencode/big-pickle",
label: "Big Pickle",
description: "opencode - opencode/big-pickle",
},
{
value: "opencode/deepseek-v4-flash-free",
label: "Deepseek V4 Flash Free",
description: "opencode - opencode/deepseek-v4-flash-free",
},
{
value: "opencode/nemotron-3-super-free",
label: "Nemotron 3 Super Free",
description: "opencode - opencode/nemotron-3-super-free",
},
{
value: "anthropic/claude-3-5-haiku-20241022",
label: "Claude 3.5 Haiku (2024-10-22)",
description: "anthropic - anthropic/claude-3-5-haiku-20241022",
},
{
value: "anthropic/claude-3-5-haiku-latest",
label: "Claude 3.5 Haiku Latest",
description: "anthropic - anthropic/claude-3-5-haiku-latest",
},
{
value: "anthropic/claude-3-5-sonnet-20240620",
label: "Claude 3.5 Sonnet (2024-06-20)",
description: "anthropic - anthropic/claude-3-5-sonnet-20240620",
},
{
value: "anthropic/claude-3-5-sonnet-20241022",
label: "Claude 3.5 Sonnet (2024-10-22)",
description: "anthropic - anthropic/claude-3-5-sonnet-20241022",
},
{
value: "anthropic/claude-3-7-sonnet-20250219",
label: "Claude 3.7 Sonnet (2025-02-19)",
description: "anthropic - anthropic/claude-3-7-sonnet-20250219",
},
{
value: "anthropic/claude-3-haiku-20240307",
label: "Claude 3 Haiku (2024-03-07)",
description: "anthropic - anthropic/claude-3-haiku-20240307",
},
{
value: "anthropic/claude-3-opus-20240229",
label: "Claude 3 Opus (2024-02-29)",
description: "anthropic - anthropic/claude-3-opus-20240229",
},
{
value: "anthropic/claude-3-sonnet-20240229",
label: "Claude 3 Sonnet (2024-02-29)",
description: "anthropic - anthropic/claude-3-sonnet-20240229",
},
{
value: "anthropic/claude-haiku-4-5",
label: "Claude Haiku 4.5",
description: "anthropic - anthropic/claude-haiku-4-5",
},
{
value: "anthropic/claude-haiku-4-5-20251001",
label: "Claude Haiku 4.5 (2025-10-01)",
description: "anthropic - anthropic/claude-haiku-4-5-20251001",
},
{
value: "anthropic/claude-opus-4-0",
label: "Claude Opus 4.0",
description: "anthropic - anthropic/claude-opus-4-0",
},
{
value: "anthropic/claude-opus-4-1",
label: "Claude Opus 4.1",
description: "anthropic - anthropic/claude-opus-4-1",
},
{
value: "anthropic/claude-opus-4-1-20250805",
label: "Claude Opus 4.1 (2025-08-05)",
description: "anthropic - anthropic/claude-opus-4-1-20250805",
},
{
value: "anthropic/claude-opus-4-20250514",
label: "Claude Opus 4 (2025-05-14)",
description: "anthropic - anthropic/claude-opus-4-20250514",
},
{
value: "anthropic/claude-opus-4-5",
label: "Claude Opus 4.5",
description: "anthropic - anthropic/claude-opus-4-5",
},
{
value: "anthropic/claude-opus-4-5-20251101",
label: "Claude Opus 4.5 (2025-11-01)",
description: "anthropic - anthropic/claude-opus-4-5-20251101",
},
{
value: "anthropic/claude-opus-4-6",
label: "Claude Opus 4.6",
description: "anthropic - anthropic/claude-opus-4-6",
},
{
value: "anthropic/claude-opus-4-6-fast",
label: "Claude Opus 4.6 Fast",
description: "anthropic - anthropic/claude-opus-4-6-fast",
},
{
value: "anthropic/claude-opus-4-7",
label: "Claude Opus 4.7",
description: "anthropic - anthropic/claude-opus-4-7",
},
{
value: "anthropic/claude-opus-4-7-fast",
label: "Claude Opus 4.7 Fast",
description: "anthropic - anthropic/claude-opus-4-7-fast",
},
{
value: "anthropic/claude-sonnet-4-0",
label: "Claude Sonnet 4.0",
description: "anthropic - anthropic/claude-sonnet-4-0",
},
{
value: "anthropic/claude-sonnet-4-20250514",
label: "Claude Sonnet 4 (2025-05-14)",
description: "anthropic - anthropic/claude-sonnet-4-20250514",
},
{
value: "anthropic/claude-sonnet-4-5",
label: "Claude Sonnet 4.5",
description: "anthropic - anthropic/claude-sonnet-4-5",
},
{
value: "anthropic/claude-sonnet-4-5-20250929",
label: "Claude Sonnet 4.5 (2025-09-29)",
description: "anthropic - anthropic/claude-sonnet-4-5-20250929",
},
{
value: "anthropic/claude-sonnet-4-6",
label: "Claude Sonnet 4.6",
description: "anthropic - anthropic/claude-sonnet-4-6",
},
{
value: "openai/gpt-5.2",
label: "GPT-5.2",
description: "openai - openai/gpt-5.2",
},
{
value: "openai/gpt-5.3-codex",
label: "GPT-5.3 Codex",
description: "openai - openai/gpt-5.3-codex",
},
{
value: "openai/gpt-5.3-codex-spark",
label: "GPT-5.3 Codex Spark",
description: "openai - openai/gpt-5.3-codex-spark",
},
{
value: "openai/gpt-5.4",
label: "GPT-5.4",
description: "openai - openai/gpt-5.4",
},
{
value: "openai/gpt-5.4-fast",
label: "GPT-5.4 Fast",
description: "openai - openai/gpt-5.4-fast",
},
{
value: "openai/gpt-5.4-mini",
label: "GPT-5.4 Mini",
description: "openai - openai/gpt-5.4-mini",
},
{
value: "openai/gpt-5.4-mini-fast",
label: "GPT-5.4 Mini Fast",
description: "openai - openai/gpt-5.4-mini-fast",
},
{
value: "openai/gpt-5.5",
label: "GPT-5.5",
description: "openai - openai/gpt-5.5",
},
{
value: "openai/gpt-5.5-fast",
label: "GPT-5.5 Fast",
description: "openai - openai/gpt-5.5-fast",
},
{
value: "openai/gpt-5.5-pro",
label: "GPT-5.5 Pro",
description: "openai - openai/gpt-5.5-pro",
},
],
DEFAULT: "anthropic/claude-sonnet-4-5",
};
/**
* Ordered provider registry. Display order in documentation.
*/
export const PROVIDERS = [
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS },
{ id: "codex", name: "OpenAI", models: CODEX_MODELS },
{ id: "gemini", name: "Google", models: GEMINI_MODELS },
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS },
{ id: "opencode", name: "OpenCode", models: OPENCODE_MODELS },
];

View File

@@ -75,7 +75,7 @@
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/public/modelConstants.js) for the full list of supported models)
## Quick Start

View File

@@ -28,14 +28,10 @@ import {
} from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createNormalizedMessage } from './shared/utils.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
// Sessions cancelled via abort-session. The abort handler already sent the
// terminal `complete` (aborted: true) to the client, so the run loop must not
// emit a second one when its generator winds down.
const abortedSessionIds = new Set();
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
@@ -208,7 +204,7 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
// Model logged at query start below
@@ -308,11 +304,7 @@ function extractTokenBudget(sdkMessage) {
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
if (messageUsage && typeof messageUsage === 'object') {
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 inputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
const totalUsed = inputTokens + outputTokens;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
@@ -322,9 +314,6 @@ function extractTokenBudget(sdkMessage) {
total: contextWindow,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
@@ -735,18 +724,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files
await cleanupTempFiles(tempImagePaths, tempDir);
// Send the terminal completion event — skipped for aborted runs, whose
// terminal `complete` (aborted: true) was already sent by abort-session.
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (!wasAborted) {
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
}
// Send completion event
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
notifyRunStopped({
userId: ws?.userId || null,
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary,
stopReason: wasAborted ? 'aborted' : 'completed'
stopReason: 'completed'
});
// Complete
@@ -761,22 +746,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir);
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (wasAborted) {
// The abort already produced the terminal complete; a generator throw
// caused by interrupt() is expected noise, not a user-facing error.
return;
}
// Check if Claude CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('claude');
const errorContent = !installed
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
: error.message;
// Send error to WebSocket, then the terminal complete
// Send error to WebSocket
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
notifyRunFailed({
userId: ws?.userId || null,
provider: 'claude',
@@ -803,10 +780,6 @@ async function abortClaudeSDKSession(sessionId) {
try {
console.log(`Aborting SDK session: ${sessionId}`);
// Mark before interrupting so the run loop knows not to emit its own
// terminal complete (the abort handler sends the aborted one).
abortedSessionIds.add(sessionId);
// Call interrupt() on the query instance
await session.instance.interrupt();
@@ -822,8 +795,6 @@ async function abortClaudeSDKSession(sessionId) {
return true;
} catch (error) {
console.error(`Error aborting session ${sessionId}:`, error);
// The run keeps going; let it emit its own terminal complete.
abortedSessionIds.delete(sessionId);
return false;
}
}

View File

@@ -455,7 +455,7 @@ async function sandboxCommand(args) {
await new Promise(resolve => setTimeout(resolve, 5000));
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try {
@@ -554,7 +554,7 @@ async function sandboxCommand(args) {
// Step 3: Start CloudCLI inside the sandbox
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
// Step 4: Forward port
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);

View File

@@ -4,7 +4,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -34,10 +34,6 @@ async function spawnCursor(command, options = {}, ws) {
let sessionCreatedSent = false; // Track if we've already sent session-created event
let hasRetriedWithTrust = false;
let settled = false;
// The unified lifecycle contract requires exactly one terminal `complete`
// per run. Cursor surfaces completion twice (the `result` JSON line and
// the process close), so the first emission wins.
let completeSent = false;
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
@@ -201,15 +197,15 @@ async function spawnCursor(command, options = {}, ws) {
break;
case 'result': {
// Session complete — terminal lifecycle event for this run
if (!completeSent) {
completeSent = true;
ws.send(createCompleteMessage({
provider: 'cursor',
sessionId: capturedSessionId || sessionId || null,
exitCode: response.subtype === 'success' ? 0 : 1,
}));
}
// Session complete — send stream end + lifecycle complete with result payload
const resultText = typeof response.result === 'string' ? response.result : '';
ws.send(createNormalizedMessage({
kind: 'complete',
exitCode: response.subtype === 'success' ? 0 : 1,
resultText,
isError: response.subtype !== 'success',
sessionId: capturedSessionId || sessionId, provider: 'cursor',
}));
break;
}
@@ -275,12 +271,7 @@ async function spawnCursor(command, options = {}, ws) {
return;
}
// Terminal complete — unless the `result` line already sent it, or the
// run was aborted (abort-session sent the aborted complete).
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: finalSessionId, exitCode: code }));
}
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
if (code === 0) {
notifyTerminalState({ code });
@@ -306,10 +297,6 @@ async function spawnCursor(command, options = {}, ws) {
: error.message;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
}
notifyTerminalState({ error });
settleOnce(() => reject(error));
@@ -327,9 +314,6 @@ function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId);
if (process) {
console.log(`Aborting Cursor session: ${sessionId}`);
// The abort handler sends the terminal complete (aborted: true); flag the
// process so its close handler does not emit a second one.
process.aborted = true;
process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId);
return true;

View File

@@ -10,7 +10,7 @@ import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -129,9 +129,6 @@ async function spawnGemini(command, options = {}, ws) {
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
let assistantBlocks = []; // Accumulate the full response blocks including tools
// Unified lifecycle contract: exactly one terminal `complete` per run
// (close and error handlers can both fire for spawn failures).
let completeSent = false;
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
@@ -489,12 +486,7 @@ async function spawnGemini(command, options = {}, ws) {
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
}
// Terminal complete — skipped for aborted runs (abort-session
// already sent the aborted complete on this run's behalf).
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
}
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
// Clean up temporary image files if any
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
@@ -574,10 +566,6 @@ async function spawnGemini(command, options = {}, ws) {
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: errorSessionId, exitCode: 1 }));
}
notifyTerminalState({ error });
reject(error);
@@ -602,9 +590,6 @@ function abortGeminiSession(sessionId) {
if (geminiProc) {
try {
// The abort handler sends the terminal complete (aborted: true);
// flag the process so its close handler does not emit a second one.
geminiProc.aborted = true;
geminiProc.kill('SIGTERM');
setTimeout(() => {
if (activeGeminiProcesses.has(processKey)) {

View File

@@ -84,17 +84,9 @@ 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);
@@ -900,27 +892,27 @@ const uploadFilesHandler = async (req, res) => {
}
}),
limits: {
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
files: MAX_FILE_UPLOAD_COUNT
fileSize: 50 * 1024 * 1024, // 50MB limit
files: 20 // Max 20 files at once
}
});
// Use multer middleware
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
uploadMiddleware.array('files', 20)(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 ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
}
return res.status(500).json({ error: err.message });
}
try {
const { projectId } = req.params;
const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
const { targetPath, relativePaths } = req.body;
// Parse relative paths if provided (for folder uploads)
let filePaths = [];
@@ -944,11 +936,6 @@ 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) {
@@ -1027,10 +1014,8 @@ const uploadFilesHandler = async (req, res) => {
res.json({
success: true,
files: uploadedFiles,
uploadedCount: uploadedFiles.length,
requestedFileCount,
targetPath: resolvedTargetDir,
message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
});
} catch (error) {
console.error('Error uploading files:', error);
@@ -1401,8 +1386,6 @@ 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--) {
@@ -1414,11 +1397,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const usage = entry.message.usage;
// Use token counts from latest assistant message only
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);
inputTokens = usage.input_tokens || 0;
outputTokens = usage.output_tokens || 0;
break; // Stop after finding the latest assistant message
}
@@ -1429,16 +1409,12 @@ 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
@@ -1507,133 +1483,74 @@ function permToRwx(perm) {
return r + w + x;
}
// Directories that are almost never interesting for a project tree but can
// contain tens of thousands of files. Skipping them before recursion keeps
// traversal time bounded on large monorepos and high-latency filesystems
// (NFS / SMB).
const IGNORED_DIRS = new Set([
// JS / TS toolchains
'node_modules', 'dist', 'build', '.next', '.nuxt', '.cache', '.parcel-cache',
// VCS
'.git', '.svn', '.hg',
// Python
'__pycache__', '.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv',
// Rust / Go / Java / Ruby
'target', 'vendor',
// Build output / IDE
'.gradle', '.idea', 'coverage', '.nyc_output'
]);
const DEFAULT_FS_CONCURRENCY = 64;
const parsedFsConcurrency = Number.parseInt(process.env.FS_CONCURRENCY || '', 10);
const FS_CONCURRENCY = Number.isFinite(parsedFsConcurrency) && parsedFsConcurrency > 0
? parsedFsConcurrency
: DEFAULT_FS_CONCURRENCY;
let activeFsOperations = 0;
const pendingFsOperations = [];
async function acquire() {
if (activeFsOperations < FS_CONCURRENCY) {
activeFsOperations += 1;
return;
}
await new Promise((resolve) => {
pendingFsOperations.push(resolve);
});
}
function release() {
const next = pendingFsOperations.shift();
if (next) {
next();
return;
}
activeFsOperations = Math.max(0, activeFsOperations - 1);
}
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
// Using fsPromises from import
let entries;
const items = [];
try {
await acquire();
try {
entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
} finally {
release();
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
// Debug: log all entries including hidden files
// Skip heavy build directories and VCS directories
if (entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build' ||
entry.name === '.git' ||
entry.name === '.svn' ||
entry.name === '.hg') continue;
const itemPath = path.join(dirPath, entry.name);
const item = {
name: entry.name,
path: itemPath,
type: entry.isDirectory() ? 'directory' : 'file'
};
// Get file stats for additional metadata
try {
const stats = await fsPromises.stat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
// Convert permissions to rwx format
const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7;
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
} catch (statError) {
// If stat fails, provide default values
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recursively get subdirectories but limit depth
try {
// Check if we can access the directory before trying to read it
await fsPromises.access(item.path, fs.constants.R_OK);
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
} catch (e) {
// Silently skip directories we can't access (permission denied, etc.)
item.children = [];
}
}
items.push(item);
}
} catch (error) {
// Only log non-permission errors to avoid spam
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
console.error('Error reading directory:', error);
}
return [];
}
const filteredEntries = entries.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)));
// Process every entry in parallel. On high-latency filesystems (NFS/SMB)
// serial stat() was the real bottleneck — issuing them concurrently lets
// the kernel pipeline the round-trips and the recursive calls overlap too.
const items = await Promise.all(filteredEntries.map(async (entry) => {
const itemPath = path.join(dirPath, entry.name);
const item = {
name: entry.name,
path: itemPath,
type: entry.isDirectory() ? 'directory' : 'file'
};
// Get file stats for additional metadata
try {
await acquire();
try {
const stats = await fsPromises.lstat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
// Mark symlinks so UI can distinguish them
if (stats.isSymbolicLink()) {
item.isSymlink = true;
}
// Convert permissions to rwx format
const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7;
item.permissions =
((mode >> 6) & 7).toString() +
((mode >> 3) & 7).toString() +
(mode & 7).toString();
item.permissionsRwx =
permToRwx(ownerPerm) +
permToRwx(groupPerm) +
permToRwx(otherPerm);
} finally {
release();
}
} catch (statError) {
// If stat fails, provide default values
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recurse. Let readdir's own EACCES bubble up through the catch in
// the recursive call rather than doing a separate access() probe
// (which doubled the round-trip count on SMB without adding info).
// The recursive call starts with a bounded readdir; holding a permit
// for the whole subtree can deadlock when sibling directories are
// waiting on their own children.
item.children = await getFileTree(itemPath, maxDepth, currentDepth + 1, showHidden);
}
return item;
}));
return items.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;

View File

@@ -10,7 +10,6 @@ type NotificationPreferences = {
channels: {
inApp: boolean;
webPush: boolean;
sound: boolean;
};
events: {
actionRequired: boolean;
@@ -23,7 +22,6 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
channels: {
inApp: false,
webPush: false,
sound: true,
},
events: {
actionRequired: true,
@@ -39,7 +37,6 @@ function normalizeNotificationPreferences(value: unknown): NotificationPreferenc
channels: {
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush === true,
sound: source.channels?.sound !== false,
},
events: {
actionRequired: source.events?.actionRequired !== false,

View File

@@ -67,17 +67,8 @@ function resolveRouteErrorMessage(error: unknown): string {
router.get(
'/',
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,
});
asyncHandler(async (_req, res) => {
const projects = await getProjectsWithSessions();
res.json(projects);
}),
);

View File

@@ -83,7 +83,7 @@ The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
- Update `server/modules/providers/provider.routes.ts`.
- Update `server/routes/agent.js` if the provider is launchable from the agent runtime.
- Update `server/index.js` if the provider needs runtime boot or shutdown wiring.
- Update the `PROVIDER_ORDER` list in `public/api-docs.html` if the provider should appear in the public API docs.
- Update `public/modelConstants.js` if the provider appears in README or public API docs.
- Update `src/components/chat/hooks/useChatProviderState.ts` and
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
the provider should be selectable in chat.

View File

@@ -18,28 +18,18 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
{
value: 'default',
label: 'Default (recommended)',
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok',
},
{
value: 'fable',
label: 'Fable',
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
},
{
value: "sonnet",
label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
value: 'sonnet',
label: 'Sonnet',
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok',
},
{
value: 'sonnet[1m]',
label: 'Sonnet (1M context)',
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
},
{
value: 'opus[1m]',
label: 'Opus 4.8 (1M context)',
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
},
{
value: 'haiku',
label: 'Haiku',

View File

@@ -17,7 +17,6 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const PROVIDER_MODELS_CACHE_VERSION = 1;
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
type ProviderModelsServiceDependencies = {
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
@@ -233,42 +232,10 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
return request;
};
const loadDirectModels = (
provider: LLMProvider,
): Promise<ProviderModelsResult> => {
const request = resolveProvider(provider).models.getSupportedModels()
.then((models) => {
const currentTime = now();
return {
models,
cache: {
updatedAt: new Date(currentTime).toISOString(),
expiresAt: new Date(currentTime).toISOString(),
source: 'fresh' as const,
},
};
})
.finally(() => {
pendingRequests.delete(provider);
});
pendingRequests.set(provider, request);
return request;
};
const getProviderModels = async (
provider: LLMProvider,
options: ProviderModelsOptions = {},
): Promise<ProviderModelsResult> => {
if (UNCACHED_PROVIDERS.has(provider)) {
const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) {
return pendingRequest;
}
return loadDirectModels(provider);
}
if (options.bypassCache) {
const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) {

View File

@@ -130,37 +130,6 @@ test('provider models are cached for the three-day ttl', async () => {
}
});
test('claude provider models are always loaded directly from the provider', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
let loadCount = 0;
try {
const service = createProviderModelsService({
cachePath: path.join(tempRoot, 'models-cache.json'),
resolveProvider: (provider) => ({
models: {
getSupportedModels: async () => {
loadCount += 1;
return createModels(`${provider}-${loadCount}`);
},
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
},
}),
});
const first = await service.getProviderModels('claude');
const second = await service.getProviderModels('claude');
assert.equal(loadCount, 2);
assert.equal(first.models.DEFAULT, 'claude-1');
assert.equal(second.models.DEFAULT, 'claude-2');
assert.equal(second.cache.source, 'fresh');
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
test('provider model cache is persisted across service instances', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
const cachePath = path.join(tempRoot, 'models-cache.json');

View File

@@ -133,10 +133,9 @@ flowchart TD
### Chat Notes
1. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`), regardless of provider: `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. Failed runs emit an informational `error` message first, then the terminal `complete` with `success: false`. Mid-run `error` messages (e.g. stderr output) are non-terminal; the frontend only treats `complete` as end-of-run.
2. `abort-session` sends the terminal `complete` (`aborted: true`) on behalf of the cancelled run; providers detect the abort and skip their own `complete` so the client sees exactly one.
3. `check-session-status` returns `{ type: "session-status", isProcessing }`.
4. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
1. `abort-session` returns a normalized `complete` message with `aborted: true`.
2. `check-session-status` returns `{ type: "session-status", isProcessing }`.
3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
## `/shell` Terminal Flow

View File

@@ -7,7 +7,7 @@ import type {
AuthenticatedWebSocketRequest,
LLMProvider,
} from '@/shared/types.js';
import { createCompleteMessage, parseIncomingJsonObject } from '@/shared/utils.js';
import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
type ChatIncomingMessage = AnyRecord & {
type?: string;
@@ -173,14 +173,14 @@ export function handleChatConnection(
success = await dependencies.abortClaudeSDKSession(sessionId);
}
// Terminal complete on behalf of the cancelled run — providers skip
// their own complete for aborted runs so the client sees exactly one.
writer.send(
createCompleteMessage({
provider,
sessionId,
createNormalizedMessage({
kind: 'complete',
exitCode: success ? 0 : 1,
aborted: true,
success,
sessionId,
provider,
})
);
return;
@@ -202,11 +202,13 @@ export function handleChatConnection(
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
const success = dependencies.abortCursorSession(sessionId);
writer.send(
createCompleteMessage({
provider: 'cursor',
sessionId,
createNormalizedMessage({
kind: 'complete',
exitCode: success ? 0 : 1,
aborted: true,
success,
sessionId,
provider: 'cursor',
})
);
return;

View File

@@ -18,7 +18,6 @@ type ShellIncomingMessage = {
provider?: string;
initialCommand?: string;
isPlainShell?: boolean;
forceRestart?: boolean;
};
type PtySessionEntry = {
@@ -181,7 +180,6 @@ export function handleShellConnection(
const hasSession = readBoolean(data.hasSession);
const provider = readString(data.provider, 'claude');
const initialCommand = readString(data.initialCommand);
const forceRestart = readBoolean(data.forceRestart);
const isPlainShell =
readBoolean(data.isPlainShell) ||
(!!initialCommand && !hasSession) ||
@@ -202,7 +200,7 @@ export function handleShellConnection(
: '';
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
if (isLoginCommand || forceRestart) {
if (isLoginCommand) {
const oldSession = ptySessionsMap.get(ptySessionKey);
if (oldSession) {
if (oldSession.timeoutId) {
@@ -213,8 +211,7 @@ export function handleShellConnection(
}
}
const existingSession =
isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
if (existingSession) {
shellProcess = existingSession.pty;
if (existingSession.timeoutId) {
@@ -371,10 +368,6 @@ export function handleShellConnection(
}
const session = ptySessionsMap.get(ptySessionKey);
if (session && session.pty !== shellProcess) {
return;
}
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.send(
JSON.stringify({
@@ -458,10 +451,6 @@ export function handleShellConnection(
session.ws = null;
session.timeoutId = setTimeout(() => {
if (ptySessionsMap.get(ptySessionKey as string) !== session) {
return;
}
session.pty.kill();
ptySessionsMap.delete(ptySessionKey as string);
}, PTY_SESSION_TIMEOUT);

View File

@@ -20,13 +20,7 @@ export function verifyWebSocketClient(
dependencies: WebSocketAuthDependencies
): boolean {
const request = info.req as AuthenticatedWebSocketRequest;
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
const loggedUrl = new URL(upgradeUrl);
if (loggedUrl.searchParams.has('token')) {
loggedUrl.searchParams.set('token', 'REDACTED');
}
console.log('WebSocket connection attempt to:', `${loggedUrl.pathname}${loggedUrl.search}`);
console.log('WebSocket connection attempt to:', request.url);
// Platform mode: use the first DB user and skip token checks.
if (dependencies.isPlatform) {
@@ -42,6 +36,7 @@ export function verifyWebSocketClient(
}
// OSS mode: read JWT from query string first, then Authorization header.
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
const token =
upgradeUrl.searchParams.get('token') ??
request.headers.authorization?.split(' ')[1] ??

View File

@@ -31,24 +31,6 @@ export function createWebSocketServer(
});
wss.on('connection', (ws, request) => {
// Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s,
// AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections
// are silently torn down even when the UI is active, causing repeated
// reconnect cycles. ws library heartbeat is opt-in.
const HEARTBEAT_INTERVAL_MS = 30_000;
const heartbeat = setInterval(() => {
if (ws.readyState === ws.OPEN) {
try {
ws.ping();
} catch {
// socket may have been closed concurrently — interval will be cleared below
}
}
}, HEARTBEAT_INTERVAL_MS);
const stopHeartbeat = () => clearInterval(heartbeat);
ws.on('close', stopHeartbeat);
ws.on('error', stopHeartbeat);
const incomingRequest = request as AuthenticatedWebSocketRequest;
const url = incomingRequest.url ?? '/';
const pathname = new URL(url, 'http://localhost').pathname;

View File

@@ -18,7 +18,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createNormalizedMessage } from './shared/utils.js';
// Track active sessions
const activeCodexSessions = new Map();
@@ -352,26 +352,21 @@ export async function queryCodex(command, options = {}, ws) {
}
}
// Send the terminal completion event — skipped for aborted runs, whose
// terminal `complete` (aborted: true) was already sent by abort-session.
const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null;
const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted;
if (!runAborted) {
sendMessage(ws, createCompleteMessage({
// Send completion event
if (!terminalFailure) {
sendMessage(ws, createNormalizedMessage({
kind: 'complete',
actualSessionId: capturedSessionId || thread.id || sessionId || null,
sessionId: capturedSessionId || sessionId || null,
provider: 'codex'
}));
notifyRunStopped({
userId: ws?.userId || null,
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
actualSessionId: capturedSessionId || thread.id || sessionId || null,
exitCode: terminalFailure ? 1 : 0,
}));
if (!terminalFailure) {
notifyRunStopped({
userId: ws?.userId || null,
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary,
stopReason: 'completed'
});
}
sessionName: sessionSummary,
stopReason: 'completed'
});
}
} catch (error) {
@@ -391,11 +386,6 @@ export async function queryCodex(command, options = {}, ws) {
: error.message;
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
sendMessage(ws, createCompleteMessage({
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
exitCode: 1,
}));
if (!terminalFailure) {
notifyRunFailed({
userId: ws?.userId || null,

View File

@@ -8,7 +8,7 @@ import { sessionsService } from './modules/providers/services/sessions.service.j
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
import { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -92,9 +92,6 @@ async function spawnOpenCode(command, options = {}, ws) {
let stdoutLineBuffer = '';
let terminalNotificationSent = false;
let opencodeProcess = null;
// Unified lifecycle contract: exactly one terminal `complete` per run
// (close and error handlers can both fire for spawn failures).
let completeSent = false;
const notifyTerminalState = ({ code = null, error = null } = {}) => {
if (terminalNotificationSent) {
@@ -259,12 +256,13 @@ async function spawnOpenCode(command, options = {}, ws) {
}));
}
// Terminal complete — skipped for aborted runs (abort-session
// already sent the aborted complete on this run's behalf).
if (!completeSent && !opencodeProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: code }));
}
ws.send(createNormalizedMessage({
kind: 'complete',
exitCode: code,
isNewSession: !sessionId && !!command,
sessionId: finalSessionId,
provider: 'opencode',
}));
if (code === 0) {
notifyTerminalState({ code });
@@ -304,10 +302,6 @@ async function spawnOpenCode(command, options = {}, ws) {
sessionId: finalSessionId,
provider: 'opencode',
}));
if (!completeSent && !opencodeProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: 1 }));
}
notifyTerminalState({ error });
reject(error);
});
@@ -321,9 +315,6 @@ function abortOpenCodeSession(sessionId) {
return false;
}
// The abort handler sends the terminal complete (aborted: true); flag the
// process so its close handler does not emit a second one.
process.aborted = true;
process.kill('SIGTERM');
activeOpenCodeProcesses.delete(sessionId);
return true;

View File

@@ -592,14 +592,12 @@ class ResponseCollector {
}
}
const inputTokens = totalInput + totalCacheRead + totalCacheCreation;
return {
inputTokens,
inputTokens: totalInput,
outputTokens: totalOutput,
cacheReadTokens: totalCacheRead,
cacheCreationTokens: totalCacheCreation,
totalTokens: inputTokens + totalOutput
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
};
}
}
@@ -646,7 +644,7 @@ class ResponseCollector {
*
* @param {string} model - (Optional) Model identifier for providers.
*
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',

View File

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

View File

@@ -68,7 +68,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
/**
* One selectable model row in a provider model catalog.
* One selectable model row (matches the documentation `public/modelConstants.js` option shape).
*/
export type ProviderModelOption = {
value: string;

View File

@@ -346,43 +346,6 @@ export function createNormalizedMessage(fields: NormalizedMessageInput): Normali
};
}
/**
* Build the unified terminal `complete` lifecycle message.
*
* Contract: every provider run ends with exactly one `complete` (the
* abort-session handler emits it on behalf of cancelled runs, so aborted runs
* must NOT emit their own). The frontend treats `complete` as the only
* terminal signal and never needs provider-specific handling:
*
* - `sessionId` — the id the client knows this run by ('' if never discovered)
* - `actualSessionId` — canonical id after the run; equals `sessionId` unless
* the provider rewrote it mid-run
* - `exitCode` — 0 on success; a missing/null code (e.g. killed process)
* is reported as failure
* - `success` — exitCode === 0 and not aborted
* - `aborted` — run was cancelled by the user
*/
export function createCompleteMessage(opts: {
provider: NormalizedMessage['provider'];
sessionId?: string | null;
actualSessionId?: string | null;
exitCode?: number | null;
aborted?: boolean;
}): NormalizedMessage {
const exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : 1;
const aborted = Boolean(opts.aborted);
return createNormalizedMessage({
kind: 'complete',
provider: opts.provider,
sessionId: opts.sessionId || null,
actualSessionId: opts.actualSessionId || opts.sessionId || null,
exitCode,
success: exitCode === 0 && !aborted,
aborted,
});
}
// ---------------------------
//----------------- MCP CONFIG PARSING UTILITIES ------------
/**

View File

@@ -1,8 +1,7 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { spawn } from 'cross-spawn';
import { spawn } from 'child_process';
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');

View File

@@ -7,41 +7,6 @@ const runningPlugins = new Map();
// Map<pluginName, Promise<port>> — in-flight start operations
const startingPlugins = new Map();
/**
* Build the environment handed to a plugin server subprocess.
*
* Intentionally minimal: only non-secret essentials, never the host's full
* environment. On Windows a handful of system variables are required for any
* child to bootstrap (Node itself, and any Python or CLI a plugin shells out
* to). Without APPDATA a `pip install --user` tool cannot locate its
* site-packages and fails to import; SystemRoot, PATHEXT and TEMP are needed to
* resolve system DLLs, executable extensions and a temp directory. None of
* these carry secrets, so the ones that are set get passed straight through.
*/
function buildPluginEnv(name) {
const env = {
PATH: process.env.PATH,
HOME: process.env.HOME,
NODE_ENV: process.env.NODE_ENV || 'production',
PLUGIN_NAME: name,
};
if (process.platform === 'win32') {
const WINDOWS_ESSENTIALS = [
'SystemRoot', 'windir', 'SystemDrive',
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
'TEMP', 'TMP', 'PATHEXT',
];
for (const key of WINDOWS_ESSENTIALS) {
if (process.env[key] !== undefined) {
env[key] = process.env[key];
}
}
}
return env;
}
/**
* Start a plugin's server subprocess.
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
@@ -61,9 +26,15 @@ export function startPluginServer(name, pluginDir, serverEntry) {
const serverPath = path.join(pluginDir, serverEntry);
// Restricted env — only essentials, no host secrets
const pluginProcess = spawn('node', [serverPath], {
cwd: pluginDir,
env: buildPluginEnv(name),
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
NODE_ENV: process.env.NODE_ENV || 'production',
PLUGIN_NAME: name,
},
stdio: ['ignore', 'pipe', 'pipe'],
});

View File

@@ -28,9 +28,12 @@ function AppContentInner() {
const wasConnectedRef = useRef(false);
const {
activeSessions,
processingSessions,
markSessionProcessing,
markSessionIdle,
markSessionAsActive,
markSessionAsInactive,
markSessionAsProcessing,
markSessionAsNotProcessing,
} = useSessionProtection();
const {
@@ -54,7 +57,7 @@ function AppContentInner() {
navigate,
latestMessage,
isMobile,
activeSessions: processingSessions,
activeSessions,
});
usePaletteOpsRegister({
@@ -182,8 +185,10 @@ function AppContentInner() {
onMenuClick={() => setSidebarOpen(true)}
isLoading={isLoadingProjects}
onInputFocusChange={setIsInputFocused}
onSessionProcessing={markSessionProcessing}
onSessionIdle={markSessionIdle}
onSessionActive={markSessionAsActive}
onSessionInactive={markSessionAsInactive}
onSessionProcessing={markSessionAsProcessing}
onSessionNotProcessing={markSessionAsNotProcessing}
processingSessions={processingSessions}
onNavigateToSession={(targetSessionId: string, options) =>
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })

View File

@@ -0,0 +1,44 @@
import { Brain, Zap, Sparkles, Atom } from 'lucide-react';
export const thinkingModes = [
{
id: 'none',
name: 'Standard',
description: 'Regular Claude response',
icon: null,
prefix: '',
color: 'text-gray-600'
},
{
id: 'think',
name: 'Think',
description: 'Basic extended thinking',
icon: Brain,
prefix: 'think',
color: 'text-blue-600'
},
{
id: 'think-hard',
name: 'Think Hard',
description: 'More thorough evaluation',
icon: Zap,
prefix: 'think hard',
color: 'text-purple-600'
},
{
id: 'think-harder',
name: 'Think Harder',
description: 'Deep analysis with alternatives',
icon: Sparkles,
prefix: 'think harder',
color: 'text-indigo-600'
},
{
id: 'ultrathink',
name: 'Ultrathink',
description: 'Maximum thinking budget',
icon: Atom,
prefix: 'ultrathink',
color: 'text-red-600'
}
];

View File

@@ -12,8 +12,7 @@ import type {
import { useDropzone } from 'react-dropzone';
import { authenticatedFetch } from '../../../utils/api';
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import { thinkingModes } from '../constants/thinkingModes';
import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage';
import type {
@@ -27,6 +26,10 @@ import { escapeRegExp } from '../utils/chatFormatting';
import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
type PendingViewSession = {
startedAt: number;
};
interface UseChatComposerStateArgs {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
@@ -44,12 +47,17 @@ interface UseChatComposerStateArgs {
tokenBudget: Record<string, unknown> | null;
sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean;
onSessionProcessing?: MarkSessionProcessing;
onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onInputFocusChange?: (focused: boolean) => void;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
pendingViewSessionRef: { current: PendingViewSession | null };
scrollToBottom: () => void;
addMessage: (msg: ChatMessage) => void;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
}
@@ -170,12 +178,17 @@ export function useChatComposerState({
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionActive,
onSessionProcessing,
onInputFocusChange,
onFileOpen,
onShowSettings,
pendingViewSessionRef,
scrollToBottom,
addMessage,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setIsUserScrolledUp,
setPendingPermissionRequests,
}: UseChatComposerStateArgs) {
@@ -191,6 +204,7 @@ export function useChatComposerState({
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const [thinkingMode, setThinkingMode] = useState('none');
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -299,7 +313,7 @@ export function useChatComposerState({
}, [addMessage]);
const executeCommand = useCallback(
async (command: SlashCommand, rawInput?: string, options?: { preserveInput?: boolean }) => {
async (command: SlashCommand, rawInput?: string) => {
if (!command || !selectedProject) {
return;
}
@@ -356,10 +370,8 @@ export function useChatComposerState({
const result = (await response.json()) as CommandExecutionResult;
if (result.type === 'builtin') {
handleBuiltInCommand(result);
if (!options?.preserveInput) {
setInput('');
inputValueRef.current = '';
}
setInput('');
inputValueRef.current = '';
} else if (result.type === 'custom') {
await handleCustomCommand(result);
}
@@ -390,19 +402,6 @@ 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,
@@ -563,7 +562,11 @@ export function useChatComposerState({
}
}
const messageContent = currentInput;
let messageContent = currentInput;
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) {
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
}
let uploadedImages: unknown[] = [];
if (attachedImages.length > 0) {
@@ -608,18 +611,27 @@ export function useChatComposerState({
};
addMessage(userMessage);
// Mark this request as processing in the per-session activity map (the
// single source of truth the indicator derives from). A brand-new
// conversation has no session id yet, so it is tracked under the
// pending placeholder until `session_created` announces the real id.
onSessionProcessing?.(effectiveSessionId || PENDING_SESSION_ID, {
statusText: null,
canInterrupt: true,
setIsLoading(true); // Processing banner starts
setCanAbortSession(true);
setClaudeStatus({
text: 'Processing',
tokens: 0,
can_interrupt: true,
});
setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 100);
if (!effectiveSessionId && !selectedSession?.id) {
// This tracks only that a request is in flight before the provider has
// emitted its real session id; routing still waits for session_created.
pendingViewSessionRef.current = { startedAt: Date.now() };
}
if (effectiveSessionId) {
onSessionActive?.(effectiveSessionId);
onSessionProcessing?.(effectiveSessionId);
}
const getToolsSettings = () => {
try {
const settingsKey =
@@ -737,6 +749,7 @@ export function useChatComposerState({
setUploadingImages(new Map());
setImageErrors(new Map());
setIsTextareaExpanded(false);
setThinkingMode('none');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
@@ -755,16 +768,22 @@ export function useChatComposerState({
geminiModel,
opencodeModel,
isLoading,
onSessionActive,
onSessionProcessing,
pendingViewSessionRef,
permissionMode,
provider,
resetCommandMenuState,
scrollToBottom,
selectedProject,
sendMessage,
setCanAbortSession,
addMessage,
setClaudeStatus,
setIsLoading,
setIsUserScrolledUp,
slashCommands,
thinkingMode,
],
);
@@ -974,11 +993,15 @@ export function useChatComposerState({
});
});
setPendingPermissionRequests((previous) =>
previous.filter((request) => !validIds.includes(request.requestId)),
);
setPendingPermissionRequests((previous) => {
const next = previous.filter((request) => !validIds.includes(request.requestId));
if (next.length === 0) {
setClaudeStatus(null);
}
return next;
});
},
[sendMessage, setPendingPermissionRequests],
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
);
const [isInputFocused, setIsInputFocused] = useState(false);
@@ -997,6 +1020,8 @@ export function useChatComposerState({
textareaRef,
inputHighlightRef,
isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount,
filteredCommands,
frequentCommands,
@@ -1034,6 +1059,5 @@ export function useChatComposerState({
isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
};
}

View File

@@ -7,12 +7,6 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage, SubagentChildTool } from '../types/types';
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
function formatToolResultContent(content: unknown): string {
const text = typeof content === 'string' ? content : JSON.stringify(content);
const toolUseErrorMatch = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/.exec(text.trim());
return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
}
/**
* Convert NormalizedMessage[] from the session store into ChatMessage[]
* that the existing UI components expect.
@@ -26,12 +20,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
// First pass: collect tool results for attachment
const toolResultMap = new Map<string, NormalizedMessage>();
const toolUseIds = new Set<string>();
for (const msg of messages) {
if (msg.kind === 'tool_use' && msg.toolId) {
toolUseIds.add(msg.toolId);
}
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
@@ -108,7 +97,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
const toolResult = tr
? {
content: formatToolResultContent(tr.content),
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
isError: Boolean(tr.isError),
toolUseResult: (tr as any).toolUseResult,
}
@@ -202,25 +191,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
break;
// tool_result is handled via attachment to tool_use above
case 'tool_result': {
if (msg.toolId && toolUseIds.has(msg.toolId)) {
break;
}
const content = formatToolResultContent(msg.content || '');
if (!content.trim()) {
break;
}
converted.push({
type: msg.isError ? 'error' : 'assistant',
content,
timestamp: msg.timestamp,
toolId: msg.toolId,
...sharedMetadata,
});
case 'tool_result':
break;
}
default:
break;

View File

@@ -2,14 +2,14 @@ import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
import { playChatCompletionSound } from '../../../utils/notificationSound';
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
type PendingViewSession = {
startedAt: number;
};
type LatestChatMessage = {
type?: string;
kind?: string;
@@ -53,14 +53,18 @@ interface UseChatRealtimeHandlersArgs {
selectedSession: ProjectSession | null;
currentSessionId: string | null;
setCurrentSessionId: (sessionId: string | null) => void;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>;
/** When each session's `check-session-status` was last sent; guards stale idle replies. */
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
onSessionProcessing?: MarkSessionProcessing;
onSessionIdle?: MarkSessionIdle;
onSessionInactive?: (sessionId?: string | null) => void;
onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onSessionNotProcessing?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
onWebSocketReconnect?: () => void;
sessionStore: SessionStore;
@@ -76,13 +80,18 @@ export function useChatRealtimeHandlers({
selectedSession,
currentSessionId,
setCurrentSessionId,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests,
pendingViewSessionRef,
streamTimerRef,
accumulatedStreamRef,
statusCheckSentAtRef,
onSessionInactive,
onSessionActive,
onSessionProcessing,
onSessionIdle,
onSessionNotProcessing,
onNavigateToSession,
onWebSocketReconnect,
sessionStore,
@@ -127,24 +136,35 @@ export function useChatRealtimeHandlers({
const status = msg.status;
if (status) {
onSessionProcessing?.(statusSessionId, {
statusText: status.text || null,
canInterrupt: status.can_interrupt !== false,
});
const statusInfo = {
text: status.text || 'Working...',
tokens: status.tokens || 0,
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
};
setClaudeStatus(statusInfo);
setIsLoading(true);
setCanAbortSession(statusInfo.can_interrupt);
return;
}
// Reply to check-session-status (or unsolicited processing update)
// Legacy isProcessing format from check-session-status
const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (msg.isProcessing) {
onSessionActive?.(statusSessionId);
onSessionProcessing?.(statusSessionId);
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
return;
}
// Idle reply: ignore it if a newer request started after the check
// was sent — the reply describes the older request.
onSessionIdle?.(statusSessionId, {
ifStartedBefore: statusCheckSentAtRef.current.get(statusSessionId),
});
onSessionInactive?.(statusSessionId);
onSessionNotProcessing?.(statusSessionId);
if (isCurrentSession) {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
}
return;
}
@@ -216,15 +236,23 @@ export function useChatRealtimeHandlers({
// We no longer synthesize client-side placeholder IDs. Until the provider
// announces `session_created`, the active id is expected to be null.
if (!currentSessionId) {
console.log('Session created with ID:', newSessionId);
console.log('Existing session ID:', currentSessionId);
setCurrentSessionId(newSessionId);
setPendingPermissionRequests((prev) =>
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
);
}
// The in-flight request now has a concrete session id: migrate the
// processing entry from the pending placeholder.
onSessionIdle?.(PENDING_SESSION_ID);
pendingViewSessionRef.current = null;
onSessionActive?.(newSessionId);
onSessionProcessing?.(newSessionId);
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({
text: 'Processing',
tokens: 0,
can_interrupt: true,
});
onNavigateToSession?.(newSessionId);
break;
}
@@ -241,28 +269,22 @@ export function useChatRealtimeHandlers({
}
accumulatedStreamRef.current = '';
// `complete` is the unified terminal event — every provider run ends
// with exactly one, regardless of success, failure, or abort. The
// indicator derives from the processing map, so deleting the entry
// hides it immediately and atomically.
onSessionIdle?.(sid);
onSessionIdle?.(PENDING_SESSION_ID);
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
setPendingPermissionRequests([]);
onSessionInactive?.(sid);
onSessionNotProcessing?.(sid);
pendingViewSessionRef.current = null;
// Handle aborted case
if (msg.aborted) {
// Abort was requested — the complete event confirms it
// No special UI action needed beyond clearing the processing entry above
// No special UI action needed beyond clearing loading state above
// The backend already sent any abort-related messages
break;
}
// Celebrate only successful runs (failed runs end with success: false).
if (msg.success !== false) {
showCompletionTitleIndicator();
void playChatCompletionSound();
}
const actualSessionId =
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
? msg.actualSessionId
@@ -275,7 +297,6 @@ export function useChatRealtimeHandlers({
if (actualSessionId && sid && actualSessionId !== sid) {
sessionStore.replaceSessionId(sid, actualSessionId);
onSessionIdle?.(actualSessionId);
if (isVisibleSession) {
setCurrentSessionId(actualSessionId);
@@ -291,9 +312,15 @@ export function useChatRealtimeHandlers({
break;
}
// 'error' is an informational message row, not a terminal event —
// providers emit it for mid-run stderr output too. Run teardown is
// always signalled by the unified 'complete' that follows.
case 'error': {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
onSessionInactive?.(sid);
onSessionNotProcessing?.(sid);
pendingViewSessionRef.current = null;
break;
}
case 'permission_request': {
if (!msg.requestId) break;
@@ -308,7 +335,9 @@ export function useChatRealtimeHandlers({
receivedAt: new Date(),
}];
});
onSessionProcessing?.(sid || PENDING_SESSION_ID);
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
break;
}
@@ -323,10 +352,13 @@ export function useChatRealtimeHandlers({
if (msg.text === 'token_budget' && msg.tokenBudget) {
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
} else if (msg.text) {
onSessionProcessing?.(sid || PENDING_SESSION_ID, {
statusText: msg.text,
canInterrupt: msg.canInterrupt !== false,
setClaudeStatus({
text: msg.text,
tokens: msg.tokens || 0,
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
});
setIsLoading(true);
setCanAbortSession(msg.canInterrupt !== false);
}
break;
}
@@ -342,13 +374,18 @@ export function useChatRealtimeHandlers({
selectedSession,
currentSessionId,
setCurrentSessionId,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests,
pendingViewSessionRef,
streamTimerRef,
accumulatedStreamRef,
statusCheckSentAtRef,
onSessionInactive,
onSessionActive,
onSessionProcessing,
onSessionIdle,
onSessionNotProcessing,
onNavigateToSession,
onWebSocketReconnect,
sessionStore,

View File

@@ -2,8 +2,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import type { MutableRefObject } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage, Provider } from '../types/types';
@@ -14,6 +12,10 @@ import { normalizedToChatMessages } from './useChatMessages';
const MESSAGES_PER_PAGE = 20;
const INITIAL_VISIBLE_MESSAGES = 100;
type PendingViewSession = {
startedAt: number;
};
interface UseChatSessionStateArgs {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
@@ -22,11 +24,9 @@ interface UseChatSessionStateArgs {
autoScrollToBottom?: boolean;
externalMessageUpdate?: number;
newSessionTrigger?: number;
processingSessions?: SessionActivityMap;
onSessionIdle?: MarkSessionIdle;
processingSessions?: Set<string>;
resetStreamingState: () => void;
/** When each session's `check-session-status` was last sent; guards stale idle replies. */
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
sessionStore: SessionStore;
}
@@ -99,19 +99,21 @@ export function useChatSessionState({
externalMessageUpdate,
newSessionTrigger,
processingSessions,
onSessionIdle,
resetStreamingState,
statusCheckSentAtRef,
pendingViewSessionRef,
sessionStore,
}: UseChatSessionStateArgs) {
const [isLoading, setIsLoading] = useState(false);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
const [hasMoreMessages, setHasMoreMessages] = useState(false);
const [totalMessages, setTotalMessages] = useState(0);
const [canAbortSession, setCanAbortSession] = useState(false);
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
@@ -168,7 +170,10 @@ export function useChatSessionState({
* - No coupling to unrelated external update signals.
*/
resetStreamingState();
onSessionIdle?.(PENDING_SESSION_ID);
pendingViewSessionRef.current = null;
setClaudeStatus(null);
setCanAbortSession(false);
setIsLoading(false);
setCurrentSessionId(null);
setPendingUserMessage(null);
sessionStorage.removeItem('cursorSessionId');
@@ -199,29 +204,13 @@ export function useChatSessionState({
clearTimeout(loadAllFinishedTimerRef.current);
loadAllFinishedTimerRef.current = null;
}
}, [newSessionTrigger, onSessionIdle, resetStreamingState]);
/* ---------------------------------------------------------------- */
/* Derive processing state for the viewed session */
/* ---------------------------------------------------------------- */
const activeSessionId = selectedSession?.id || currentSessionId || null;
// The activity indicator always reflects the latest status of the session
// being viewed (or of the pending not-yet-created session on a fresh
// draft) — never stale local UI state from the last time it was open.
const sessionActivity = processingSessions?.get(activeSessionId ?? PENDING_SESSION_ID) ?? null;
const isProcessing = sessionActivity !== null;
const canAbortSession = isProcessing && sessionActivity.canInterrupt;
// Ref mirror so effects can read the latest map without re-running on
// every activity transition.
const processingSessionsRef = useRef(processingSessions);
processingSessionsRef.current = processingSessions;
}, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]);
/* ---------------------------------------------------------------- */
/* Derive chatMessages from the store */
/* ---------------------------------------------------------------- */
const activeSessionId = selectedSession?.id || currentSessionId || null;
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
@@ -394,59 +383,28 @@ export function useChatSessionState({
setIsUserScrolledUp(false);
}, [selectedProject?.projectId, selectedSession?.id]);
// Initial scroll to bottom — robust to lazy content reflow.
// The previous implementation fired one scrollToBottom() at +200ms and
// cleared the pending flag. When markdown blocks, code highlighting, or
// images finished rendering after that window, scrollHeight grew but
// nothing re-anchored the viewport, leaving the chat tab visually
// "scrolled way up" with the latest assistant message off-screen.
//
// This version re-scrolls every animation frame while scrollHeight is
// still growing, capped at ~1s (60 frames) or 3 consecutive stable
// frames. Cancels cleanly on session change via the pending flag.
// Initial scroll to bottom
useEffect(() => {
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; }
const container = scrollContainerRef.current;
let frame = 0;
let lastHeight = 0;
let stableCount = 0;
let rafId = 0;
const tick = () => {
if (!pendingInitialScrollRef.current || !scrollContainerRef.current) return;
container.scrollTop = container.scrollHeight;
if (container.scrollHeight === lastHeight) {
stableCount++;
} else {
stableCount = 0;
lastHeight = container.scrollHeight;
}
frame++;
if (stableCount < 3 && frame < 60) {
rafId = requestAnimationFrame(tick);
} else {
pendingInitialScrollRef.current = false;
}
};
rafId = requestAnimationFrame(tick);
return () => {
if (rafId) cancelAnimationFrame(rafId);
};
pendingInitialScrollRef.current = false;
if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
// Main session loading effect — store-based
useEffect(() => {
if (!selectedSession || !selectedProject) {
// A new provider run can be in flight before the router has a canonical
// selectedSession. Keep the draft view intact until complete/error.
if (processingSessionsRef.current?.has(PENDING_SESSION_ID)) {
// selectedSession. Keep the processing banner alive until complete/error.
if (pendingViewSessionRef.current) {
return;
}
resetStreamingState();
pendingViewSessionRef.current = null;
setClaudeStatus(null);
setCanAbortSession(false);
setIsLoading(false);
setCurrentSessionId(null);
sessionStorage.removeItem('cursorSessionId');
messagesOffsetRef.current = 0;
@@ -468,6 +426,9 @@ export function useChatSessionState({
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
if (sessionChanged) {
resetStreamingState();
pendingViewSessionRef.current = null;
setClaudeStatus(null);
setCanAbortSession(false);
}
// Reset pagination/scroll state
@@ -486,6 +447,7 @@ export function useChatSessionState({
if (sessionChanged) {
setTokenBudget(null);
setIsLoading(false);
}
setCurrentSessionId(selectedSession.id);
@@ -493,11 +455,8 @@ export function useChatSessionState({
sessionStorage.setItem('cursorSessionId', selectedSession.id);
}
// Reconcile processing state with the server. Recording the send time
// lets the reply handler discard idle replies that a newer request has
// since outdated.
// Check session status
if (ws) {
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
}
@@ -522,11 +481,11 @@ export function useChatSessionState({
setIsLoadingSessionMessages(false);
});
}, [
pendingViewSessionRef,
resetStreamingState,
selectedProject,
selectedSession?.id,
sendMessage,
statusCheckSentAtRef,
ws,
sessionStore,
]);
@@ -540,7 +499,7 @@ export function useChatSessionState({
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
// Skip store refresh during active streaming
if (!isProcessing) {
if (!isLoading) {
await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as LLMProvider,
projectId: selectedProject.projectId,
@@ -565,7 +524,7 @@ export function useChatSessionState({
selectedProject,
selectedSession,
sessionStore,
isProcessing,
isLoading,
]);
// Search navigation target
@@ -732,6 +691,16 @@ export function useChatSessionState({
return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
useEffect(() => {
const activeViewSessionId = selectedSession?.id || currentSessionId;
if (!activeViewSessionId || !processingSessions) return;
const shouldBeProcessing = processingSessions.has(activeViewSessionId);
if (shouldBeProcessing && !isLoading) {
setIsLoading(true);
setCanAbortSession(true);
}
}, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
// "Load all" overlay
const prevLoadingRef = useRef(false);
useEffect(() => {
@@ -813,15 +782,16 @@ export function useChatSessionState({
addMessage,
clearMessages,
rewindMessages,
sessionActivity,
isProcessing,
canAbortSession,
isLoading,
setIsLoading,
currentSessionId,
setCurrentSessionId,
isLoadingSessionMessages,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
canAbortSession,
setCanAbortSession,
isUserScrolledUp,
setIsUserScrolledUp,
tokenBudget,
@@ -834,6 +804,8 @@ export function useChatSessionState({
isLoadingAllMessages,
loadAllJustFinished,
showLoadAllOverlay,
claudeStatus,
setClaudeStatus,
createDiff,
scrollContainerRef,
scrollToBottom,

View File

@@ -393,8 +393,7 @@ export function useSlashCommands({
return;
}
// Match / at start of input OR after whitespace, capturing the /word up to cursor.
const slashPattern = /(?:^|\s)(\/\S*)$/;
const slashPattern = /^\/(\S*)$/;
const match = textBeforeCursor.match(slashPattern);
if (!match) {
@@ -402,9 +401,8 @@ export function useSlashCommands({
return;
}
// Compute actual position of / in the full input string.
const slashPos = match.index! + (match[0].length - match[1].length);
const query = match[1].slice(1); // strip leading /
const slashPos = 0;
const query = match[1];
setSlashPosition(slashPos);
setShowCommandMenu(true);

View File

@@ -564,15 +564,11 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
if (!config.result) return false;
// Hidden/success-only configs suppress noisy successful output, but errors
// still need to be visible so failed tool calls are diagnosable.
if (toolResult?.isError) return false;
// Always hidden
if (config.result.hidden) return true;
// Hide on success only
if (config.result.hideOnSuccess && toolResult) {
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
return true;
}

View File

@@ -1,9 +1,4 @@
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type {
MarkSessionIdle,
MarkSessionProcessing,
SessionActivityMap,
} from '../../../hooks/useSessionProtection';
export type Provider = LLMProvider;
@@ -115,9 +110,11 @@ export interface ChatInterfaceProps {
latestMessage: any;
onFileOpen?: (filePath: string, diffInfo?: any) => void;
onInputFocusChange?: (focused: boolean) => void;
onSessionProcessing?: MarkSessionProcessing;
onSessionIdle?: MarkSessionIdle;
processingSessions?: SessionActivityMap;
onSessionActive?: (sessionId?: string | null) => void;
onSessionInactive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onSessionNotProcessing?: (sessionId?: string | null) => void;
processingSessions?: Set<string>;
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onShowSettings?: () => void;
autoExpandTools?: boolean;

View File

@@ -17,6 +17,10 @@ import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal';
type PendingViewSession = {
startedAt: number;
};
function ChatInterface({
selectedProject,
selectedSession,
@@ -25,8 +29,10 @@ function ChatInterface({
latestMessage,
onFileOpen,
onInputFocusChange,
onSessionActive,
onSessionInactive,
onSessionProcessing,
onSessionIdle,
onSessionNotProcessing,
processingSessions,
onNavigateToSession,
onShowSettings,
@@ -45,9 +51,7 @@ function ChatInterface({
const sessionStore = useSessionStore();
const streamTimerRef = useRef<number | null>(null);
const accumulatedStreamRef = useRef('');
// When each session's `check-session-status` was last sent; idle replies
// older than a later local request are discarded as stale.
const statusCheckSentAtRef = useRef(new Map<string, number>());
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
const resetStreamingState = useCallback(() => {
if (streamTimerRef.current) {
@@ -88,15 +92,16 @@ function ChatInterface({
const {
chatMessages,
addMessage,
sessionActivity,
isProcessing,
canAbortSession,
isLoading,
setIsLoading,
currentSessionId,
setCurrentSessionId,
isLoadingSessionMessages,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
canAbortSession,
setCanAbortSession,
isUserScrolledUp,
setIsUserScrolledUp,
tokenBudget,
@@ -109,6 +114,8 @@ function ChatInterface({
isLoadingAllMessages,
loadAllJustFinished,
showLoadAllOverlay,
claudeStatus,
setClaudeStatus,
createDiff,
scrollContainerRef,
scrollToBottom,
@@ -123,9 +130,8 @@ function ChatInterface({
externalMessageUpdate,
newSessionTrigger,
processingSessions,
onSessionIdle,
resetStreamingState,
statusCheckSentAtRef,
pendingViewSessionRef,
sessionStore,
});
@@ -135,6 +141,8 @@ function ChatInterface({
textareaRef,
inputHighlightRef,
isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount,
filteredCommands,
frequentCommands,
@@ -172,7 +180,6 @@ function ChatInterface({
isInputFocused: _isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
} = useChatComposerState({
selectedProject,
selectedSession,
@@ -185,40 +192,40 @@ function ChatInterface({
codexModel,
geminiModel,
opencodeModel,
isLoading: isProcessing,
isLoading,
canAbortSession,
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionActive,
onSessionProcessing,
onInputFocusChange,
onFileOpen,
onShowSettings,
pendingViewSessionRef,
scrollToBottom,
addMessage,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setIsUserScrolledUp,
setPendingPermissionRequests,
});
// On WebSocket reconnect, re-fetch the current session's messages from the
// server so missed streaming events are shown, then re-check the session's
// processing status — the authoritative reply restores or clears the
// activity indicator depending on whether the run is still active.
// On WebSocket reconnect, re-fetch the current session's messages from the server
// so missed streaming events are shown. Also reset isLoading.
const handleWebSocketReconnect = useCallback(async () => {
if (!selectedProject || !selectedSession) return;
const providerVal =
selectedSession.__provider
|| (localStorage.getItem('selected-provider') as LLMProvider)
|| 'claude';
const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
await sessionStore.refreshFromServer(selectedSession.id, {
provider: providerVal as LLMProvider,
provider: (selectedSession.__provider || providerVal) as LLMProvider,
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
});
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider: providerVal });
}, [selectedProject, selectedSession, sendMessage, sessionStore]);
setIsLoading(false);
setCanAbortSession(false);
}, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);
useChatRealtimeHandlers({
latestMessage,
@@ -226,20 +233,25 @@ function ChatInterface({
selectedSession,
currentSessionId,
setCurrentSessionId,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests,
pendingViewSessionRef,
streamTimerRef,
accumulatedStreamRef,
statusCheckSentAtRef,
onSessionInactive,
onSessionActive,
onSessionProcessing,
onSessionIdle,
onSessionNotProcessing,
onNavigateToSession,
onWebSocketReconnect: handleWebSocketReconnect,
sessionStore,
});
useEffect(() => {
if (!canAbortSession) {
if (!isLoading || !canAbortSession) {
return;
}
@@ -256,7 +268,7 @@ function ChatInterface({
return () => {
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
};
}, [canAbortSession, handleAbortSession]);
}, [canAbortSession, handleAbortSession, isLoading]);
useEffect(() => {
return () => {
@@ -351,13 +363,15 @@ function ChatInterface({
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
activity={sessionActivity}
isLoading={isProcessing}
claudeStatus={claudeStatus}
isLoading={isLoading}
onAbortSession={handleAbortSession}
provider={provider}
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())}

View File

@@ -1,80 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Shimmer } from '../../../../shared/view/ui';
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
type ActivityIndicatorProps = {
activity: SessionActivity | null;
onAbort?: () => void;
};
const ACTION_KEYS = [
'claudeStatus.actions.thinking',
'claudeStatus.actions.processing',
'claudeStatus.actions.analyzing',
'claudeStatus.actions.working',
'claudeStatus.actions.computing',
'claudeStatus.actions.reasoning',
];
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
/**
* Minimal response-in-progress indicator, in the spirit of the inline status
* lines in Claude Code / Codex / OpenCode: a shimmering activity label, the
* elapsed time, and an interrupt affordance. Rendered only while the viewed
* session has an entry in the processing map; it disappears the instant that
* entry is removed.
*/
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
const { t } = useTranslation('chat');
const startedAt = activity?.startedAt ?? null;
const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {
if (startedAt === null) return;
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [startedAt]);
if (!activity) return null;
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
.replace(/\.+$/, '');
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = elapsedSeconds % 60;
const elapsedLabel = minutes < 1
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
return (
<div className="animate-in fade-in mb-2 w-full duration-300">
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
<Shimmer className="text-xs font-medium">{`${label}`}</Shimmer>
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
{activity.canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
>
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
<rect x="5" y="5" width="14" height="14" rx="2" />
</svg>
<span>{t('claudeStatus.stop', { defaultValue: 'Stop' })}</span>
<kbd className="hidden rounded border border-border/60 px-1 text-[10px] text-muted-foreground/70 sm:inline-block">
esc
</kbd>
</button>
)}
</div>
</div>
);
}

View File

@@ -2,17 +2,23 @@ import { useTranslation } from 'react-i18next';
import type {
ChangeEvent,
ClipboardEvent,
Dispatch,
FormEvent,
KeyboardEvent,
MouseEvent,
ReactNode,
RefObject,
SetStateAction,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsageSummary from './TokenUsageSummary';
import {
PromptInput,
PromptInputHeader,
@@ -24,12 +30,6 @@ import {
PromptInputSubmit,
} from '../../../../shared/view/ui';
import CommandMenu from './CommandMenu';
import ActivityIndicator from './ActivityIndicator';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import TokenUsageSummary from './TokenUsageSummary';
interface MentionableFile {
name: string;
path: string;
@@ -52,13 +52,15 @@ interface ChatComposerProps {
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void;
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
activity: SessionActivity | null;
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
isLoading: boolean;
onAbortSession: () => void;
provider: Provider | string;
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
thinkingMode: string;
setThinkingMode: Dispatch<SetStateAction<string>>;
tokenBudget: Record<string, unknown> | null;
onShowTokenUsage: () => void;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
@@ -105,13 +107,15 @@ export default function ChatComposer({
pendingPermissionRequests,
handlePermissionDecision,
handleGrantToolPermission,
activity,
claudeStatus,
isLoading,
onAbortSession,
provider,
permissionMode,
onModeSwitch,
thinkingMode,
setThinkingMode,
tokenBudget,
onShowTokenUsage,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
@@ -172,7 +176,12 @@ export default function ChatComposer({
return (
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
{!hasPendingPermissions && (
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
/>
)}
{pendingPermissionRequests.length > 0 && (
@@ -286,7 +295,6 @@ export default function ChatComposer({
<PromptInputTextarea
ref={textareaRef}
dir="auto"
value={input}
onChange={onInputChange}
onClick={onTextareaClick}
@@ -349,7 +357,11 @@ export default function ChatComposer({
</div>
</button>
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
{provider === 'claude' && (
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsageSummary usage={tokenBudget} />
<PromptInputButton
tooltip={{ content: t('input.showAllCommands') }}
@@ -370,7 +382,7 @@ export default function ChatComposer({
<PromptInputButton
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
onClick={onClearInput}
className="hidden sm:flex"
className="hidden sm:No-flex"
>
<XIcon />
</PromptInputButton>
@@ -387,8 +399,7 @@ export default function ChatComposer({
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
</div>
<PromptInputSubmit
onClick={isLoading ? onAbortSession : undefined}
disabled={!isLoading && !input.trim()}
disabled={!input.trim() || isLoading}
className="h-10 w-10 sm:h-10 sm:w-10"
/>
</div>

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../../lib/utils';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
type ClaudeStatusProps = {
status: {
text?: string;
tokens?: number;
can_interrupt?: boolean;
} | null;
onAbort?: () => void;
isLoading: boolean;
provider?: string;
};
const ACTION_KEYS = [
'claudeStatus.actions.thinking',
'claudeStatus.actions.processing',
'claudeStatus.actions.analyzing',
'claudeStatus.actions.working',
'claudeStatus.actions.computing',
'claudeStatus.actions.reasoning',
];
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const PROVIDER_LABEL_KEYS: Record<string, string> = {
claude: 'messageTypes.claude',
codex: 'messageTypes.codex',
cursor: 'messageTypes.cursor',
gemini: 'messageTypes.gemini',
opencode: 'messageTypes.opencode',
};
function formatElapsedTime(totalSeconds: number) {
const mins = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`;
}
export default function ClaudeStatus({
status,
onAbort,
isLoading,
provider = 'claude',
}: ClaudeStatusProps) {
const { t } = useTranslation('chat');
const [elapsedTime, setElapsedTime] = useState(0);
const [dots, setDots] = useState('');
useEffect(() => {
if (!isLoading) {
setElapsedTime(0);
return;
}
const startTime = Date.now();
const timer = setInterval(() => {
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
const dotTimer = setInterval(() => {
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
}, 500);
return () => {
clearInterval(timer);
clearInterval(dotTimer);
};
}, [isLoading]);
if (!isLoading && !status) return null;
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
const statusText = (status?.text || actionWords[Math.floor(elapsedTime / 3) % actionWords.length]).replace(/[.]+$/, '');
const providerLabel = t(PROVIDER_LABEL_KEYS[provider] || 'claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
return (
<div className="animate-in fade-in slide-in-from-bottom-2 mb-3 w-full duration-500">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-3 overflow-hidden rounded-full border border-border/50 bg-slate-100 px-3 py-1.5 shadow-sm backdrop-blur-md dark:bg-slate-900">
{/* Left Side: Identity & Status */}
<div className="flex min-w-0 items-center gap-2.5">
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/20 ring-1 ring-primary/10">
<SessionProviderLogo provider={provider} className="h-3.5 w-3.5" />
{isLoading && (
<span className="absolute inset-0 animate-pulse rounded-full ring-2 ring-emerald-500/20" />
)}
</div>
<div className="flex min-w-0 flex-col sm:flex-row sm:items-center sm:gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70">
{providerLabel}
</span>
<div className="flex items-center gap-1.5">
<span className={cn("h-1.5 w-1.5 rounded-full", isLoading ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<p className="truncate text-xs font-medium text-foreground">
{statusText}<span className="inline-block w-4 text-primary">{isLoading ? dots : ''}</span>
</p>
</div>
</div>
</div>
{/* Right Side: Metrics & Actions */}
<div className="flex items-center gap-2">
{isLoading && status?.can_interrupt !== false && onAbort && (
<>
<div className="hidden items-center rounded-md bg-muted/50 px-2 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground sm:flex">
{formatElapsedTime(elapsedTime)}
</div>
<button
type="button"
onClick={onAbort}
className="group flex items-center gap-1.5 rounded-full bg-destructive/10 px-2.5 py-1 text-[10px] font-bold text-destructive transition-all hover:bg-destructive hover:text-destructive-foreground"
>
<svg className="h-3 w-3 fill-current" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z" />
</svg>
<span className="hidden sm:inline">STOP</span>
<kbd className="hidden rounded bg-black/10 px-1 text-[9px] group-hover:bg-white/20 sm:block">
ESC
</kbd>
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type {
ChatMessage,
@@ -9,10 +8,10 @@ import type {
Provider,
} from '../../types/types';
import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl';
@@ -42,9 +41,10 @@ type InteractiveOption = {
isSelected: boolean;
};
type PermissionGrantState = 'idle' | 'granted' | 'error';
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
@@ -53,6 +53,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
(prevMessage.type === 'error'));
const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')),
@@ -71,6 +73,10 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
!message.isThinking;
useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
useEffect(() => {
const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return;
@@ -114,7 +120,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
/* User message bubble on the right */
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
<div dir="auto" className="whitespace-pre-wrap break-words text-sm">
<div className="whitespace-pre-wrap break-words text-sm">
{message.content}
</div>
{message.images && message.images.length > 0 && (
@@ -235,6 +241,55 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
{String(message.toolResult.content || '')}
</Markdown>
{permissionSuggestion && (
<div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
if (!onGrantToolPermission) return;
const result = onGrantToolPermission(permissionSuggestion);
if (result?.success) {
setPermissionGrantState('granted');
} else {
setPermissionGrantState('error');
}
}}
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
: 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
}`}
>
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? t('permissions.added')
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
</button>
{onShowSettings && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
>
{t('permissions.openSettings')}
</button>
)}
</div>
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
</div>
{permissionGrantState === 'error' && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
{t('permissions.error')}
</div>
)}
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
{t('permissions.retry')}
</div>
)}
</div>
)}
</div>
</div>
) : (
@@ -350,7 +405,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
</ReasoningContent>
</Reasoning>
) : (
<div dir="auto" className="text-sm text-gray-700 dark:text-gray-300">
<div className="text-sm text-gray-700 dark:text-gray-300">
{/* Reasoning accordion */}
{showThinking && message.reasoning && (
<Reasoning className="mb-3" defaultOpen={false}>

View File

@@ -277,15 +277,11 @@ export default function ProviderSelectionEmptyState({
>
<div className="min-w-0 flex-1">
<div className="truncate">{model.label}</div>
{/*
// * 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 && (
{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" />
@@ -325,7 +321,6 @@ export default function ProviderSelectionEmptyState({
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
<Trans
ns="chat"
i18nKey="providerSelection.pressToSearch"
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
components={{

View File

@@ -0,0 +1,244 @@
import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { Brain, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { thinkingModes } from '../../constants/thinkingModes';
type ThinkingModeSelectorProps = {
selectedMode: string;
onModeChange: (modeId: string) => void;
onClose?: () => void;
className?: string;
};
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
const { t } = useTranslation('chat');
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
// Mapping from mode ID to translation key
const modeKeyMap: Record<string, string> = {
'think-hard': 'thinkHard',
'think-harder': 'thinkHarder'
};
// Create translated modes for display
const translatedModes = thinkingModes.map(mode => {
const modeKey = modeKeyMap[mode.id] || mode.id;
return {
...mode,
name: t(`thinkingMode.modes.${modeKey}.name`),
description: t(`thinkingMode.modes.${modeKey}.description`),
prefix: t(`thinkingMode.modes.${modeKey}.prefix`)
};
});
const closeDropdown = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
const updateDropdownPosition = useCallback(() => {
const trigger = triggerRef.current;
const dropdown = dropdownRef.current;
if (!trigger || !dropdown || typeof window === 'undefined') {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const viewportPadding = window.innerWidth < 640 ? 12 : 16;
const spacing = 8;
const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256);
let left = triggerRect.left + triggerRect.width / 2 - width / 2;
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding));
const measuredHeight = dropdown.offsetHeight || 0;
const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding;
const spaceAbove = triggerRect.top - spacing - viewportPadding;
const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove;
const availableHeight = Math.min(
window.innerHeight - viewportPadding * 2,
Math.max(180, openBelow ? spaceBelow : spaceAbove),
);
const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight);
const top = openBelow
? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight)
: Math.max(viewportPadding, triggerRect.top - spacing - panelHeight);
setDropdownStyle({
position: 'fixed',
top,
left,
width,
maxHeight: availableHeight,
zIndex: 80,
});
}, []);
useEffect(() => {
if (!isOpen) {
setDropdownStyle(null);
return;
}
const rafId = window.requestAnimationFrame(updateDropdownPosition);
const handleViewportChange = () => updateDropdownPosition();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [isOpen, updateDropdownPosition]);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) {
return;
}
closeDropdown();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeDropdown();
}
};
document.addEventListener('pointerdown', handlePointerDown, true);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, closeDropdown]);
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
const IconComponent = currentMode.icon || Brain;
return (
<div className={`relative ${className}`} ref={containerRef}>
<button
ref={triggerRef}
type="button"
onClick={() => {
if (isOpen) {
closeDropdown();
return;
}
setIsOpen(true);
}}
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
}`}
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
aria-haspopup="dialog"
aria-expanded={isOpen}
>
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
</button>
{isOpen && typeof document !== 'undefined' && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' }}
className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="dialog"
aria-modal="false"
>
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('thinkingMode.selector.title')}
</h3>
<button
type="button"
onClick={closeDropdown}
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="h-4 w-4 text-gray-500" />
</button>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('thinkingMode.selector.description')}
</p>
</div>
<div className="min-h-0 overflow-y-auto py-1">
{translatedModes.map((mode) => {
const ModeIcon = mode.icon;
const isSelected = mode.id === selectedMode;
return (
<button
key={mode.id}
type="button"
onClick={() => {
onModeChange(mode.id);
closeDropdown();
}}
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}>
{ModeIcon ? <ModeIcon className="h-5 w-5" /> : <div className="h-5 w-5" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'
}`}>
{mode.name}
</span>
{isSelected && (
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{t('thinkingMode.selector.active')}
</span>
)}
</div>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{mode.description}
</p>
{mode.prefix && (
<code className="mt-1 inline-block rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-700">
{mode.prefix}
</code>
)}
</div>
</div>
</button>
);
})}
</div>
<div className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900">
<p className="text-xs text-gray-600 dark:text-gray-400">
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
</p>
</div>
</div>,
document.body
)}
</div>
);
}
export default ThinkingModeSelector;

View File

@@ -2,7 +2,6 @@ import { ActivityIcon } from 'lucide-react';
type TokenUsageSummaryProps = {
usage: Record<string, unknown> | null;
onClick?: () => void;
};
const formatTokenCount = (value: number) => {
@@ -30,7 +29,7 @@ const readUsageNumber = (value: unknown) => {
return Number.isFinite(parsed) ? parsed : 0;
};
export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryProps) {
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
const breakdown =
usage?.breakdown && typeof usage.breakdown === 'object'
? usage.breakdown as Record<string, unknown>
@@ -40,18 +39,15 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
return (
<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"
<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"
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>
</button>
</div>
);
}

View File

@@ -102,7 +102,7 @@ export default function EditorSidebar({
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
return (
<div ref={containerRef} className={`flex h-full min-w-0 ${editorExpanded ? 'flex-1' : ''}`}>
<div ref={containerRef} className={`flex h-full min-w-0 flex-shrink-0 ${editorExpanded ? 'flex-1' : ''}`}>
{!editorExpanded && (
<div
ref={resizeHandleRef}

View File

@@ -6,14 +6,6 @@ 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,13 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { IS_PLATFORM } from '../../../constants/config';
import { useCallback, useState, useRef } from 'react';
import type { Project } from '../../../types/app';
import {
MAX_FILE_UPLOAD_COUNT,
MAX_FILE_UPLOAD_SIZE_BYTES,
MAX_FILE_UPLOAD_SIZE_LABEL,
} from '../constants/constants';
import { api } from '../../../utils/api';
type UseFileTreeUploadOptions = {
selectedProject: Project | null;
@@ -15,141 +8,6 @@ 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[] = [];
@@ -199,48 +57,6 @@ 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,
@@ -249,150 +65,20 @@ 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 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) => {
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragOver = useCallback((e: DragEvent) => {
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragLeave = useCallback((e: DragEvent) => {
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set isDragOver to false if we're leaving the entire tree
@@ -402,35 +88,103 @@ export const useFileTreeUpload = ({
}
}, []);
const handleDrop = useCallback(
async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const targetPath = dropTarget || '';
const targetPath = dropTarget || '';
setOperationLoading(true);
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);
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);
}
}
},
[dropTarget, setUploadError, showToast, uploadFiles],
);
const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => {
if (files.length === 0) {
setOperationLoading(false);
setDropTarget(null);
return;
}
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) => {
e.preventDefault();
e.stopPropagation();
setDropTarget(itemPath);
}, []);
const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => {
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
e.preventDefault();
e.stopPropagation();
setDropTarget(itemPath);
@@ -440,9 +194,7 @@ export const useFileTreeUpload = ({
isDragOver,
dropTarget,
operationLoading,
uploadProgress,
treeRef,
handleFileSelect,
handleDragEnter,
handleDragOver,
handleDragLeave,

View File

@@ -1,7 +1,6 @@
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';
@@ -14,12 +13,10 @@ 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';
@@ -69,7 +66,6 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
onRefresh: refreshFiles,
showToast,
});
const operationLoading = operations.operationLoading || upload.operationLoading;
// Focus input when creating new item
useEffect(() => {
@@ -150,19 +146,14 @@ 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={operationLoading}
isUploading={upload.uploadProgress?.status === 'uploading'}
uploadProgress={upload.uploadProgress?.progress ?? null}
operationLoading={operations.operationLoading}
/>
<FileTreeUploadProgress upload={upload.uploadProgress} />
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
<ScrollArea className="flex-1 px-2 py-1">
@@ -193,7 +184,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
}, 100);
}}
className="h-6 flex-1 text-sm"
disabled={operationLoading}
disabled={operations.operationLoading}
/>
</div>
)}
@@ -222,7 +213,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
handleConfirmRename={operations.handleConfirmRename}
handleCancelRename={operations.handleCancelRename}
renameInputRef={renameInputRef}
operationLoading={operationLoading}
operationLoading={operations.operationLoading}
/>
</ScrollArea>
@@ -260,17 +251,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
<div className="flex justify-end gap-2">
<button
onClick={operations.handleCancelDelete}
disabled={operationLoading}
disabled={operations.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={operationLoading}
disabled={operations.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"
>
{operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{operations.operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{t('fileTree.delete.confirm', 'Delete')}
</button>
</div>

View File

@@ -1,11 +1,7 @@
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 { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, 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 = {
@@ -16,14 +12,11 @@ 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({
@@ -33,24 +26,12 @@ 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">
@@ -59,50 +40,6 @@ 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

@@ -1,90 +0,0 @@
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

@@ -1,13 +1,10 @@
import type { Dispatch, SetStateAction } from 'react';
import type { AppTab, Project, ProjectSession } from '../../../types/app';
import type {
MarkSessionIdle,
MarkSessionProcessing,
SessionActivityMap,
} from '../../../hooks/useSessionProtection';
import type { SessionNavigationOptions } from '../../chat/types/types';
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
export type TaskMasterTask = {
id: string | number;
title?: string;
@@ -49,9 +46,11 @@ export type MainContentProps = {
onMenuClick: () => void;
isLoading: boolean;
onInputFocusChange: (focused: boolean) => void;
onSessionProcessing: MarkSessionProcessing;
onSessionIdle: MarkSessionIdle;
processingSessions: SessionActivityMap;
onSessionActive: SessionLifecycleHandler;
onSessionInactive: SessionLifecycleHandler;
onSessionProcessing: SessionLifecycleHandler;
onSessionNotProcessing: SessionLifecycleHandler;
processingSessions: Set<string>;
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onShowSettings: () => void;
externalMessageUpdate: number;

View File

@@ -42,8 +42,10 @@ function MainContent({
onMenuClick,
isLoading,
onInputFocusChange,
onSessionActive,
onSessionInactive,
onSessionProcessing,
onSessionIdle,
onSessionNotProcessing,
processingSessions,
onNavigateToSession,
onShowSettings,
@@ -129,8 +131,10 @@ function MainContent({
latestMessage={latestMessage}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onSessionProcessing={onSessionProcessing}
onSessionIdle={onSessionIdle}
onSessionNotProcessing={onSessionNotProcessing}
processingSessions={processingSessions}
onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings}

View File

@@ -26,7 +26,6 @@ const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-start
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism';
type PluginRecommendation = {
id: string;
@@ -73,14 +72,6 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
icon: Clock,
source: 'unofficial',
},
{
id: 'prism',
translationKey: 'prismCloudCLI',
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
installedNames: ['prism'],
icon: Activity,
source: 'unofficial'
}
];
function repoSlug(repoUrl: string) {

View File

@@ -1,8 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTheme } from '../../../contexts/ThemeContext';
import { authenticatedFetch } from '../../../utils/api';
import { setNotificationSoundEnabled } from '../../../utils/notificationSound';
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
import {
DEFAULT_CODE_EDITOR_SETTINGS,
@@ -109,7 +107,6 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
channels: {
inApp: true,
webPush: false,
sound: true,
},
events: {
actionRequired: true,
@@ -118,25 +115,6 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
},
});
const normalizeNotificationPreferences = (
preferences?: Partial<NotificationPreferencesState> | null,
): NotificationPreferencesState => {
const defaults = createDefaultNotificationPreferences();
return {
channels: {
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
sound: preferences?.channels?.sound ?? defaults.channels.sound,
},
events: {
actionRequired: preferences?.events?.actionRequired ?? defaults.events.actionRequired,
stop: preferences?.events?.stop ?? defaults.events.stop,
error: preferences?.events?.error ?? defaults.events.error,
},
};
};
export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
const closeTimerRef = useRef<number | null>(null);
@@ -208,7 +186,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
if (notificationResponse.ok) {
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
if (notificationData.success && notificationData.preferences) {
setNotificationPreferences(normalizeNotificationPreferences(notificationData.preferences));
setNotificationPreferences(notificationData.preferences);
} else {
setNotificationPreferences(createDefaultNotificationPreferences());
}
@@ -323,10 +301,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
void refreshProviderAuthStatuses();
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
useEffect(() => {
setNotificationSoundEnabled(notificationPreferences.channels.sound);
}, [notificationPreferences.channels.sound]);
useEffect(() => {
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));

View File

@@ -1,5 +1,4 @@
import type { Dispatch, SetStateAction } from 'react';
import type { LLMProvider } from '../../../types/app';
import type { ProviderAuthStatus } from '../../provider-auth/types';
@@ -30,7 +29,6 @@ export type NotificationPreferencesState = {
channels: {
inApp: boolean;
webPush: boolean;
sound: boolean;
};
events: {
actionRequired: boolean;

View File

@@ -1,8 +1,5 @@
import { Bell, BellOff, BellRing, Loader2, Play, Volume2 } from 'lucide-react';
import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../../shared/view/ui';
import { playChatCompletionSound } from '../../../../utils/notificationSound';
import type { NotificationPreferencesState } from '../../types/types';
type NotificationsSettingsTabProps = {
@@ -85,54 +82,6 @@ export default function NotificationsSettingsTab({
)}
</div>
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Volume2 className="h-4 w-4 text-blue-600" />
<h4 className="font-medium text-foreground">
{t('notifications.sound.title', { defaultValue: 'Sound' })}
</h4>
</div>
<p className="text-sm text-muted-foreground">
{t('notifications.sound.description', {
defaultValue: 'Play a short tone when a chat run finishes.',
})}
</p>
</div>
<label className="flex shrink-0 items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={notificationPreferences.channels.sound}
onChange={(event) =>
onNotificationPreferencesChange({
...notificationPreferences,
channels: {
...notificationPreferences.channels,
sound: event.target.checked,
},
})
}
className="h-4 w-4"
/>
{t('notifications.sound.enabled', { defaultValue: 'Enabled' })}
</label>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
void playChatCompletionSound({ force: true });
}}
>
<Play className="h-4 w-4" />
{t('notifications.sound.test', { defaultValue: 'Test sound' })}
</Button>
</div>
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
<div className="space-y-3">

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { MutableRefObject } from 'react';
import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app';
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
@@ -32,8 +31,8 @@ type UseShellConnectionResult = {
isConnected: boolean;
isConnecting: boolean;
closeSocket: () => void;
connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
connectToShell: () => void;
disconnectFromShell: () => void;
};
export function useShellConnection({
@@ -55,8 +54,6 @@ export function useShellConnection({
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const connectingRef = useRef(false);
const forceRestartOnInitRef = useRef(false);
const suppressAutoConnectRef = useRef(false);
const handleProcessCompletion = useCallback(
(output: string) => {
@@ -144,8 +141,6 @@ export function useShellConnection({
}
currentFitAddon.fit();
const forceRestart = forceRestartOnInitRef.current;
forceRestartOnInitRef.current = false;
sendSocketMessage(socket, {
type: 'init',
@@ -157,7 +152,6 @@ export function useShellConnection({
rows: currentTerminal.rows,
initialCommand: initialCommandRef.current,
isPlainShell: isPlainShellRef.current,
forceRestart,
});
}, TERMINAL_INIT_DELAY_MS);
};
@@ -183,7 +177,6 @@ export function useShellConnection({
setIsConnected(false);
setIsConnecting(false);
connectingRef.current = false;
forceRestartOnInitRef.current = false;
}
},
[
@@ -202,40 +195,27 @@ export function useShellConnection({
],
);
const connectToShell = useCallback((options?: { forceRestart?: boolean }) => {
const connectToShell = useCallback(() => {
if (!isInitialized || isConnected || isConnecting || connectingRef.current) {
return;
}
forceRestartOnInitRef.current = Boolean(options?.forceRestart);
suppressAutoConnectRef.current = false;
connectingRef.current = true;
setIsConnecting(true);
connectWebSocket(true);
}, [connectWebSocket, isConnected, isConnecting, isInitialized]);
const disconnectFromShell = useCallback((options?: { suppressAutoConnect?: boolean }) => {
if (options?.suppressAutoConnect) {
suppressAutoConnectRef.current = true;
}
const disconnectFromShell = useCallback(() => {
closeSocket();
clearTerminalScreen();
setIsConnected(false);
setIsConnecting(false);
connectingRef.current = false;
forceRestartOnInitRef.current = false;
setAuthUrl('');
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
useEffect(() => {
if (
!autoConnect ||
suppressAutoConnectRef.current ||
!isInitialized ||
isConnecting ||
isConnected
) {
if (!autoConnect || !isInitialized || isConnecting || isConnected) {
return;
}

View File

@@ -1,7 +1,6 @@
import type { MutableRefObject, RefObject } from 'react';
import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app';
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
@@ -16,7 +15,6 @@ export type ShellInitMessage = {
rows: number;
initialCommand: string | null | undefined;
isPlainShell: boolean;
forceRestart?: boolean;
};
export type ShellResizeMessage = {
@@ -71,8 +69,8 @@ export type UseShellRuntimeResult = {
isConnecting: boolean;
authUrl: string;
authUrlVersion: number;
connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
connectToShell: () => void;
disconnectFromShell: () => void;
openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
};

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import '@xterm/xterm/css/xterm.css';
import type { Project, ProjectSession } from '../../../types/app';
import {
@@ -14,7 +13,6 @@ import {
import { useShellRuntime } from '../hooks/useShellRuntime';
import { sendSocketMessage } from '../utils/socket';
import { getSessionDisplayName } from '../utils/auth';
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
import ShellEmptyState from './subcomponents/ShellEmptyState';
import ShellHeader from './subcomponents/ShellHeader';
@@ -48,8 +46,6 @@ export default function Shell({
const [isRestarting, setIsRestarting] = useState(false);
const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null);
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const restartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const restartAfterInitRef = useRef(false);
const onOutputRef = useRef<(() => void) | null>(null);
const {
@@ -144,7 +140,6 @@ export default function Shell({
useEffect(() => {
return () => {
if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);
if (restartTimerRef.current) clearTimeout(restartTimerRef.current);
};
}, []);
@@ -195,42 +190,12 @@ export default function Shell({
);
const handleRestartShell = useCallback(() => {
restartAfterInitRef.current = true;
setIsRestarting(true);
if (restartTimerRef.current) {
clearTimeout(restartTimerRef.current);
}
restartTimerRef.current = setTimeout(() => {
window.setTimeout(() => {
setIsRestarting(false);
restartTimerRef.current = null;
}, SHELL_RESTART_DELAY_MS);
}, []);
const handleDisconnectShell = useCallback(() => {
restartAfterInitRef.current = false;
if (restartTimerRef.current) {
clearTimeout(restartTimerRef.current);
restartTimerRef.current = null;
}
setIsRestarting(false);
disconnectFromShell({ suppressAutoConnect: true });
}, [disconnectFromShell]);
useEffect(() => {
if (
!restartAfterInitRef.current ||
isRestarting ||
!isInitialized ||
isConnected ||
isConnecting
) {
return;
}
restartAfterInitRef.current = false;
connectToShell({ forceRestart: true });
}, [connectToShell, isConnected, isConnecting, isInitialized, isRestarting]);
if (!selectedProject) {
return (
<ShellEmptyState
@@ -289,7 +254,7 @@ export default function Shell({
isRestarting={isRestarting}
hasSession={Boolean(selectedSession)}
sessionDisplayNameShort={sessionDisplayNameShort}
onDisconnect={handleDisconnectShell}
onDisconnect={disconnectFromShell}
onRestart={handleRestartShell}
statusNewSessionText={t('shell.status.newSession')}
statusInitializingText={t('shell.status.initializing')}
@@ -298,7 +263,7 @@ export default function Shell({
disconnectTitle={t('shell.actions.disconnectTitle')}
restartLabel={t('shell.actions.restart')}
restartTitle={t('shell.actions.restartTitle')}
disableRestart={isRestarting || !isInitialized}
disableRestart={isRestarting || isConnected}
/>
<div className="relative flex-1 overflow-hidden p-2">
@@ -316,7 +281,7 @@ export default function Shell({
connectLabel={t('shell.actions.connect')}
connectTitle={t('shell.actions.connectTitle')}
connectingLabel={t('shell.connecting')}
onConnect={handleRestartShell}
onConnect={connectToShell}
/>
)}

View File

@@ -1,5 +1,3 @@
import { Loader2, RotateCcw } from 'lucide-react';
type ShellConnectionOverlayProps = {
mode: 'loading' | 'connect' | 'connecting';
description: string;
@@ -21,42 +19,40 @@ export default function ShellConnectionOverlay({
}: ShellConnectionOverlayProps) {
if (mode === 'loading') {
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90">
<div className="inline-flex items-center gap-2 text-sm font-medium text-gray-100">
<Loader2 className="h-4 w-4 animate-spin text-blue-300" aria-hidden="true" />
<span>{loadingLabel}</span>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">{loadingLabel}</div>
</div>
);
}
if (mode === 'connect') {
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="w-full max-w-sm text-center">
<button
type="button"
onClick={onConnect}
className="pointer-events-auto inline-flex min-h-12 w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-md bg-emerald-600 px-5 py-3 text-base font-semibold text-white shadow-lg shadow-emerald-950/30 transition-colors hover:bg-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-300 focus:ring-offset-2 focus:ring-offset-gray-950 active:bg-emerald-700"
className="flex w-full items-center justify-center space-x-2 rounded-lg bg-green-600 px-6 py-3 text-base font-medium text-white transition-colors hover:bg-green-700 sm:w-auto"
title={connectTitle}
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
<span className="min-w-0 truncate">{connectLabel}</span>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{connectLabel}</span>
</button>
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p>
<p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
</div>
</div>
);
}
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
<div className="flex items-center justify-center gap-3 text-yellow-300">
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="w-full max-w-sm text-center">
<div className="flex items-center justify-center space-x-3 text-yellow-400">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">{connectingLabel}</span>
</div>
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p>
<p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
</div>
</div>
);

View File

@@ -1,5 +1,3 @@
import { RotateCcw, X } from 'lucide-react';
type ShellHeaderProps = {
isConnected: boolean;
isInitialized: boolean;
@@ -52,27 +50,34 @@ export default function ShellHeader({
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center space-x-3">
{isConnected && (
<button
type="button"
onClick={onDisconnect}
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-red-600 px-3 text-xs font-medium text-white transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400/70 focus:ring-offset-2 focus:ring-offset-gray-800"
className="flex items-center space-x-1 rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700"
title={disconnectTitle}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>{disconnectLabel}</span>
</button>
)}
<button
type="button"
onClick={onRestart}
disabled={disableRestart}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-gray-600/80 bg-gray-700/70 px-3 text-xs font-medium text-gray-100 transition-colors hover:border-blue-400/70 hover:bg-blue-600/80 hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-400/70 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:cursor-not-allowed disabled:border-transparent disabled:bg-transparent disabled:text-gray-500 disabled:opacity-60"
className="flex items-center space-x-1 text-xs text-gray-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
title={restartTitle}
>
<RotateCcw className={`h-3.5 w-3.5 ${isRestarting ? 'animate-spin' : ''}`} aria-hidden="true" />
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>{restartLabel}</span>
</button>
</div>

View File

@@ -1,6 +1,4 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { authenticatedFetch } from "../../../utils/api";
import { ReleaseInfo } from "../../../types/sharedTypes";
@@ -156,10 +154,8 @@ export function VersionUpgradeModal({
)}
</div>
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-700/50">
<div className="prose prose-sm max-w-none text-sm text-gray-700 dark:prose-invert dark:text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={changelogComponents}>
{cleanChangelog(releaseInfo.body)}
</ReactMarkdown>
<div className="prose prose-sm max-w-none whitespace-pre-wrap text-sm text-gray-700 dark:prose-invert dark:text-gray-300">
{cleanChangelog(releaseInfo.body)}
</div>
</div>
</div>
@@ -240,14 +236,6 @@ export function VersionUpgradeModal({
);
};
const changelogComponents = {
a: ({ href, children }: { href?: string; children?: ReactNode }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline dark:text-blue-400">
{children}
</a>
),
};
// Clean up changelog by removing GitHub-specific metadata
const cleanChangelog = (body: string) => {
if (!body) return '';

View File

@@ -36,12 +36,8 @@ const useWebSocketProviderState = (): WebSocketContextType => {
const { token } = useAuth();
useEffect(() => {
// The cleanup below sets unmountedRef = true. Without this reset, every
// re-run of the effect (e.g. on token refresh) would short-circuit connect()
// at its unmounted guard and leave the socket permanently disconnected.
unmountedRef.current = false;
connect();
return () => {
unmountedRef.current = true;
if (reconnectTimeoutRef.current) {

View File

@@ -12,14 +12,12 @@ import type {
ProjectsUpdatedMessage,
} from '../types/app';
import type { SessionActivityMap } from './useSessionProtection';
type UseProjectsStateArgs = {
sessionId?: string;
navigate: NavigateFunction;
latestMessage: AppSocketMessage | null;
isMobile: boolean;
activeSessions: SessionActivityMap;
activeSessions: Set<string>;
};
type FetchProjectsOptions = {

View File

@@ -1,103 +1,55 @@
import { useCallback, useState } from 'react';
/**
* Map key for a request that is in flight before the provider has announced
* its real session id (a brand-new conversation). `session_created` migrates
* the entry to the concrete session id.
*/
export const PENDING_SESSION_ID = '__pending_session__';
export interface SessionActivity {
/** Provider-supplied status line; null renders the default activity label. */
statusText: string | null;
canInterrupt: boolean;
/**
* When this request was first marked as processing (client clock). Drives
* the elapsed-time display and the stale `session-status` reply guard.
*/
startedAt: number;
}
export type SessionActivityMap = ReadonlyMap<string, SessionActivity>;
export type MarkSessionProcessing = (
sessionId?: string | null,
activity?: { statusText?: string | null; canInterrupt?: boolean },
) => void;
export type MarkSessionIdle = (
sessionId?: string | null,
opts?: { ifStartedBefore?: number },
) => void;
/**
* Single source of truth for which sessions are actively processing a
* request. Everything the chat UI shows (activity indicator, abort
* availability, status text) is derived from this map; terminal events
* (`complete`, `error`, abort, an authoritative idle status reply) delete the
* entry atomically. The map also drives session protection: project refreshes
* are suppressed for sessions that have an entry here.
*/
export function useSessionProtection() {
const [processingSessions, setProcessingSessions] = useState<Map<string, SessionActivity>>(
new Map(),
);
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());
const markSessionProcessing = useCallback<MarkSessionProcessing>((sessionId, activity) => {
const markSessionAsActive = useCallback((sessionId?: string | null) => {
if (!sessionId) {
return;
}
setProcessingSessions((prev) => {
const existing = prev.get(sessionId);
const next: SessionActivity = {
statusText:
activity?.statusText !== undefined ? activity.statusText : existing?.statusText ?? null,
canInterrupt: activity?.canInterrupt ?? existing?.canInterrupt ?? true,
startedAt: existing?.startedAt ?? Date.now(),
};
setActiveSessions((prev) => new Set([...prev, sessionId]));
}, []);
if (
existing
&& existing.statusText === next.statusText
&& existing.canInterrupt === next.canInterrupt
) {
return prev;
}
const markSessionAsInactive = useCallback((sessionId?: string | null) => {
if (!sessionId) {
return;
}
const updated = new Map(prev);
updated.set(sessionId, next);
return updated;
setActiveSessions((prev) => {
const next = new Set(prev);
next.delete(sessionId);
return next;
});
}, []);
const markSessionIdle = useCallback<MarkSessionIdle>((sessionId, opts) => {
const markSessionAsProcessing = useCallback((sessionId?: string | null) => {
if (!sessionId) {
return;
}
setProcessingSessions((prev) => new Set([...prev, sessionId]));
}, []);
const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {
if (!sessionId) {
return;
}
setProcessingSessions((prev) => {
const existing = prev.get(sessionId);
if (!existing) {
return prev;
}
// Guard against stale `check-session-status` replies: if a new request
// started after the check was sent, the idle reply describes the older
// request and must not clear the newer one.
if (opts?.ifStartedBefore !== undefined && existing.startedAt >= opts.ifStartedBefore) {
return prev;
}
const updated = new Map(prev);
updated.delete(sessionId);
return updated;
const next = new Set(prev);
next.delete(sessionId);
return next;
});
}, []);
return {
activeSessions,
processingSessions,
markSessionProcessing,
markSessionIdle,
markSessionAsActive,
markSessionAsInactive,
markSessionAsProcessing,
markSessionAsNotProcessing,
};
}

View File

@@ -84,15 +84,6 @@ 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';
@@ -187,15 +178,6 @@ 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,11 +24,6 @@ export const languages = [
label: 'Simplified Chinese',
nativeName: '简体中文',
},
{
value: 'zh-TW',
label: 'Traditional Chinese',
nativeName: '繁體中文',
},
{
value: 'ja',
label: 'Japanese',

View File

@@ -138,6 +138,42 @@
"clearInput": "Eingabe leeren",
"scrollToBottom": "Nach unten scrollen"
},
"thinkingMode": {
"selector": {
"title": "Denkmodus",
"description": "Erweitertes Denken gibt Claude mehr Zeit, Alternativen zu evaluieren",
"active": "Aktiv",
"tip": "Höhere Denkmodi brauchen mehr Zeit, liefern aber eine gründlichere Analyse"
},
"modes": {
"none": {
"name": "Standard",
"description": "Reguläre Claude-Antwort",
"prefix": ""
},
"think": {
"name": "Denken",
"description": "Grundlegendes erweitertes Denken",
"prefix": "think"
},
"thinkHard": {
"name": "Intensiv denken",
"description": "Gründlichere Auswertung",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Sehr intensiv denken",
"description": "Tiefgehende Analyse mit Alternativen",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultradenken",
"description": "Maximales Denkbudget",
"prefix": "ultrathink"
}
},
"buttonTitle": "Denkmodus: {{mode}}"
},
"providerSelection": {
"title": "KI-Assistent wählen",
"description": "Anbieter auswählen, um eine neue Unterhaltung zu starten",

View File

@@ -94,35 +94,9 @@
"git": "Git",
"apiTokens": "API & Token",
"tasks": "Aufgaben",
"notifications": "Benachrichtigungen",
"plugins": "Plugins",
"about": "Info"
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Lege fest, welche Benachrichtigungen du erhältst.",
"webPush": {
"title": "Web-Push-Benachrichtigungen",
"enable": "Push-Benachrichtigungen aktivieren",
"disable": "Push-Benachrichtigungen deaktivieren",
"enabled": "Push-Benachrichtigungen sind aktiviert",
"loading": "Wird aktualisiert...",
"unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
"denied": "Push-Benachrichtigungen sind blockiert. Bitte erlaube sie in den Browsereinstellungen."
},
"sound": {
"title": "Ton",
"description": "Spielt einen kurzen Ton ab, wenn ein Chat-Lauf abgeschlossen ist.",
"enabled": "Aktiviert",
"test": "Ton testen"
},
"events": {
"title": "Ereignistypen",
"actionRequired": "Aktion erforderlich",
"stop": "Lauf gestoppt",
"error": "Lauf fehlgeschlagen"
}
},
"appearanceSettings": {
"darkMode": {
"label": "Darkmode",

View File

@@ -139,6 +139,42 @@
"clearInput": "Clear input",
"scrollToBottom": "Scroll to bottom"
},
"thinkingMode": {
"selector": {
"title": "Thinking Mode",
"description": "Extended thinking gives Claude more time to evaluate alternatives",
"active": "Active",
"tip": "Higher thinking modes take more time but provide more thorough analysis"
},
"modes": {
"none": {
"name": "Standard",
"description": "Regular Claude response",
"prefix": ""
},
"think": {
"name": "Think",
"description": "Basic extended thinking",
"prefix": "think"
},
"thinkHard": {
"name": "Think Hard",
"description": "More thorough evaluation",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Think Harder",
"description": "Deep analysis with alternatives",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultrathink",
"description": "Maximum thinking budget",
"prefix": "ultrathink"
}
},
"buttonTitle": "Thinking mode: {{mode}}"
},
"providerSelection": {
"title": "Choose Your AI Assistant",
"description": "Select a provider to start a new conversation",
@@ -193,7 +229,7 @@
"disconnect": "Disconnect",
"disconnectTitle": "Disconnect from shell",
"restart": "Restart",
"restartTitle": "Restart Shell",
"restartTitle": "Restart Shell (disconnect first)",
"connect": "Continue in Shell",
"connectTitle": "Connect to shell"
},
@@ -224,7 +260,6 @@
"label": "{{time}} elapsed",
"startingNow": "Starting now"
},
"stop": "Stop",
"controls": {
"stopGeneration": "Stop Generation",
"pressEscToStop": "Press Esc anytime to stop"

View File

@@ -110,12 +110,6 @@
"unsupported": "Push notifications are not supported in this browser.",
"denied": "Push notifications are blocked. Please allow them in your browser settings."
},
"sound": {
"title": "Sound",
"description": "Play a short tone when a chat run finishes.",
"enabled": "Enabled",
"test": "Test sound"
},
"events": {
"title": "Event Types",
"actionRequired": "Action required",
@@ -508,12 +502,6 @@
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
"install": "Install"
},
"prismCloudCLI": {
"name": "PRISM CloudCLI",
"badge": "unofficial",
"description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.",
"install": "Install"
},
"morePlugins": "More",
"enable": "Enable",
"disable": "Disable",

View File

@@ -138,6 +138,42 @@
"clearInput": "Cancella input",
"scrollToBottom": "Scorri in basso"
},
"thinkingMode": {
"selector": {
"title": "Modalità ragionamento",
"description": "Il ragionamento esteso dà a Claude più tempo per valutare le alternative",
"active": "Attivo",
"tip": "Modalità di ragionamento più elevate richiedono più tempo ma forniscono un'analisi più approfondita"
},
"modes": {
"none": {
"name": "Standard",
"description": "Risposta Claude normale",
"prefix": ""
},
"think": {
"name": "Pensa",
"description": "Ragionamento esteso base",
"prefix": "think"
},
"thinkHard": {
"name": "Pensa di più",
"description": "Valutazione più approfondita",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Pensa ancora",
"description": "Analisi profonda con alternative",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultrapensiero",
"description": "Budget massimo di ragionamento",
"prefix": "ultrathink"
}
},
"buttonTitle": "Modalità ragionamento: {{mode}}"
},
"providerSelection": {
"title": "Scegli il tuo assistente AI",
"description": "Seleziona un provider per iniziare una nuova conversazione",

View File

@@ -110,12 +110,6 @@
"unsupported": "Le notifiche push non sono supportate in questo browser.",
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
},
"sound": {
"title": "Suono",
"description": "Riproduci un breve tono quando termina un'esecuzione della chat.",
"enabled": "Attivato",
"test": "Prova suono"
},
"events": {
"title": "Tipi di evento",
"actionRequired": "Azione richiesta",

View File

@@ -117,6 +117,42 @@
"clickToChangeMode": "クリックで権限モードを変更または入力欄でTab",
"showAllCommands": "すべてのコマンドを表示"
},
"thinkingMode": {
"selector": {
"title": "思考モード",
"description": "拡張思考によりClaudeがより多くの選択肢を検討できます",
"active": "有効",
"tip": "高い思考モードは時間がかかりますが、より深い分析が得られます"
},
"modes": {
"none": {
"name": "標準",
"description": "通常のClaudeの応答",
"prefix": ""
},
"think": {
"name": "Think",
"description": "基本的な拡張思考",
"prefix": "think"
},
"thinkHard": {
"name": "Think Hard",
"description": "より深い検討",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Think Harder",
"description": "代替案を含む深い分析",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultrathink",
"description": "最大限の思考予算",
"prefix": "ultrathink"
}
},
"buttonTitle": "思考モード: {{mode}}"
},
"providerSelection": {
"title": "AIアシスタントを選択",
"description": "新しい会話を始めるプロバイダーを選択してください",

View File

@@ -110,12 +110,6 @@
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
},
"sound": {
"title": "サウンド",
"description": "チャット実行が完了したときに短い音を再生します。",
"enabled": "有効",
"test": "サウンドをテスト"
},
"events": {
"title": "イベント種別",
"actionRequired": "対応が必要",

View File

@@ -120,6 +120,42 @@
"clearInput": "입력 지우기",
"scrollToBottom": "맨 아래로 스크롤"
},
"thinkingMode": {
"selector": {
"title": "Thinking 모드",
"description": "확장된 thinking은 Claude에게 대안을 평가할 시간을 더 줍니다",
"active": "활성",
"tip": "높은 thinking 모드는 시간이 더 걸리지만 더 철저한 분석을 제공합니다"
},
"modes": {
"none": {
"name": "Standard",
"description": "일반 Claude 응답",
"prefix": ""
},
"think": {
"name": "Think",
"description": "기본 확장 thinking",
"prefix": "think"
},
"thinkHard": {
"name": "Think Hard",
"description": "더 철저한 평가",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Think Harder",
"description": "대안을 포함한 심층 분석",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultrathink",
"description": "최대 thinking 예산",
"prefix": "ultrathink"
}
},
"buttonTitle": "Thinking 모드: {{mode}}"
},
"providerSelection": {
"title": "AI 어시스턴트 선택",
"description": "새 대화를 시작할 프로바이더를 선택하세요",

View File

@@ -110,12 +110,6 @@
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
},
"sound": {
"title": "소리",
"description": "채팅 실행이 완료되면 짧은 알림음을 재생합니다.",
"enabled": "사용",
"test": "소리 테스트"
},
"events": {
"title": "이벤트 유형",
"actionRequired": "작업 필요",

View File

@@ -138,6 +138,42 @@
"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": "Выберите провайдера для начала нового разговора",

View File

@@ -94,35 +94,9 @@
"git": "Git",
"apiTokens": "API и токены",
"tasks": "Задачи",
"notifications": "Уведомления",
"plugins": "Плагины",
"about": "О программе"
},
"notifications": {
"title": "Уведомления",
"description": "Управляйте тем, какие события уведомлений вы получаете.",
"webPush": {
"title": "Web Push уведомления",
"enable": "Включить Push уведомления",
"disable": "Отключить Push уведомления",
"enabled": "Push уведомления включены",
"loading": "Обновление...",
"unsupported": "Push уведомления не поддерживаются в этом браузере.",
"denied": "Push уведомления заблокированы. Разрешите их в настройках браузера."
},
"sound": {
"title": "Звук",
"description": "Воспроизводить короткий сигнал при завершении запуска чата.",
"enabled": "Включено",
"test": "Проверить звук"
},
"events": {
"title": "Типы событий",
"actionRequired": "Требуется действие",
"stop": "Запуск остановлен",
"error": "Запуск завершился с ошибкой"
}
},
"appearanceSettings": {
"darkMode": {
"label": "Темная тема",

View File

@@ -138,6 +138,42 @@
"clearInput": "Girdiyi temizle",
"scrollToBottom": "En alta git"
},
"thinkingMode": {
"selector": {
"title": "Düşünme Modu",
"description": "Uzatılmış düşünme, Claude'a alternatifleri değerlendirmek için daha fazla zaman verir",
"active": "Aktif",
"tip": "Daha yüksek düşünme modları daha fazla zaman alır ama daha kapsamlı analiz sağlar"
},
"modes": {
"none": {
"name": "Standart",
"description": "Normal Claude yanıtı",
"prefix": ""
},
"think": {
"name": "Düşün",
"description": "Temel uzatılmış düşünme",
"prefix": "think"
},
"thinkHard": {
"name": "Daha Fazla Düşün",
"description": "Daha kapsamlı değerlendirme",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Derin Düşün",
"description": "Alternatiflerle derin analiz",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultra Düşün",
"description": "Maksimum düşünme bütçesi",
"prefix": "ultrathink"
}
},
"buttonTitle": "Düşünme modu: {{mode}}"
},
"providerSelection": {
"title": "AI Asistanını Seç",
"description": "Yeni bir konuşma başlatmak için bir sağlayıcı seç",

View File

@@ -110,12 +110,6 @@
"unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.",
"denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver."
},
"sound": {
"title": "Ses",
"description": "Sohbet çalışması tamamlandığında kısa bir ton çal.",
"enabled": "Etkin",
"test": "Sesi test et"
},
"events": {
"title": "Etkinlik Türleri",
"actionRequired": "Aksiyon gerekli",

View File

@@ -120,6 +120,42 @@
"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": "选择一个供应商以开始新对话",

View File

@@ -110,12 +110,6 @@
"unsupported": "此浏览器不支持推送通知。",
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
},
"sound": {
"title": "声音",
"description": "聊天运行完成时播放短提示音。",
"enabled": "已启用",
"test": "测试声音"
},
"events": {
"title": "事件类型",
"actionRequired": "需要处理",

View File

@@ -1,37 +0,0 @@
{
"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

@@ -1,257 +0,0 @@
{
"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

@@ -1,34 +0,0 @@
{
"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

@@ -1,267 +0,0 @@
{
"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": "關閉側邊欄"
}
}
}

Some files were not shown because too many files have changed in this diff Show More