Compare commits

...

33 Commits

Author SHA1 Message Date
simosmik
09dcea05fb fix: precise Claude SDK denial message detection in deriveToolStatus 2026-04-20 15:52:09 +00:00
simosmik
3969135bd4 fix: iOS scrolling main chat area 2026-04-20 15:47:19 +00:00
simosmik
25820ed995 fix: small mobile respnosive fixes 2026-04-20 15:41:37 +00:00
simosmik
fc3504eaed fix: migrate PlanDisplay raw params from native details to Collapsible primitive 2026-04-20 15:36:02 +00:00
simosmik
ec0ff974cb refactor: queue primitive, tool status badges, and tool display cleanup
- Add Queue/QueueItem/QueueItemIndicator/QueueItemContent primitive
- Rewrite TodoList using Queue (clean list, no bordered cards, no priority badges)
- Add ToolStatusBadge component (Running/Completed/Error/Denied)
- Migrate CollapsibleSection from native <details> to Collapsible primitive
- Add badge prop threading through CollapsibleDisplay and CollapsibleSection
- Add status badges to OneLineDisplay and CollapsibleDisplay via ToolRenderer
- Update SubagentContainer: theme tokens + Collapsible for tool history
- Replace hardcoded gray-* colors with theme tokens throughout tool displays
2026-04-20 15:30:16 +00:00
simosmik
c471b5d3fa fix: small mobile respnosive fixes 2026-04-20 15:05:08 +00:00
simosmik
5758bee8a0 refactor: chat composer new design 2026-04-20 14:47:49 +00:00
simosmik
7763e60fb3 refactor: add primitives, plan mode display, and new session model selector 2026-04-20 12:47:55 +00:00
viper151
25b00b58de chore(release): v1.29.5 2026-04-16 11:02:31 +00:00
simosmik
6a13e1773b fix: update node-pty to latest version 2026-04-16 10:52:55 +00:00
viper151
6102b74455 chore(release): v1.29.4 2026-04-16 10:33:45 +00:00
Simos Mikelatos
9ef1ab533d Refactor CLI authentication module location (#660)
* refactor: move cli-auth.js to the providers folder

* fix: expired oauth token returns no error message
2026-04-16 12:32:25 +02:00
simosmik
e9c7a5041c feat: deleting from sidebar will now ask whether to remove all data as well 2026-04-16 09:05:56 +00:00
simosmik
289520814c refactor: remove the sqlite3 dependency 2026-04-16 08:37:59 +00:00
simosmik
09486016e6 chore: upgrade commit lint to 20.5.0 2026-04-16 08:08:36 +00:00
simosmik
4c106a5083 fix: pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set
Closes #468
2026-04-16 07:58:32 +00:00
Simos Mikelatos
63e996bb77 refactor(server): extract URL detection and color utils from index.js (#657)
No behavioral changes — 1:1 code move with imports replacing inline definitions.
2026-04-16 09:46:09 +02:00
viper151
fbad3a90f8 chore(release): v1.29.3 2026-04-15 12:02:27 +00:00
Haile
96463df8da Feature/backend ts support andunification of auth settings on frontend (#654)
* fix: remove project dependency from settings controller and onboarding

* fix(settings): remove onClose prop from useSettingsController args

* chore: tailwind classes order

* refactor: move provider auth status management to custom hook

* refactor: rename SessionProvider to LLMProvider

* feat(frontend): support for @ alias based imports)

* fix: replace init.sql with schema.js

* fix: refactor database initialization to use schema.js for SQL statements

* feat(server): add a real backend TypeScript build and enforce module boundaries

The backend had started to grow beyond what the frontend-only tooling setup could
support safely. We were still running server code directly from /server, linting
mainly the client, and relying on path assumptions such as "../.." that only
worked in the source layout. That created three problems:

- backend alias imports were hard to resolve consistently in the editor, ESLint,
  and the runtime
- server code had no enforced module boundary rules, so cross-module deep imports
  could bypass intended public entry points
- building the backend into a separate output directory would break repo-level
  lookups for package.json, .env, dist, and public assets because those paths
  were derived from source-only relative assumptions

This change makes the backend tooling explicit and runtime-safe.

A dedicated backend TypeScript config now lives in server/tsconfig.json, with
tsconfig.server.json reduced to a compatibility shim. This gives the language
service and backend tooling a canonical project rooted in /server while still
preserving top-level compatibility for any existing references. The backend alias
mapping now resolves relative to /server, which avoids colliding with the
frontend's "@/..." -> "src/*" mapping.

The package scripts were updated so development runs through tsx with the backend
tsconfig, build now produces a compiled backend in dist-server, and typecheck/lint
cover both client and server. A new build-server.mjs script runs TypeScript and
tsc-alias and cleans dist-server first, which prevents stale compiled files from
shadowing current source files after refactors.

To make the compiled backend behave the same as the source backend, runtime path
resolution was centralized in server/utils/runtime-paths.js. Instead of assuming
fixed relative paths from each module, server entry points now resolve the actual
app root and server root at runtime. That keeps package.json, .env, dist, public,
and default database paths stable whether code is executed from /server or from
/dist-server/server.

ESLint was expanded from a frontend-only setup into a backend-aware one. The
backend now uses import resolution tied to the backend tsconfig so aliased imports
resolve correctly in linting, import ordering matches the frontend style, and
unused/duplicate imports are surfaced consistently.

Most importantly, eslint-plugin-boundaries now enforces server module boundaries.
Files under server/modules can no longer import another module's internals
directly. Cross-module imports must go through that module's barrel file
(index.ts/index.js). boundaries/no-unknown was also enabled so alias-resolution
gaps cannot silently bypass the rule.

Together, these changes make the backend buildable, keep runtime path resolution
stable after compilation, align server tooling with the client where appropriate,
and enforce a stricter modular architecture for server code.

* fix: update package.json to include dist-server in files and remove tsconfig.server.json

* refactor: remove build-server.mjs and inline its logic into package.json scripts

* fix: update paths in package.json and bin.js to use dist-server directory

* feat(eslint): add backend shared types and enforce compile-time contract for imports

* fix(eslint): update shared types pattern

---------

Co-authored-by: Haileyesus <something@gmail.com>
2026-04-15 13:26:12 +02:00
simosmik
31f28a2c18 chore: remove unused route (migrated to providers already) 2026-04-14 21:58:53 +00:00
Simos Mikelatos
8ff5f35c05 Update model constants for Opus and Gemini versions 2026-04-14 23:06:43 +02:00
Haile
641304242d fix(version-upgrade-modal): implement reload countdown and update UI messages (#655)
Co-authored-by: Haileyesus <something@gmail.com>
2026-04-14 23:02:20 +02:00
viper151
c3599cd2c4 chore(release): v1.29.2 2026-04-14 18:16:20 +00:00
simosmik
9b11c034d9 fix(sandbox): use backgrounded sbx run to keep sandbox alive 2026-04-14 18:14:58 +00:00
viper151
b6d19201b6 chore(release): v1.29.1 2026-04-14 17:38:53 +00:00
simosmik
4a569725da fix: add latest tag to docker npx command and change the detach mode to work without spawn 2026-04-14 17:37:20 +00:00
viper151
6ce3306947 chore(release): v1.29.0 2026-04-14 15:20:18 +00:00
Haile
d0dd007d0f Feature/restart server on update (#652)
* feat: support restart server on update for platform

* feat: add update platform script to package.json

* feat: optimize platform update command by omitting dev dependencies

* feat: simplify update commands for platform

---------

Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-04-14 17:18:47 +02:00
simosmik
13e97e2c71 feat: adding docker sandbox environments 2026-04-14 15:18:02 +00:00
Haile
c7a5baf147 fix(thinking-mode): fix dropdown positioning (#646) 2026-04-13 11:44:31 +02:00
simosmik
e2459cb0f8 chore: update release flow node version 2026-04-10 14:56:33 +00:00
viper151
9552577e94 chore(release): v1.28.1 2026-04-10 13:36:05 +00:00
Haile
590dd42649 refactor: remove unused whispher transcribe logic (#637) 2026-04-10 15:34:34 +02:00
144 changed files with 5701 additions and 4121 deletions

View File

@@ -20,14 +20,14 @@ jobs:
contents: write contents: write
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }} token: ${{ secrets.RELEASE_PAT }}
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
node-version: 20 node-version: 22
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
- name: git config - name: git config

3
.gitignore vendored
View File

@@ -8,6 +8,7 @@ lerna-debug.log*
# Build outputs # Build outputs
dist/ dist/
dist-server/
dist-ssr/ dist-ssr/
build/ build/
out/ out/
@@ -138,4 +139,4 @@ tasks/
!src/i18n/locales/de/tasks.json !src/i18n/locales/de/tasks.json
# Git worktrees # Git worktrees
.worktrees/ .worktrees/

View File

@@ -3,6 +3,82 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [1.29.5](https://github.com/siteboon/claudecodeui/compare/v1.29.4...v1.29.5) (2026-04-16)
### Bug Fixes
* update node-pty to latest version ([6a13e17](https://github.com/siteboon/claudecodeui/commit/6a13e1773b145049ade512aa6e5cac21c2e5c4de))
## [1.29.4](https://github.com/siteboon/claudecodeui/compare/v1.29.3...v1.29.4) (2026-04-16)
### New Features
* deleting from sidebar will now ask whether to remove all data as well ([e9c7a50](https://github.com/siteboon/claudecodeui/commit/e9c7a5041c31a6f7b2032f06abe19c52d3d4cd8c))
### Bug Fixes
* pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set ([4c106a5](https://github.com/siteboon/claudecodeui/commit/4c106a5083d90989bbeedaefdbb68f5b3fa6fd58)), closes [#468](https://github.com/siteboon/claudecodeui/issues/468)
### Refactoring
* remove the sqlite3 dependency ([2895208](https://github.com/siteboon/claudecodeui/commit/289520814cf3ca36403056739ef22021f78c6033))
* **server:** extract URL detection and color utils from index.js ([#657](https://github.com/siteboon/claudecodeui/issues/657)) ([63e996b](https://github.com/siteboon/claudecodeui/commit/63e996bb77cfa97b1f55f6bdccc50161a75a3eee))
### Maintenance
* upgrade commit lint to 20.5.0 ([0948601](https://github.com/siteboon/claudecodeui/commit/09486016e67d97358c228ebc6eb4502ccb0012e4))
## [1.29.3](https://github.com/siteboon/claudecodeui/compare/v1.29.2...v1.29.3) (2026-04-15)
### Bug Fixes
* **version-upgrade-modal:** implement reload countdown and update UI messages ([#655](https://github.com/siteboon/claudecodeui/issues/655)) ([6413042](https://github.com/siteboon/claudecodeui/commit/641304242d7705b54aab65faa4a7673438c92c60))
### Maintenance
* remove unused route (migrated to providers already) ([31f28a2](https://github.com/siteboon/claudecodeui/commit/31f28a2c183f6ead50941027632d7ab64b7bb2d4))
## [1.29.2](https://github.com/siteboon/claudecodeui/compare/v1.29.1...v1.29.2) (2026-04-14)
### Bug Fixes
* **sandbox:** use backgrounded sbx run to keep sandbox alive ([9b11c03](https://github.com/siteboon/claudecodeui/commit/9b11c034d9a19710a23b56c62dcf07c21a17bd97))
## [1.29.1](https://github.com/siteboon/claudecodeui/compare/v1.29.0...v1.29.1) (2026-04-14)
### Bug Fixes
* add latest tag to docker npx command and change the detach mode to work without spawn ([4a56972](https://github.com/siteboon/claudecodeui/commit/4a569725dae320a505753359d8edfd8ca79f0fd7))
## [1.29.0](https://github.com/siteboon/claudecodeui/compare/v1.28.1...v1.29.0) (2026-04-14)
### New Features
* adding docker sandbox environments ([13e97e2](https://github.com/siteboon/claudecodeui/commit/13e97e2c71254de7a60afb5495b21064c4bc4241))
### Bug Fixes
* **thinking-mode:** fix dropdown positioning ([#646](https://github.com/siteboon/claudecodeui/issues/646)) ([c7a5baf](https://github.com/siteboon/claudecodeui/commit/c7a5baf1479404bd40e23aa58bd9f677df9a04c6))
### Maintenance
* update release flow node version ([e2459cb](https://github.com/siteboon/claudecodeui/commit/e2459cb0f8b35f54827778a7b444e6c3ca326506))
## [1.28.1](https://github.com/siteboon/claudecodeui/compare/v1.28.0...v1.28.1) (2026-04-10)
### New Features
* add branding, community links, GitHub star badge, and About settings tab ([2207d05](https://github.com/siteboon/claudecodeui/commit/2207d05c1ca229214aa9c2e2c9f4d0827d421574))
### Bug Fixes
* corrupted binary downloads ([#634](https://github.com/siteboon/claudecodeui/issues/634)) ([e61f8a5](https://github.com/siteboon/claudecodeui/commit/e61f8a543d63fe7c24a04b3d2186085a06dcbcdb))
* **ui:** remove mobile bottom nav, unify processing indicator, and improve tooltip behavior on mobile ([#632](https://github.com/siteboon/claudecodeui/issues/632)) ([a8dab0e](https://github.com/siteboon/claudecodeui/commit/a8dab0edcf949ae610820bae9500c433781f7c73))
### Refactoring
* remove unused whispher transcribe logic ([#637](https://github.com/siteboon/claudecodeui/issues/637)) ([590dd42](https://github.com/siteboon/claudecodeui/commit/590dd42649424ab990353fcf59ce0965036d3d25))
## [1.28.0](https://github.com/siteboon/claudecodeui/compare/v1.27.1...v1.28.0) (2026-04-03) ## [1.28.0](https://github.com/siteboon/claudecodeui/compare/v1.27.1...v1.28.0) (2026-04-03)
### New Features ### New Features

View File

@@ -76,6 +76,8 @@ Der schnellste Einstieg keine lokale Einrichtung erforderlich. Erhalte eine
### Self-Hosted (Open Source) ### Self-Hosted (Open Source)
#### npm
CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+): CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+):
```bash ```bash
@@ -93,6 +95,15 @@ cloudcli
Die **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr. Die **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr.
#### Docker Sandboxes (Experimentell)
Agents in isolierten Sandboxes mit Hypervisor-Isolation ausführen. Standardmäßig wird Claude Code gestartet. Erfordert die [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Unterstützt Claude Code, Codex und Gemini CLI. Weitere Details in der [Sandbox-Dokumentation](docker/).
--- ---

View File

@@ -72,6 +72,8 @@
### セルフホスト(オープンソース) ### セルフホスト(オープンソース)
#### npm
**npx** で今すぐ CloudCLI UI を試せます(**Node.js** v22+ が必要): **npx** で今すぐ CloudCLI UI を試せます(**Node.js** v22+ が必要):
```bash ```bash
@@ -89,6 +91,15 @@ cloudcli
より詳細な設定オプション、PM2、リモートサーバー設定などについては **[ドキュメントはこちら →](https://cloudcli.ai/docs)** を参照してください。 より詳細な設定オプション、PM2、リモートサーバー設定などについては **[ドキュメントはこちら →](https://cloudcli.ai/docs)** を参照してください。
#### Docker Sandboxes実験的
ハイパーバイザーレベルの分離でエージェントをサンドボックスで実行します。デフォルトでは Claude Code が起動します。[`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/) が必要です。
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Claude Code、Codex、Gemini CLI に対応。詳細は[サンドボックスのドキュメント](docker/)をご覧ください。
--- ---

View File

@@ -72,6 +72,8 @@
### 셀프 호스트 (오픈 소스) ### 셀프 호스트 (오픈 소스)
#### npm
**npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요): **npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요):
```bash ```bash
@@ -87,7 +89,17 @@ cloudcli
`http://localhost:3001`을 열면 기존 세션이 자동으로 발견됩니다. `http://localhost:3001`을 열면 기존 세션이 자동으로 발견됩니다.
자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요 자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요.
#### Docker Sandboxes (실험적)
하이퍼바이저 수준 격리로 에이전트를 샌드박스에서 실행합니다. 기본 에이전트는 Claude Code입니다. [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)가 필요합니다.
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Claude Code, Codex, Gemini CLI를 지원합니다. 자세한 내용은 [샌드박스 문서](docker/)를 참고하세요.
--- ---

View File

@@ -76,6 +76,8 @@ The fastest way to get started — no local setup required. Get a fully managed,
### Self-Hosted (Open source) ### Self-Hosted (Open source)
#### npm
Try CloudCLI UI instantly with **npx** (requires **Node.js** v22+): Try CloudCLI UI instantly with **npx** (requires **Node.js** v22+):
``` ```
@@ -91,33 +93,41 @@ cloudcli
Open `http://localhost:3001` — all your existing sessions are discovered automatically. Open `http://localhost:3001` — all your existing sessions are discovered automatically.
Visit the **[documentation →](https://cloudcli.ai/docs)** for more full configuration options, PM2, remote server setup and more Visit the **[documentation →](https://cloudcli.ai/docs)** for full configuration options, PM2, remote server setup and more.
#### Docker Sandboxes (Experimental)
Run agents in isolated sandboxes with hypervisor-level isolation. Starts Claude Code by default. Requires the [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
--- ---
## Which option is right for you? ## Which option is right for you?
CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, or use CloudCLI Cloud which builds on top of it with a full managed cloud environment, team features, and deeper integrations. CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, run it in a Docker sandbox for isolation, or use CloudCLI Cloud for a fully managed environment.
| | CloudCLI UI (Self-hosted) | CloudCLI Cloud | | | Self-Hosted (npm) | Self-Hosted (Docker Sandbox) *(Experimental)* | CloudCLI Cloud |
|---|---|---| |---|---|---|---|
| **Best for** | Developers who want a full UI for local agent sessions on their own machine | Teams and developers who want agents running in the cloud, accessible from anywhere | | **Best for** | Local agent sessions on your own machine | Isolated agents with web/mobile IDE | Teams who want agents in the cloud |
| **How you access it** | Browser via `[yourip]:port` | Browser, any IDE, REST API, n8n | | **How you access it** | Browser via `[yourip]:port` | Browser via `localhost:port` | Browser, any IDE, REST API, n8n |
| **Setup** | `npx @cloudcli-ai/cloudcli` | No setup required | | **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
| **Machine needs to stay on** | Yes | No | | **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
| **Mobile access** | Any browser on your network | Any device, native app coming | | **Machine needs to stay on** | Yes | Yes | No |
| **Sessions available** | All sessions auto-discovered from `~/.claude` | All sessions within your cloud environment | | **Mobile access** | Any browser on your network | Any browser on your network | Any device, native app coming |
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | | **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
| **File explorer and Git** | Yes, built into the UI | Yes, built into the UI | | **File explorer and Git** | Yes | Yes | Yes |
| **MCP configuration** | Managed via UI, synced with your local `~/.claude` config | Managed via UI | | **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
| **IDE access** | Your local IDE | Any IDE connected to your cloud environment | | **REST API** | Yes | Yes | Yes |
| **REST API** | Yes | Yes | | **Team sharing** | No | No | Yes |
| **n8n node** | No | Yes | | **Platform cost** | Free, open source | Free, open source | Starts at $7/month |
| **Team sharing** | No | Yes |
| **Platform cost** | Free, open source | Starts at $7/month |
> Both options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI. > All options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
--- ---

View File

@@ -76,6 +76,8 @@
### Self-Hosted (Open source) ### Self-Hosted (Open source)
#### npm
Попробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+): Попробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+):
```bash ```bash
@@ -91,8 +93,17 @@ cloudcli
Откройте `http://localhost:3001` — все ваши существующие сессии будут обнаружены автоматически. Откройте `http://localhost:3001` — все ваши существующие сессии будут обнаружены автоматически.
Посетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое Посетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое.
#### Docker Sandboxes (Экспериментально)
Запускайте агентов в изолированных песочницах с гипервизорной изоляцией. По умолчанию запускается Claude Code. Требуется [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Поддерживаются Claude Code, Codex и Gemini CLI. Подробнее в [документации sandbox](docker/).
--- ---

View File

@@ -72,6 +72,8 @@
### 自托管(开源) ### 自托管(开源)
#### npm
启动 CloudCLI UI只需一行 `npx`(需要 Node.js v22+ 启动 CloudCLI UI只需一行 `npx`(需要 Node.js v22+
```bash ```bash
@@ -87,7 +89,17 @@ cloudcli
打开 `http://localhost:3001`,系统会自动发现所有现有会话。 打开 `http://localhost:3001`,系统会自动发现所有现有会话。
更多配置选项、PM2、远程服务器设置等请参阅 **[文档 →](https://cloudcli.ai/docs)** 更多配置选项、PM2、远程服务器设置等请参阅 **[文档 →](https://cloudcli.ai/docs)**
#### Docker Sandboxes实验性
在隔离的沙箱中运行代理,具有虚拟机管理程序级别的隔离。默认启动 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
支持 Claude Code、Codex 和 Gemini CLI。详情请参阅 [沙箱文档](docker/)。
--- ---

View File

@@ -1,89 +1,160 @@
# CloudCLI — Docker Sandbox Templates <!-- Docker Hub short description (100 chars max): -->
<!-- Sandbox templates for running AI coding agents with a web & mobile IDE (Claude Code, Codex, Gemini) -->
Run AI coding agents with a full web IDE inside [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/). # Sandboxed coding agents with a web & mobile IDE (CloudCLI)
Instead of a terminal-only experience, get a browser-based interface with chat, file explorer, git panel, shell, and MCP configuration — all running safely inside an isolated sandbox. [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) templates that add [CloudCLI](https://cloudcli.ai) on top of Claude Code, Codex, and Gemini CLI. You get a full web and mobile IDE accessible from any browser on any device.
## Available Templates ## Get started
| Template | Base Image | Agent | ### 1. Install the sbx CLI
|----------|-----------|-------|
| `cloudcli-ai/sandbox:claude-code` | `docker/sandbox-templates:claude-code` | Claude Code |
| `cloudcli-ai/sandbox:codex` | `docker/sandbox-templates:codex` | OpenAI Codex |
| `cloudcli-ai/sandbox:gemini` | `docker/sandbox-templates:gemini` | Gemini CLI |
## Quick Start Docker Sandboxes run agents in isolated microVMs. Install the `sbx` CLI:
### 1. Start a sandbox with the template - **macOS**: `brew install docker/tap/sbx`
- **Windows**: `winget install -h Docker.sbx`
- **Linux**: `sudo apt-get install docker-sbx`
Full instructions: [docs.docker.com/ai/sandboxes/get-started](https://docs.docker.com/ai/sandboxes/get-started/)
### 2. Store your API key
`sbx` manages credentials securely — your API key never enters the sandbox. Store it once:
```bash ```bash
sbx run --template docker.io/cloudcli-ai/sandbox:claude-code claude ~/my-project sbx login
sbx secret set -g anthropic
``` ```
### 2. Forward the UI port ### 3. Launch Claude Code
```bash ```bash
sbx ports <sandbox-name> --publish 3001:3001 npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
``` ```
### 3. Open the browser Open **http://localhost:3001**. Set a password on first visit. Start building.
``` ### Using a different agent
http://localhost:3001
Store the matching API key and pass `--agent`:
```bash
# OpenAI Codex
sbx secret set -g openai
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent codex
# Gemini CLI
sbx secret set -g google
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent gemini
``` ```
On first visit you'll set a password — this protects the UI if the port is ever exposed beyond localhost. ### Available templates
## What You Get | Agent | Template |
|-------|----------|
| **Claude Code** (default) | `docker.io/cloudcliai/sandbox:claude-code` |
| OpenAI Codex | `docker.io/cloudcliai/sandbox:codex` |
| Gemini CLI | `docker.io/cloudcliai/sandbox:gemini` |
- **Chat** — Rich conversation UI with markdown rendering, code blocks, and message history These are used with `--template` when running `sbx` directly (see [Advanced usage](#advanced-usage)).
- **Files** — Visual file tree with syntax-highlighted editor
- **Git** — Diff viewer, staging, branch switching, and commit — all visual ## Managing sandboxes
```bash
sbx ls # List all sandboxes
sbx stop my-project # Stop (preserves state)
sbx start my-project # Restart a stopped sandbox
sbx rm my-project # Remove everything
sbx exec my-project bash # Open a shell inside the sandbox
```
If you install CloudCLI globally (`npm install -g @cloudcli-ai/cloudcli`), you can also use:
```bash
cloudcli sandbox ls
cloudcli sandbox start my-project # Restart and re-launch web UI
cloudcli sandbox logs my-project # View server logs
```
## What you get
- **Chat** — Markdown rendering, code blocks, message history
- **Files** — File tree with syntax-highlighted editor
- **Git** — Diff viewer, staging, branch switching, commits
- **Shell** — Built-in terminal emulator - **Shell** — Built-in terminal emulator
- **MCP** — Configure Model Context Protocol servers through the UI - **MCP** — Configure Model Context Protocol servers visually
- **Mobile** — Works on tablet and phone browsers - **Mobile** — Works on tablet and phone browsers
## Building Locally Your project directory is mounted bidirectionally — edits propagate in real time, both ways.
All Dockerfiles share scripts from `shared/`. Build with the `docker/` directory as context:
```bash
# Claude Code variant
docker build -f docker/claude-code/Dockerfile -t cloudcli-sandbox:claude-code docker/
# Codex variant
docker build -f docker/codex/Dockerfile -t cloudcli-sandbox:codex docker/
# Gemini variant
docker build -f docker/gemini/Dockerfile -t cloudcli-sandbox:gemini docker/
```
## How It Works
Each template extends Docker's official sandbox base image and adds:
1. **Node.js 22** — Runtime for CloudCLI
2. **CloudCLI** — Installed globally via `npm install -g @cloudcli-ai/cloudcli`
3. **Auto-start** — The UI server starts in the background when the sandbox shell opens (port 3001)
The agent (Claude Code, Codex, or Gemini) comes from the base image. CloudCLI connects to it and provides the web interface on top.
## Configuration ## Configuration
| Environment Variable | Default | Description | Set variables at creation time with `--env`:
|---------------------|---------|-------------|
| `SERVER_PORT` | `3001` | Port for the web UI |
| `HOST` | `0.0.0.0` | Bind address |
| `DATABASE_PATH` | `~/.cloudcli/auth.db` | SQLite database location |
## Network Policies
If your sandbox uses restricted network policies, allow the UI port:
```bash ```bash
sbx policy allow network "localhost:3001" npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --env SERVER_PORT=8080
``` ```
Or inside a running sandbox:
```bash
sbx exec my-project bash -c 'echo "export SERVER_PORT=8080" >> /etc/sandbox-persistent.sh'
```
Restart CloudCLI for changes to take effect:
```bash
sbx exec my-project bash -c 'pkill -f "server/index.js"'
sbx exec -d my-project cloudcli start --port 3001
```
| Variable | Default | Description |
|----------|---------|-------------|
| `SERVER_PORT` | `3001` | Web UI port |
| `HOST` | `0.0.0.0` | Bind address (must be `0.0.0.0` for `sbx ports`) |
| `DATABASE_PATH` | `~/.cloudcli/auth.db` | SQLite database location |
## Advanced usage
For branch mode, multiple workspaces, memory limits, or the terminal agent experience, use `sbx` with the template:
```bash
# Terminal agent + web UI
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --name my-project
sbx ports my-project --publish 3001:3001
# Branch mode (Git worktree isolation)
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --branch my-feature
# Multiple workspaces
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/project ~/shared-libs:ro
# Pass a prompt directly
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project -- "Fix the auth bug"
```
CloudCLI auto-starts via `.bashrc` when using `sbx run`.
Full options in the [Docker Sandboxes usage guide](https://docs.docker.com/ai/sandboxes/usage/).
## Network policies
Sandboxes restrict outbound access by default. To reach host services from inside the sandbox:
```bash
sbx policy allow network localhost:11434
# Inside the sandbox: curl http://host.docker.internal:11434
```
The web UI itself doesn't need a policy — access it via `sbx ports`.
## Links
- [CloudCLI Cloud](https://cloudcli.ai) — fully managed, no setup required
- [Documentation](https://cloudcli.ai/docs) — full configuration guide
- [Discord](https://discord.gg/buxwujPNRE) — community support
- [GitHub](https://github.com/siteboon/claudecodeui) — source code and issues
## License ## License
These templates are free and open-source under the same license as CloudCLI (AGPL-3.0-or-later). AGPL-3.0-or-later

View File

@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
USER agent USER agent
RUN npm install -g @cloudcli-ai/cloudcli RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc

View File

@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
USER agent USER agent
RUN npm install -g @cloudcli-ai/cloudcli RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc

View File

@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
USER agent USER agent
RUN npm install -g @cloudcli-ai/cloudcli RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc

View File

@@ -1,13 +1,10 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Install Node.js 22 LTS # Install build tools needed for native modules (node-pty, better-sqlite3, bcrypt)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - # Node.js is already provided by the sandbox base image
apt-get update && apt-get install -y --no-install-recommends \
# Install Node.js + build tools needed for native modules (node-pty, better-sqlite3, bcrypt) build-essential python3 python3-setuptools \
# Node.js + build tools for native modules + common dev tools
apt-get install -y --no-install-recommends \
nodejs build-essential python3 python3-setuptools \
jq ripgrep sqlite3 zip unzip tree vim-tiny jq ripgrep sqlite3 zip unzip tree vim-tiny
# Clean up apt cache to reduce image size # Clean up apt cache to reduce image size

View File

@@ -4,19 +4,14 @@
# This script is sourced from ~/.bashrc on sandbox shell open. # This script is sourced from ~/.bashrc on sandbox shell open.
if ! pgrep -f "server/index.js" > /dev/null 2>&1; then if ! pgrep -f "server/index.js" > /dev/null 2>&1; then
# Start the pre-installed version immediately
nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 &
disown disown
# Check for updates in the background (non-blocking)
nohup npm update -g @cloudcli-ai/cloudcli > /tmp/cloudcli-update.log 2>&1 &
disown
echo "" echo ""
echo " CloudCLI is starting on port 3001..." echo " CloudCLI is starting on port 3001..."
echo "" echo ""
echo " To access the web UI, forward the port:" echo " Forward the port from another terminal:"
echo " sbx ports \$(hostname) --publish 3001:3001" echo " sbx ports <sandbox-name> --publish 3001:3001"
echo "" echo ""
echo " Then open: http://localhost:3001" echo " Then open: http://localhost:3001"
echo "" echo ""

View File

@@ -3,7 +3,9 @@ import tseslint from "typescript-eslint";
import react from "eslint-plugin-react"; import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh"; import reactRefresh from "eslint-plugin-react-refresh";
import importX from "eslint-plugin-import-x"; import { createNodeResolver, importX } from "eslint-plugin-import-x";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import boundaries from "eslint-plugin-boundaries";
import tailwindcss from "eslint-plugin-tailwindcss"; import tailwindcss from "eslint-plugin-tailwindcss";
import unusedImports from "eslint-plugin-unused-imports"; import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals"; import globals from "globals";
@@ -82,7 +84,7 @@ export default tseslint.config(
"sibling", "sibling",
"index", "index",
], ],
"newlines-between": "never", "newlines-between": "always",
}, },
], ],
@@ -98,5 +100,131 @@ export default tseslint.config(
"no-control-regex": "off", "no-control-regex": "off",
"no-useless-escape": "off", "no-useless-escape": "off",
}, },
},
{
files: ["server/**/*.{js,ts}"], // apply this block only to backend source files
ignores: ["server/**/*.d.ts"], // skip generated declaration files in backend linting
plugins: {
boundaries, // enforce backend architecture boundaries (module-to-module contracts)
"import-x": importX, // keep import hygiene rules (duplicates, unresolved paths, etc.)
"unused-imports": unusedImports, // remove dead imports/variables from backend files
},
languageOptions: {
parser: tseslint.parser, // parse both JS and TS syntax in backend files
parserOptions: {
ecmaVersion: "latest", // support modern ECMAScript syntax in backend code
sourceType: "module", // treat backend files as ESM modules
},
globals: {
...globals.node, // expose Node.js globals such as process, Buffer, and __dirname equivalents
},
},
settings: {
"boundaries/include": ["server/**/*.{js,ts}"], // only analyze dependency boundaries inside backend files
"import/resolver": {
// boundaries resolves imports through eslint-module-utils, which reads the classic
// import/resolver setting instead of import-x/resolver-next.
typescript: {
project: ["server/tsconfig.json"], // resolve backend aliases using the canonical backend tsconfig
alwaysTryTypes: true, // keep normal TS package/type resolution working alongside aliases
},
node: {
extensions: [".mjs", ".cjs", ".js", ".json", ".node", ".ts", ".tsx"], // preserve Node-style fallback resolution for plain files
},
},
"import-x/resolver-next": [
// ESLint's import plugin does not read tsconfig path aliases on its own.
// This resolver teaches import-x how to understand the backend-only "@/*"
// mapping defined in server/tsconfig.json, which fixes false no-unresolved errors in editors.
createTypeScriptImportResolver({
project: ["server/tsconfig.json"], // point the resolver at the canonical backend tsconfig instead of the frontend one
alwaysTryTypes: true, // keep standard TypeScript package resolution working while backend aliases are enabled
}),
// Keep Node-style resolution available for normal package imports and plain relative JS files.
// The TypeScript resolver handles aliases, while the Node resolver preserves the expected fallback behavior.
createNodeResolver({
extensions: [".mjs", ".cjs", ".js", ".json", ".node", ".ts", ".tsx"],
}),
],
"boundaries/elements": [
{
type: "backend-shared-types", // shared backend type contract that modules may consume without creating runtime coupling
pattern: ["server/shared/types.{js,ts}"], // support the current shared types path
mode: "file", // treat the types file itself as the boundary element instead of the whole folder
},
{
type: "backend-module", // logical element name used by boundaries rules below
pattern: "server/modules/*", // each direct folder in server/modules is treated as one module boundary
mode: "folder", // classify dependencies at folder-module level (not per individual file)
capture: ["moduleName"], // capture the module folder name for messages/debugging/template use
},
],
},
rules: {
// --- Unused imports/vars (backend) ---
"unused-imports/no-unused-imports": "warn", // warn when imports are not used so they can be cleaned up
"unused-imports/no-unused-vars": "off", // keep backend signal focused on dead imports instead of local unused variables
// --- Import hygiene (backend) ---
"import-x/no-duplicates": "warn", // prevent duplicate import lines from the same module
"import-x/order": [
"warn", // keep backend import grouping/order consistent with the frontend config
{
groups: [
"builtin", // Node built-ins such as fs, path, and url come first
"external", // third-party packages come after built-ins
"internal", // aliased internal imports such as @/... come next
"parent", // ../ imports come after aliased internal imports
"sibling", // ./foo imports come after parent imports
"index", // bare ./ imports stay last
],
"newlines-between": "always", // require a blank line between import groups in backend files too
},
],
"import-x/no-unresolved": "error", // fail when an import path cannot be resolved
"import-x/no-useless-path-segments": "warn", // prefer cleaner paths (remove redundant ./ and ../ segments)
"import-x/no-absolute-path": "error", // disallow absolute filesystem imports in backend files
// --- General safety/style (backend) ---
eqeqeq: ["warn", "always", { null: "ignore" }], // avoid accidental coercion while still allowing x == null checks
// --- Architecture boundaries (backend modules) ---
"boundaries/dependencies": [
"error", // treat architecture violations as lint errors
{
default: "allow", // allow normal imports unless a rule below explicitly disallows them
checkInternals: false, // do not apply these cross-module rules to imports inside the same module
rules: [
{
from: { type: "backend-module" }, // modules may depend on the shared types contract only as erased type-only imports
to: { type: "backend-shared-types" },
disallow: {
dependency: { kind: ["value", "typeof"] },
}, // block runtime imports so shared types stay a compile-time contract instead of a hidden shared module
message:
"Backend modules may only use `import type` when importing from server/shared/types.ts (or server/types.ts).",
},
{
to: { type: "backend-module" }, // when importing anything that belongs to another backend module
disallow: { to: { internalPath: "**" } }, // block all direct/deep imports into module internals by default
message:
"Cross-module imports must go through that module's barrel file (server/modules/<module>/index.ts or index.js).", // explicit error message for architecture violations
},
{
to: { type: "backend-module" }, // same target scope as the disallow rule above
allow: {
to: {
internalPath: [
"index", // allow extensionless barrel imports resolved as module root index
"index.{js,mjs,cjs,ts,tsx}", // allow explicit index.* barrel file imports
],
},
}, // re-allow only public module entry points (barrel files)
},
],
},
],
"boundaries/no-unknown": "error", // fail fast if boundaries cannot classify a dependency, which prevents silent rule bypasses
},
} }
); );

1895
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,17 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.28.0", "version": "1.29.5",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "server/index.js", "main": "dist-server/server/index.js",
"bin": { "bin": {
"cloudcli": "server/cli.js" "cloudcli": "dist-server/server/cli.js"
}, },
"files": [ "files": [
"server/", "server/",
"shared/", "shared/",
"dist/", "dist/",
"dist-server/",
"scripts/", "scripts/",
"README.md" "README.md"
], ],
@@ -23,19 +24,25 @@
"url": "https://github.com/siteboon/claudecodeui/issues" "url": "https://github.com/siteboon/claudecodeui/issues"
}, },
"scripts": { "scripts": {
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"", "dev": "concurrently --kill-others \"npm run server:dev\" \"npm run client\"",
"server": "node server/index.js", "server": "node dist-server/server/index.js",
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
"client": "vite", "client": "vite",
"build": "vite build", "build": "npm run build:client && npm run build:server",
"build:client": "vite build",
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
"build:server": "tsc -p server/tsconfig.json && tsc-alias -p server/tsconfig.json",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p server/tsconfig.json",
"lint": "eslint src/", "lint": "eslint src/ server/",
"lint:fix": "eslint src/ --fix", "lint:fix": "eslint src/ server/ --fix",
"start": "npm run build && npm run server", "start": "npm run build && npm run server",
"release": "./release.sh", "release": "./release.sh",
"prepublishOnly": "npm run build", "prepublishOnly": "npm run build",
"postinstall": "node scripts/fix-node-pty.js", "postinstall": "node scripts/fix-node-pty.js",
"prepare": "husky" "prepare": "husky",
"update:platform": "./update-platform.sh"
}, },
"keywords": [ "keywords": [
"claude code", "claude code",
@@ -97,7 +104,7 @@
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"multer": "^2.0.1", "multer": "^2.0.1",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"node-pty": "^1.1.0-beta34", "node-pty": "^1.2.0-beta.12",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
@@ -110,15 +117,13 @@
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.4.3", "@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.4.3", "@commitlint/config-conventional": "^20.5.0",
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@release-it/conventional-changelog": "^10.0.5", "@release-it/conventional-changelog": "^10.0.5",
"@types/node": "^22.19.7", "@types/node": "^22.19.7",
@@ -129,6 +134,8 @@
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",
"eslint-plugin-import-x": "^4.16.1", "eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
@@ -143,11 +150,14 @@
"release-it": "^19.0.5", "release-it": "^19.0.5",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"tsc-alias": "^1.8.16",
"tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^7.0.4" "vite": "^7.0.4"
}, },
"lint-staged": { "lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": "eslint" "src/**/*.{ts,tsx,js,jsx}": "eslint",
"server/**/*.{js,ts}": "eslint"
} }
} }

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env node #!/usr/bin/env node
import('@cloudcli-ai/cloudcli/server/cli.js'); import('@cloudcli-ai/cloudcli/dist-server/server/cli.js');

View File

@@ -26,13 +26,14 @@ import {
} from './services/notification-orchestrator.js'; } from './services/notification-orchestrator.js';
import { claudeAdapter } from './providers/claude/adapter.js'; import { claudeAdapter } from './providers/claude/adapter.js';
import { createNormalizedMessage } from './providers/types.js'; import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
const activeSessions = new Map(); const activeSessions = new Map();
const pendingToolApprovals = new Map(); const pendingToolApprovals = new Map();
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000; const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']); const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
function createRequestId() { function createRequestId() {
if (typeof crypto.randomUUID === 'function') { if (typeof crypto.randomUUID === 'function') {
@@ -148,6 +149,10 @@ function mapCliOptionsToSDK(options = {}) {
const sdkOptions = {}; const sdkOptions = {};
if (process.env.CLAUDE_CLI_PATH) {
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH;
}
// Map working directory // Map working directory
if (cwd) { if (cwd) {
sdkOptions.cwd = cwd; sdkOptions.cwd = cwd;
@@ -701,8 +706,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files on error // Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir); await cleanupTempFiles(tempImagePaths, tempDir);
// Check if Claude CLI is installed for a clearer error message
const installed = getStatusChecker('claude')?.checkInstalled() ?? true;
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 // Send error to WebSocket
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
notifyRunFailed({ notifyRunFailed({
userId: ws?.userId || null, userId: ws?.userId || null,
provider: 'claude', provider: 'claude',
@@ -710,8 +721,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
sessionName: sessionSummary, sessionName: sessionSummary,
error error
}); });
throw error;
} }
} }

View File

@@ -7,6 +7,7 @@
* Commands: * Commands:
* (no args) - Start the server (default) * (no args) - Start the server (default)
* start - Start the server * start - Start the server
* sandbox - Manage Docker sandbox environments
* status - Show configuration and data locations * status - Show configuration and data locations
* help - Show help information * help - Show help information
* version - Show version information * version - Show version information
@@ -15,11 +16,12 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { fileURLToPath } from 'url'; import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url); const __dirname = getModuleDir(import.meta.url);
const __dirname = dirname(__filename); // The CLI is compiled into dist-server/server, but it still needs to read the top-level
// package.json and .env file. Resolving the app root once keeps those lookups stable.
const APP_ROOT = findAppRoot(__dirname);
// ANSI color codes for terminal output // ANSI color codes for terminal output
const colors = { const colors = {
@@ -49,13 +51,16 @@ const c = {
}; };
// Load package.json for version info // Load package.json for version info
const packageJsonPath = path.join(__dirname, '../package.json'); const packageJsonPath = path.join(APP_ROOT, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Match the runtime fallback in load-env.js so "cloudcli status" reports the same default
// database location that the backend will actually use when no DATABASE_PATH is configured.
const DEFAULT_DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
// Load environment variables from .env file if it exists // Load environment variables from .env file if it exists
function loadEnvFile() { function loadEnvFile() {
try { try {
const envPath = path.join(__dirname, '../.env'); const envPath = path.join(APP_ROOT, '.env');
const envFile = fs.readFileSync(envPath, 'utf8'); const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => { envFile.split('\n').forEach(line => {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
@@ -74,12 +79,12 @@ function loadEnvFile() {
// Get the database path (same logic as db.js) // Get the database path (same logic as db.js)
function getDatabasePath() { function getDatabasePath() {
loadEnvFile(); loadEnvFile();
return process.env.DATABASE_PATH || path.join(__dirname, 'database', 'auth.db'); return process.env.DATABASE_PATH || DEFAULT_DATABASE_PATH;
} }
// Get the installation directory // Get the installation directory
function getInstallDir() { function getInstallDir() {
return path.join(__dirname, '..'); return APP_ROOT;
} }
// Show status command // Show status command
@@ -123,7 +128,7 @@ function showStatus() {
console.log(` Status: ${projectsExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found')}`); console.log(` Status: ${projectsExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found')}`);
// Config file location // Config file location
const envFilePath = path.join(__dirname, '../.env'); const envFilePath = path.join(APP_ROOT, '.env');
const envExists = fs.existsSync(envFilePath); const envExists = fs.existsSync(envFilePath);
console.log(`\n${c.info('[INFO]')} Configuration File:`); console.log(`\n${c.info('[INFO]')} Configuration File:`);
console.log(` ${c.dim(envFilePath)}`); console.log(` ${c.dim(envFilePath)}`);
@@ -150,6 +155,7 @@ Usage:
Commands: Commands:
start Start the CloudCLI server (default) start Start the CloudCLI server (default)
sandbox Manage Docker sandbox environments
status Show configuration and data locations status Show configuration and data locations
update Update to the latest version update Update to the latest version
help Show this help information help Show this help information
@@ -164,8 +170,7 @@ Options:
Examples: Examples:
$ cloudcli # Start with defaults $ cloudcli # Start with defaults
$ cloudcli --port 8080 # Start on port 8080 $ cloudcli --port 8080 # Start on port 8080
$ cloudcli -p 3000 # Short form for port $ cloudcli sandbox ~/my-project # Run in a Docker sandbox
$ cloudcli start --port 4000 # Explicit start command
$ cloudcli status # Show configuration $ cloudcli status # Show configuration
Environment Variables: Environment Variables:
@@ -244,6 +249,353 @@ async function updatePackage() {
} }
} }
// ── Sandbox command ─────────────────────────────────────────
const SANDBOX_TEMPLATES = {
claude: 'docker.io/cloudcliai/sandbox:claude-code',
codex: 'docker.io/cloudcliai/sandbox:codex',
gemini: 'docker.io/cloudcliai/sandbox:gemini',
};
const SANDBOX_SECRETS = {
claude: 'anthropic',
codex: 'openai',
gemini: 'google',
};
function parseSandboxArgs(args) {
const result = {
subcommand: null,
workspace: null,
agent: 'claude',
name: null,
port: 3001,
template: null,
env: [],
};
const subcommands = ['ls', 'stop', 'start', 'rm', 'logs', 'help'];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (i === 0 && subcommands.includes(arg)) {
result.subcommand = arg;
} else if (arg === '--agent' || arg === '-a') {
result.agent = args[++i];
} else if (arg === '--name' || arg === '-n') {
result.name = args[++i];
} else if (arg === '--port') {
result.port = parseInt(args[++i], 10);
} else if (arg === '--template' || arg === '-t') {
result.template = args[++i];
} else if (arg === '--env' || arg === '-e') {
result.env.push(args[++i]);
} else if (!arg.startsWith('-')) {
if (!result.subcommand) {
result.workspace = arg;
} else {
result.name = arg; // for stop/start/rm/logs <name>
}
}
}
// Default subcommand based on what we got
if (!result.subcommand) {
result.subcommand = 'create';
}
// Derive name from workspace path if not set
if (!result.name && result.workspace) {
result.name = path.basename(path.resolve(result.workspace.replace(/^~/, os.homedir())));
}
// Default template from agent
if (!result.template) {
result.template = SANDBOX_TEMPLATES[result.agent] || SANDBOX_TEMPLATES.claude;
}
return result;
}
function showSandboxHelp() {
console.log(`
${c.bright('CloudCLI Sandbox')} — Run CloudCLI inside Docker Sandboxes
Usage:
cloudcli sandbox <workspace> Create and start a sandbox
cloudcli sandbox <subcommand> [name] Manage sandboxes
Subcommands:
${c.bright('(default)')} Create a sandbox and start the web UI
${c.bright('ls')} List all sandboxes
${c.bright('start')} Restart a stopped sandbox and re-launch the web UI
${c.bright('stop')} Stop a sandbox (preserves state)
${c.bright('rm')} Remove a sandbox
${c.bright('logs')} Show CloudCLI server logs
${c.bright('help')} Show this help
Options:
-a, --agent <agent> Agent to use: claude, codex, gemini (default: claude)
-n, --name <name> Sandbox name (default: derived from workspace folder)
-t, --template <image> Custom template image
-e, --env <KEY=VALUE> Set environment variable (repeatable)
--port <port> Host port for the web UI (default: 3001)
Examples:
$ cloudcli sandbox ~/my-project
$ cloudcli sandbox ~/my-project --agent codex --port 8080
$ cloudcli sandbox ~/my-project --env SERVER_PORT=8080 --env HOST=0.0.0.0
$ cloudcli sandbox ls
$ cloudcli sandbox stop my-project
$ cloudcli sandbox start my-project
$ cloudcli sandbox rm my-project
Prerequisites:
1. Install sbx CLI: https://docs.docker.com/ai/sandboxes/get-started/
2. Authenticate and store your API key:
sbx login
sbx secret set -g anthropic # for Claude
sbx secret set -g openai # for Codex
sbx secret set -g google # for Gemini
Advanced usage:
For branch mode, multiple workspaces, memory limits, network policies,
or passing prompts to the agent, use sbx directly with the template:
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --branch my-feature
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/project ~/libs:ro --memory 8g
Full Docker Sandboxes docs: https://docs.docker.com/ai/sandboxes/usage/
`);
}
async function sandboxCommand(args) {
const { execFileSync, spawn: spawnProcess } = await import('child_process');
// Safe execution — uses execFileSync (no shell) to prevent injection
const sbx = (subcmd, opts = {}) => {
const result = execFileSync('sbx', subcmd, {
encoding: 'utf8',
stdio: opts.inherit ? 'inherit' : 'pipe',
});
return result || '';
};
const opts = parseSandboxArgs(args);
if (opts.subcommand === 'help') {
showSandboxHelp();
return;
}
// Validate name (alphanumeric, hyphens, underscores only)
if (opts.name && !/^[\w-]+$/.test(opts.name)) {
console.error(`\n${c.error('❌')} Invalid sandbox name: ${opts.name}`);
console.log(` Names may only contain letters, numbers, hyphens, and underscores.\n`);
process.exit(1);
}
// Check sbx is installed
try {
sbx(['version']);
} catch {
console.error(`\n${c.error('❌')} ${c.bright('sbx')} CLI not found.\n`);
console.log(` Install it from: ${c.info('https://docs.docker.com/ai/sandboxes/get-started/')}`);
console.log(` Then run: ${c.bright('sbx login')}`);
console.log(` And store your API key: ${c.bright('sbx secret set -g anthropic')}\n`);
process.exit(1);
}
switch (opts.subcommand) {
case 'ls':
sbx(['ls'], { inherit: true });
break;
case 'stop':
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox stop <name>\n`);
process.exit(1);
}
sbx(['stop', opts.name], { inherit: true });
break;
case 'rm':
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox rm <name>\n`);
process.exit(1);
}
sbx(['rm', opts.name], { inherit: true });
break;
case 'logs':
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox logs <name>\n`);
process.exit(1);
}
try {
sbx(['exec', opts.name, 'bash', '-c', 'cat /tmp/cloudcli-ui.log'], { inherit: true });
} catch (e) {
console.error(`\n${c.error('❌')} Could not read logs: ${e.message || 'Is the sandbox running?'}\n`);
}
break;
case 'start': {
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox start <name>\n`);
process.exit(1);
}
console.log(`\n${c.info('▶')} Starting sandbox ${c.bright(opts.name)}...`);
const restartRun = spawnProcess('sbx', ['run', opts.name], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
});
restartRun.unref();
await new Promise(resolve => setTimeout(resolve, 5000));
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try {
sbx(['ports', opts.name, '--publish', `${opts.port}:3001`]);
} catch (e) {
const msg = e.stdout || e.stderr || e.message || '';
if (msg.includes('address already in use')) {
const altPort = opts.port + 1;
console.log(`${c.warn('⚠')} Port ${opts.port} in use, trying ${altPort}...`);
try {
sbx(['ports', opts.name, '--publish', `${altPort}:3001`]);
opts.port = altPort;
} catch {
console.error(`${c.error('❌')} Ports ${opts.port} and ${altPort} both in use. Use --port to specify a free port.`);
process.exit(1);
}
} else {
throw e;
}
}
console.log(`\n${c.ok('✔')} ${c.bright('CloudCLI is ready!')}`);
console.log(` ${c.info('→')} ${c.bright(`http://localhost:${opts.port}`)}\n`);
break;
}
case 'create': {
if (!opts.workspace) {
console.error(`\n${c.error('❌')} Workspace path required: cloudcli sandbox <path>\n`);
console.log(` Example: ${c.bright('cloudcli sandbox ~/my-project')}\n`);
process.exit(1);
}
const workspace = opts.workspace.startsWith('~')
? opts.workspace.replace(/^~/, os.homedir())
: path.resolve(opts.workspace);
if (!fs.existsSync(workspace)) {
console.error(`\n${c.error('❌')} Workspace path not found: ${c.dim(workspace)}\n`);
process.exit(1);
}
const secret = SANDBOX_SECRETS[opts.agent] || 'anthropic';
// Check if the required secret is stored
try {
const secretList = sbx(['secret', 'ls']);
if (!secretList.includes(secret)) {
console.error(`\n${c.error('❌')} No ${c.bright(secret)} API key found.\n`);
console.log(` Run: ${c.bright(`sbx secret set -g ${secret}`)}\n`);
process.exit(1);
}
} catch { /* sbx secret ls not available, skip check */ }
console.log(`\n${c.bright('CloudCLI Sandbox')}`);
console.log(c.dim('─'.repeat(50)));
console.log(` Agent: ${c.info(opts.agent)} ${c.dim(`(${secret} credentials)`)}`);
console.log(` Workspace: ${c.dim(workspace)}`);
console.log(` Name: ${c.dim(opts.name)}`);
console.log(` Template: ${c.dim(opts.template)}`);
console.log(` Port: ${c.dim(String(opts.port))}`);
if (opts.env.length > 0) {
console.log(` Env: ${c.dim(opts.env.join(', '))}`);
}
console.log(c.dim('─'.repeat(50)));
// Step 1: Launch sandbox with sbx run in background.
// sbx run creates the sandbox (or reconnects) AND holds an active session,
// which prevents the sandbox from auto-stopping.
console.log(`\n${c.info('▶')} Creating sandbox ${c.bright(opts.name)}...`);
const bgRun = spawnProcess('sbx', [
'run', '--template', opts.template, '--name', opts.name, opts.agent, workspace,
], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
});
bgRun.unref();
// Wait for sandbox to be ready
await new Promise(resolve => setTimeout(resolve, 5000));
// Step 2: Inject environment variables
if (opts.env.length > 0) {
console.log(`${c.info('▶')} Setting environment variables...`);
const exports = opts.env
.filter(e => /^\w+=.+$/.test(e))
.map(e => `export ${e}`)
.join('\n');
if (exports) {
sbx(['exec', opts.name, 'bash', '-c', `echo '${exports}' >> /etc/sandbox-persistent.sh`]);
}
const invalid = opts.env.filter(e => !/^\w+=.+$/.test(e));
if (invalid.length > 0) {
console.log(`${c.warn('⚠')} Skipped invalid env vars: ${invalid.join(', ')} (expected KEY=VALUE)`);
}
}
// Step 3: Start CloudCLI inside the sandbox
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
// Step 4: Forward port
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try {
sbx(['ports', opts.name, '--publish', `${opts.port}:3001`]);
} catch (e) {
const msg = e.stdout || e.stderr || e.message || '';
if (msg.includes('address already in use')) {
const altPort = opts.port + 1;
console.log(`${c.warn('⚠')} Port ${opts.port} in use, trying ${altPort}...`);
try {
sbx(['ports', opts.name, '--publish', `${altPort}:3001`]);
opts.port = altPort;
} catch {
console.error(`${c.error('❌')} Ports ${opts.port} and ${altPort} both in use. Use --port to specify a free port.`);
process.exit(1);
}
} else {
throw e;
}
}
// Done
console.log(`\n${c.ok('✔')} ${c.bright('CloudCLI is ready!')}`);
console.log(` ${c.info('→')} Open ${c.bright(`http://localhost:${opts.port}`)}`);
console.log(`\n${c.dim(' Manage with:')}`);
console.log(` ${c.dim('$')} sbx ls`);
console.log(` ${c.dim('$')} sbx stop ${opts.name}`);
console.log(` ${c.dim('$')} sbx start ${opts.name}`);
console.log(` ${c.dim('$')} sbx rm ${opts.name}`);
console.log(`\n${c.dim(' Or install globally:')} npm install -g @cloudcli-ai/cloudcli\n`);
break;
}
default:
showSandboxHelp();
}
}
// ── Server ──────────────────────────────────────────────────
// Start the server // Start the server
async function startServer() { async function startServer() {
// Check for updates silently on startup // Check for updates silently on startup
@@ -274,6 +626,10 @@ function parseArgs(args) {
parsed.command = 'version'; parsed.command = 'version';
} else if (!arg.startsWith('-')) { } else if (!arg.startsWith('-')) {
parsed.command = arg; parsed.command = arg;
if (arg === 'sandbox') {
parsed.remainingArgs = args.slice(i + 1);
break;
}
} }
} }
@@ -283,7 +639,7 @@ function parseArgs(args) {
// Main CLI handler // Main CLI handler
async function main() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const { command, options } = parseArgs(args); const { command, options, remainingArgs } = parseArgs(args);
// Apply CLI options to environment variables // Apply CLI options to environment variables
if (options.serverPort) { if (options.serverPort) {
@@ -299,6 +655,9 @@ async function main() {
case 'start': case 'start':
await startServer(); await startServer();
break; break;
case 'sandbox':
await sandboxCommand(remainingArgs || []);
break;
case 'status': case 'status':
case 'info': case 'info':
showStatus(); showStatus();

View File

@@ -3,6 +3,7 @@ import crossSpawn from 'cross-spawn';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { cursorAdapter } from './providers/cursor/adapter.js'; import { cursorAdapter } from './providers/cursor/adapter.js';
import { createNormalizedMessage } from './providers/types.js'; import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
// Use cross-spawn on Windows for better command execution // Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -294,7 +295,13 @@ async function spawnCursor(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey; const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId); activeCursorProcesses.delete(finalSessionId);
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); // Check if Cursor CLI is installed for a clearer error message
const installed = getStatusChecker('cursor')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Cursor CLI is not installed. Please install it from https://cursor.com'
: error.message;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
notifyTerminalState({ error }); notifyTerminalState({ error });
settleOnce(() => reject(error)); settleOnce(() => reject(error));

View File

@@ -2,11 +2,21 @@ import Database from 'better-sqlite3';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import crypto from 'crypto'; import crypto from 'crypto';
import { fileURLToPath } from 'url'; import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
import { dirname } from 'path'; import {
APP_CONFIG_TABLE_SQL,
USER_NOTIFICATION_PREFERENCES_TABLE_SQL,
VAPID_KEYS_TABLE_SQL,
PUSH_SUBSCRIPTIONS_TABLE_SQL,
SESSION_NAMES_TABLE_SQL,
SESSION_NAMES_LOOKUP_INDEX_SQL,
DATABASE_SCHEMA_SQL
} from './schema.js';
const __filename = fileURLToPath(import.meta.url); const __dirname = getModuleDir(import.meta.url);
const __dirname = dirname(__filename); // The compiled backend lives under dist-server/server/database, but the install root we log
// should still point at the project/app root. Resolving it here avoids build-layout drift.
const APP_ROOT = findAppRoot(__dirname);
// ANSI color codes for terminal output // ANSI color codes for terminal output
const colors = { const colors = {
@@ -24,7 +34,6 @@ const c = {
// Use DATABASE_PATH environment variable if set, otherwise use default location // Use DATABASE_PATH environment variable if set, otherwise use default location
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db'); const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
// Ensure database directory exists if custom path is provided // Ensure database directory exists if custom path is provided
if (process.env.DATABASE_PATH) { if (process.env.DATABASE_PATH) {
@@ -62,14 +71,10 @@ const db = new Database(DB_PATH);
// app_config must exist before any other module imports (auth.js reads the JWT secret at load time). // app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
// runMigrations() also creates this table, but it runs too late for existing installations // runMigrations() also creates this table, but it runs too late for existing installations
// where auth.js is imported before initializeDatabase() is called. // where auth.js is imported before initializeDatabase() is called.
db.exec(`CREATE TABLE IF NOT EXISTS app_config ( db.exec(APP_CONFIG_TABLE_SQL);
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Show app installation path prominently // Show app installation path prominently
const appInstallPath = path.join(__dirname, '../..'); const appInstallPath = APP_ROOT;
console.log(''); console.log('');
console.log(c.dim('═'.repeat(60))); console.log(c.dim('═'.repeat(60)));
console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`); console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
@@ -100,53 +105,12 @@ const runMigrations = () => {
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0'); db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
} }
db.exec(` db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SQL);
CREATE TABLE IF NOT EXISTS user_notification_preferences ( db.exec(VAPID_KEYS_TABLE_SQL);
user_id INTEGER PRIMARY KEY, db.exec(PUSH_SUBSCRIPTIONS_TABLE_SQL);
preferences_json TEXT NOT NULL, db.exec(APP_CONFIG_TABLE_SQL);
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, db.exec(SESSION_NAMES_TABLE_SQL);
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE db.exec(SESSION_NAMES_LOOKUP_INDEX_SQL);
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Create app_config table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Create session_names table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)');
console.log('Database migrations completed successfully'); console.log('Database migrations completed successfully');
} catch (error) { } catch (error) {
@@ -158,8 +122,7 @@ const runMigrations = () => {
// Initialize database with schema // Initialize database with schema
const initializeDatabase = async () => { const initializeDatabase = async () => {
try { try {
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8'); db.exec(DATABASE_SCHEMA_SQL);
db.exec(initSQL);
console.log('Database initialized successfully'); console.log('Database initialized successfully');
runMigrations(); runMigrations();
} catch (error) { } catch (error) {

View File

@@ -1,99 +0,0 @@
-- Initialize authentication database
PRAGMA foreign_keys = ON;
-- Users table (single user system)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1,
git_name TEXT,
git_email TEXT,
has_completed_onboarding BOOLEAN DEFAULT 0
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
-- API Keys table for external API access
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
key_name TEXT NOT NULL,
api_key TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
-- User credentials table for storing various tokens/credentials (GitHub, GitLab, etc.)
CREATE TABLE IF NOT EXISTS user_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_name TEXT NOT NULL,
credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc.
credential_value TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
-- User notification preferences (backend-owned, provider-agnostic)
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- VAPID key pair for Web Push notifications
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Browser push subscriptions
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Session custom names (provider-agnostic display name overrides)
CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
);
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
-- App configuration table (auto-generated secrets, settings, etc.)
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

102
server/database/schema.js Normal file
View File

@@ -0,0 +1,102 @@
export const APP_CONFIG_TABLE_SQL = `CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`;
export const USER_NOTIFICATION_PREFERENCES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`;
export const VAPID_KEYS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`;
export const PUSH_SUBSCRIPTIONS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`;
export const SESSION_NAMES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
);`;
export const SESSION_NAMES_LOOKUP_INDEX_SQL = `CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);`;
export const DATABASE_SCHEMA_SQL = `PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1,
git_name TEXT,
git_email TEXT,
has_completed_onboarding BOOLEAN DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
key_name TEXT NOT NULL,
api_key TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
CREATE TABLE IF NOT EXISTS user_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_name TEXT NOT NULL,
credential_type TEXT NOT NULL,
credential_value TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
${USER_NOTIFICATION_PREFERENCES_TABLE_SQL}
${VAPID_KEYS_TABLE_SQL}
${PUSH_SUBSCRIPTIONS_TABLE_SQL}
${SESSION_NAMES_TABLE_SQL}
${SESSION_NAMES_LOOKUP_INDEX_SQL}
${APP_CONFIG_TABLE_SQL}
`;

View File

@@ -10,6 +10,7 @@ import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js'; import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createNormalizedMessage } from './providers/types.js'; import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
let activeGeminiProcesses = new Map(); // Track active processes by session ID let activeGeminiProcesses = new Map(); // Track active processes by session ID
@@ -380,6 +381,15 @@ async function spawnGemini(command, options = {}, ws) {
notifyTerminalState({ code }); notifyTerminalState({ code });
resolve(); resolve();
} else { } else {
// code 127 = shell "command not found" — check installation
if (code === 127) {
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
if (!installed) {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
}
}
notifyTerminalState({ notifyTerminalState({
code, code,
error: code === null ? 'Gemini CLI process was terminated or timed out' : null error: code === null ? 'Gemini CLI process was terminated or timed out' : null
@@ -394,8 +404,14 @@ async function spawnGemini(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey; const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId); activeGeminiProcesses.delete(finalSessionId);
// Check if Gemini CLI is installed for a clearer error message
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'
: error.message;
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' })); ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
notifyTerminalState({ error }); notifyTerminalState({ error });
reject(error); reject(error);

View File

@@ -3,33 +3,15 @@
import './load-env.js'; import './load-env.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url); const __dirname = getModuleDir(import.meta.url);
const __dirname = dirname(__filename); // The server source runs from /server, while the compiled output runs from /dist-server/server.
// 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 installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm'; import { c } from './utils/colors.js';
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
ok: (text) => `${colors.green}${text}${colors.reset}`,
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
tip: (text) => `${colors.blue}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
console.log('SERVER_PORT from env:', process.env.SERVER_PORT); console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
@@ -226,68 +208,7 @@ const server = http.createServer(app);
const ptySessionsMap = new Map(); const ptySessionsMap = new Map();
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000; const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768; const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g; import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js';
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
function stripAnsiSequences(value = '') {
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
}
function normalizeDetectedUrl(url) {
if (!url || typeof url !== 'string') return null;
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
if (!cleaned) return null;
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function extractUrlsFromText(value = '') {
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
// Handle wrapped terminal URLs split across lines by terminal width.
const wrappedMatches = [];
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
const lines = value.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
if (!startMatch) continue;
let combined = startMatch[0];
let j = i + 1;
while (j < lines.length) {
const continuation = lines[j].trim();
if (!continuation) break;
if (!continuationRegex.test(continuation)) break;
combined += continuation;
j++;
}
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
}
return Array.from(new Set([...directMatches, ...wrappedMatches]));
}
function shouldAutoOpenUrlFromOutput(value = '') {
const normalized = value.toLowerCase();
return (
normalized.includes('browser didn\'t open') ||
normalized.includes('open this url') ||
normalized.includes('continue in your browser') ||
normalized.includes('press enter to open') ||
normalized.includes('open_url:')
);
}
// Single WebSocket server that handles both paths // Single WebSocket server that handles both paths
const wss = new WebSocketServer({ const wss = new WebSocketServer({
@@ -405,11 +326,11 @@ app.use('/api/sessions', authenticateToken, messagesRoutes);
app.use('/api/agent', agentRoutes); app.use('/api/agent', agentRoutes);
// Serve public files (like api-docs.html) // Serve public files (like api-docs.html)
app.use(express.static(path.join(__dirname, '../public'))); app.use(express.static(path.join(APP_ROOT, 'public')));
// Static files served after API routes // Static files served after API routes
// Add cache control: HTML files should not be cached, but assets can be cached // Add cache control: HTML files should not be cached, but assets can be cached
app.use(express.static(path.join(__dirname, '../dist'), { app.use(express.static(path.join(APP_ROOT, 'dist'), {
setHeaders: (res, filePath) => { setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) { if (filePath.endsWith('.html')) {
// Prevent HTML caching to avoid service worker issues after builds // Prevent HTML caching to avoid service worker issues after builds
@@ -431,17 +352,24 @@ app.use(express.static(path.join(__dirname, '../dist'), {
app.post('/api/system/update', authenticateToken, async (req, res) => { app.post('/api/system/update', authenticateToken, async (req, res) => {
try { try {
// Get the project root directory (parent of server directory) // Get the project root directory (parent of server directory)
const projectRoot = path.join(__dirname, '..'); const projectRoot = APP_ROOT;
console.log('Starting system update from directory:', projectRoot); console.log('Starting system update from directory:', projectRoot);
// Run the update command based on install mode // Platform deployments use their own update workflow from the project root.
const updateCommand = installMode === 'git' const updateCommand = IS_PLATFORM
? 'git checkout main && git pull && npm install' // In platform, husky and dev dependencies are not needed
: 'npm install -g @cloudcli-ai/cloudcli@latest'; ? 'npm run update:platform'
: installMode === 'git'
? 'git checkout main && git pull && npm install'
: 'npm install -g @cloudcli-ai/cloudcli@latest';
const updateCwd = IS_PLATFORM || installMode === 'git'
? projectRoot
: os.homedir();
const child = spawn('sh', ['-c', updateCommand], { const child = spawn('sh', ['-c', updateCommand], {
cwd: installMode === 'git' ? projectRoot : os.homedir(), cwd: updateCwd,
env: process.env env: process.env
}); });
@@ -566,12 +494,15 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
} }
}); });
// Delete project endpoint (force=true to delete with sessions) // Delete project endpoint
// force=true to allow removal even when sessions exist
// deleteData=true to also delete session/memory files on disk (destructive)
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => { app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try { try {
const { projectName } = req.params; const { projectName } = req.params;
const force = req.query.force === 'true'; const force = req.query.force === 'true';
await deleteProject(projectName, force); const deleteData = req.query.deleteData === 'true';
await deleteProject(projectName, force, deleteData);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -1984,155 +1915,6 @@ function handleShellConnection(ws) {
console.error('[ERROR] Shell WebSocket error:', error); console.error('[ERROR] Shell WebSocket error:', error);
}); });
} }
// Audio transcription endpoint
app.post('/api/transcribe', authenticateToken, async (req, res) => {
try {
const multer = (await import('multer')).default;
const upload = multer({ storage: multer.memoryStorage() });
// Handle multipart form data
upload.single('audio')(req, res, async (err) => {
if (err) {
return res.status(400).json({ error: 'Failed to process audio file' });
}
if (!req.file) {
return res.status(400).json({ error: 'No audio file provided' });
}
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: 'OpenAI API key not configured. Please set OPENAI_API_KEY in server environment.' });
}
try {
// Create form data for OpenAI
const FormData = (await import('form-data')).default;
const formData = new FormData();
formData.append('file', req.file.buffer, {
filename: req.file.originalname,
contentType: req.file.mimetype
});
formData.append('model', 'whisper-1');
formData.append('response_format', 'json');
formData.append('language', 'en');
// Make request to OpenAI
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
...formData.getHeaders()
},
body: formData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error?.message || `Whisper API error: ${response.status}`);
}
const data = await response.json();
let transcribedText = data.text || '';
// Check if enhancement mode is enabled
const mode = req.body.mode || 'default';
// If no transcribed text, return empty
if (!transcribedText) {
return res.json({ text: '' });
}
// If default mode, return transcribed text without enhancement
if (mode === 'default') {
return res.json({ text: transcribedText });
}
// Handle different enhancement modes
try {
const OpenAI = (await import('openai')).default;
const openai = new OpenAI({ apiKey });
let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
switch (mode) {
case 'prompt':
systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';
prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.
Your enhanced prompt should:
1. Be specific and unambiguous
2. Include relevant context and constraints
3. Specify the desired output format
4. Use clear, actionable language
5. Include examples where helpful
6. Consider edge cases and potential ambiguities
Transform this rough instruction into a well-crafted prompt:
"${transcribedText}"
Enhanced prompt:`;
break;
case 'vibe':
case 'instructions':
case 'architect':
systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';
temperature = 0.5; // Lower temperature for more controlled output
prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.
IMPORTANT RULES:
- Format as clear, step-by-step instructions
- Add reasonable implementation details based on common patterns
- Only include details directly related to what was asked
- Do NOT add features or functionality not mentioned
- Keep the original intent and scope intact
- Use clear, actionable language an agent can follow
Transform this idea into agent-friendly instructions:
"${transcribedText}"
Agent instructions:`;
break;
default:
// No enhancement needed
break;
}
// Only make GPT call if we have a prompt
if (prompt) {
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemMessage },
{ role: 'user', content: prompt }
],
temperature: temperature,
max_tokens: maxTokens
});
transcribedText = completion.choices[0].message.content || transcribedText;
}
} catch (gptError) {
console.error('GPT processing error:', gptError);
// Fall back to original transcription if GPT fails
}
res.json({ text: transcribedText });
} catch (error) {
console.error('Transcription error:', error);
res.status(500).json({ error: error.message });
}
});
} catch (error) {
console.error('Endpoint error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Image upload endpoint // Image upload endpoint
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => { app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
try { try {
@@ -2415,7 +2197,7 @@ app.get('*', (req, res) => {
// Only serve index.html for HTML routes, not for static assets // Only serve index.html for HTML routes, not for static assets
// Static assets should already be handled by express.static middleware above // Static assets should already be handled by express.static middleware above
const indexPath = path.join(__dirname, '../dist/index.html'); const indexPath = path.join(APP_ROOT, 'dist', 'index.html');
// Check if dist/index.html exists (production build available) // Check if dist/index.html exists (production build available)
if (fs.existsSync(indexPath)) { if (fs.existsSync(indexPath)) {
@@ -2530,7 +2312,7 @@ async function startServer() {
configureWebPush(); configureWebPush();
// Check if running in production mode (dist folder exists) // Check if running in production mode (dist folder exists)
const distIndexPath = path.join(__dirname, '../dist/index.html'); const distIndexPath = path.join(APP_ROOT, 'dist', 'index.html');
const isProduction = fs.existsSync(distIndexPath); const isProduction = fs.existsSync(distIndexPath);
// Log Claude implementation mode // Log Claude implementation mode
@@ -2544,7 +2326,7 @@ async function startServer() {
console.log(`${c.info('[INFO]')} To run in development mode with hot-module replacement, go to http://${DISPLAY_HOST}:${VITE_PORT}`); console.log(`${c.info('[INFO]')} To run in development mode with hot-module replacement, go to http://${DISPLAY_HOST}:${VITE_PORT}`);
server.listen(SERVER_PORT, HOST, async () => { server.listen(SERVER_PORT, HOST, async () => {
const appInstallPath = path.join(__dirname, '..'); const appInstallPath = APP_ROOT;
console.log(''); console.log('');
console.log(c.dim('═'.repeat(63))); console.log(c.dim('═'.repeat(63)));

View File

@@ -2,14 +2,15 @@
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url); const __dirname = getModuleDir(import.meta.url);
const __dirname = dirname(__filename); // Resolve the repo/app root via the nearest /server folder so this file keeps finding the
// same top-level .env file from both /server/load-env.js and /dist-server/server/load-env.js.
const APP_ROOT = findAppRoot(__dirname);
try { try {
const envPath = path.join(__dirname, '../.env'); const envPath = path.join(APP_ROOT, '.env');
const envFile = fs.readFileSync(envPath, 'utf8'); const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => { envFile.split('\n').forEach(line => {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
@@ -24,6 +25,10 @@ try {
console.log('No .env file found or error reading it:', e.message); console.log('No .env file found or error reading it:', e.message);
} }
// Keep the default database in a stable user-level location so rebuilding dist-server
// never changes where the backend stores auth.db when DATABASE_PATH is not set explicitly.
const DEFAULT_DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
if (!process.env.DATABASE_PATH) { if (!process.env.DATABASE_PATH) {
process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db'); process.env.DATABASE_PATH = DEFAULT_DATABASE_PATH;
} }

View File

@@ -17,6 +17,7 @@ import { Codex } from '@openai/codex-sdk';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { codexAdapter } from './providers/codex/adapter.js'; import { codexAdapter } from './providers/codex/adapter.js';
import { createNormalizedMessage } from './providers/types.js'; import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
// Track active sessions // Track active sessions
const activeCodexSessions = new Map(); const activeCodexSessions = new Map();
@@ -308,7 +309,14 @@ export async function queryCodex(command, options = {}, ws) {
if (!wasAborted) { if (!wasAborted) {
console.error('[Codex] Error:', error); console.error('[Codex] Error:', error);
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' }));
// Check if Codex SDK is available for a clearer error message
const installed = getStatusChecker('codex')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Codex CLI is not configured. Please set up authentication first.'
: error.message;
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: currentSessionId, provider: 'codex' }));
if (!terminalFailure) { if (!terminalFailure) {
notifyRunFailed({ notifyRunFailed({
userId: ws?.userId || null, userId: ws?.userId || null,

View File

@@ -62,8 +62,7 @@ import fsSync from 'fs';
import path from 'path'; import path from 'path';
import readline from 'readline'; import readline from 'readline';
import crypto from 'crypto'; import crypto from 'crypto';
import sqlite3 from 'sqlite3'; import Database from 'better-sqlite3';
import { open } from 'sqlite';
import os from 'os'; import os from 'os';
import sessionManager from './sessionManager.js'; import sessionManager from './sessionManager.js';
import { applyCustomSessionNames } from './database/db.js'; import { applyCustomSessionNames } from './database/db.js';
@@ -1164,8 +1163,9 @@ async function isProjectEmpty(projectName) {
} }
} }
// Delete a project (force=true to delete even with sessions) // Remove a project from the UI.
async function deleteProject(projectName, force = false) { // When deleteData=true, also delete session/memory files on disk (destructive).
async function deleteProject(projectName, force = false, deleteData = false) {
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try { try {
@@ -1175,48 +1175,50 @@ async function deleteProject(projectName, force = false) {
} }
const config = await loadProjectConfig(); const config = await loadProjectConfig();
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
// Fallback to extractProjectDirectory if projectPath is not in config // Destructive path: delete underlying data when explicitly requested
if (!projectPath) { if (deleteData) {
projectPath = await extractProjectDirectory(projectName); let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
} if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
}
// Remove the project directory (includes all Claude sessions) // Remove the Claude project directory (session logs, memory, subagent data)
await fs.rm(projectDir, { recursive: true, force: true }); await fs.rm(projectDir, { recursive: true, force: true });
// Delete all Codex sessions associated with this project // Delete Codex sessions associated with this project
if (projectPath) { if (projectPath) {
try { try {
const codexSessions = await getCodexSessions(projectPath, { limit: 0 }); const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
for (const session of codexSessions) { for (const session of codexSessions) {
try { try {
await deleteCodexSession(session.id); await deleteCodexSession(session.id);
} catch (err) { } catch (err) {
console.warn(`Failed to delete Codex session ${session.id}:`, err.message); console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
}
} }
} catch (err) {
console.warn('Failed to delete Codex sessions:', err.message);
} }
} catch (err) {
console.warn('Failed to delete Codex sessions:', err.message);
}
// Delete Cursor sessions directory if it exists // Delete Cursor sessions directory if it exists
try { try {
const hash = crypto.createHash('md5').update(projectPath).digest('hex'); const hash = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash); const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
await fs.rm(cursorProjectDir, { recursive: true, force: true }); await fs.rm(cursorProjectDir, { recursive: true, force: true });
} catch (err) { } catch (err) {
// Cursor dir may not exist, ignore // Cursor dir may not exist, ignore
}
} }
} }
// Remove from project config // Always remove from project config
delete config[projectName]; delete config[projectName];
await saveProjectConfig(config); await saveProjectConfig(config);
return true; return true;
} catch (error) { } catch (error) {
console.error(`Error deleting project ${projectName}:`, error); console.error(`Error removing project ${projectName}:`, error);
throw error; throw error;
} }
} }
@@ -1305,16 +1307,10 @@ async function getCursorSessions(projectPath) {
} catch (_) { } } catch (_) { }
// Open SQLite database // Open SQLite database
const db = await open({ const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
// Get metadata from meta table // Get metadata from meta table
const metaRows = await db.all(` const metaRows = db.prepare('SELECT key, value FROM meta').all();
SELECT key, value FROM meta
`);
// Parse metadata // Parse metadata
let metadata = {}; let metadata = {};
@@ -1336,11 +1332,9 @@ async function getCursorSessions(projectPath) {
} }
// Get message count // Get message count
const messageCountResult = await db.get(` const messageCountResult = db.prepare('SELECT COUNT(*) as count FROM blobs').get();
SELECT COUNT(*) as count FROM blobs
`);
await db.close(); db.close();
// Extract session info // Extract session info
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session'; const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';

View File

@@ -0,0 +1,136 @@
/**
* Claude Provider Status
*
* Checks whether Claude Code CLI is installed and whether the user
* has valid authentication credentials.
*
* @module providers/claude/status
*/
import { execFileSync } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Claude Code CLI is installed and available.
* Uses CLAUDE_CLI_PATH env var if set, otherwise looks for 'claude' in PATH.
* @returns {boolean}
*/
export function checkInstalled() {
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
try {
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
method: null,
error: 'Claude Code CLI is not installed'
};
}
const credentialsResult = await checkCredentials();
if (credentialsResult.authenticated) {
return {
installed,
authenticated: true,
email: credentialsResult.email || 'Authenticated',
method: credentialsResult.method || null,
error: null
};
}
return {
installed,
authenticated: false,
email: credentialsResult.email || null,
method: credentialsResult.method || null,
error: credentialsResult.error || 'Not authenticated'
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function loadSettingsEnv() {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
if (settings?.env && typeof settings.env === 'object') {
return settings.env;
}
} catch {
// Ignore missing or malformed settings.
}
return {};
}
/**
* Checks Claude authentication credentials.
*
* Priority 1: ANTHROPIC_API_KEY environment variable
* Priority 1b: ~/.claude/settings.json env values
* Priority 2: ~/.claude/.credentials.json OAuth tokens
*/
async function checkCredentials() {
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
const settingsEnv = await loadSettingsEnv();
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
}
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await fs.readFile(credPath, 'utf8');
const creds = JSON.parse(content);
const oauth = creds.claudeAiOauth;
if (oauth && oauth.accessToken) {
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
if (!isExpired) {
return {
authenticated: true,
email: creds.email || creds.user || null,
method: 'credentials_file'
};
}
return {
authenticated: false,
email: creds.email || creds.user || null,
method: 'credentials_file',
error: 'OAuth token has expired. Please re-authenticate with claude login'
};
}
return { authenticated: false, email: null, method: null };
} catch {
return { authenticated: false, email: null, method: null };
}
}

View File

@@ -0,0 +1,78 @@
/**
* Codex Provider Status
*
* Checks whether the user has valid Codex authentication credentials.
* Codex uses an SDK that makes direct API calls (no external binary),
* so installation check always returns true if the server is running.
*
* @module providers/codex/status
*/
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Codex is installed.
* Codex SDK is bundled with this application — no external binary needed.
* @returns {boolean}
*/
export function checkInstalled() {
return true;
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
const result = await checkCredentials();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function checkCredentials() {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await fs.readFile(authPath, 'utf8');
const auth = JSON.parse(content);
const tokens = auth.tokens || {};
if (tokens.id_token || tokens.access_token) {
let email = 'Authenticated';
if (tokens.id_token) {
try {
const parts = tokens.id_token.split('.');
if (parts.length >= 2) {
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
email = payload.email || payload.user || 'Authenticated';
}
} catch {
email = 'Authenticated';
}
}
return { authenticated: true, email };
}
if (auth.OPENAI_API_KEY) {
return { authenticated: true, email: 'API Key Auth' };
}
return { authenticated: false, email: null, error: 'No valid tokens found' };
} catch (error) {
if (error.code === 'ENOENT') {
return { authenticated: false, email: null, error: 'Codex not configured' };
}
return { authenticated: false, email: null, error: error.message };
}
}

View File

@@ -20,21 +20,16 @@ const PROVIDER = 'cursor';
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>} * @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
*/ */
async function loadCursorBlobs(sessionId, projectPath) { async function loadCursorBlobs(sessionId, projectPath) {
// Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable // Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable
const { default: sqlite3 } = await import('sqlite3'); const { default: Database } = await import('better-sqlite3');
const { open } = await import('sqlite');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex'); const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db'); const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
const db = await open({ const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY,
});
try { try {
const allBlobs = await db.all('SELECT rowid, id, data FROM blobs'); const allBlobs = db.prepare('SELECT rowid, id, data FROM blobs').all();
const blobMap = new Map(); const blobMap = new Map();
const parentRefs = new Map(); const parentRefs = new Map();
@@ -129,7 +124,7 @@ async function loadCursorBlobs(sessionId, projectPath) {
return messages; return messages;
} finally { } finally {
await db.close(); db.close();
} }
} }

View File

@@ -0,0 +1,128 @@
/**
* Cursor Provider Status
*
* Checks whether cursor-agent CLI is installed and whether the user
* is logged in.
*
* @module providers/cursor/status
*/
import { execFileSync, spawn } from 'child_process';
/**
* Check if cursor-agent CLI is installed.
* @returns {boolean}
*/
export function checkInstalled() {
try {
execFileSync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
error: 'Cursor CLI is not installed'
};
}
const result = await checkCursorLogin();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
function checkCursorLogin() {
return new Promise((resolve) => {
let processCompleted = false;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
if (childProcess) {
childProcess.kill();
}
resolve({
authenticated: false,
email: null,
error: 'Command timeout'
});
}
}, 5000);
let childProcess;
try {
childProcess = spawn('cursor-agent', ['status']);
} catch {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch) {
resolve({ authenticated: true, email: emailMatch[1] });
} else if (stdout.includes('Logged in')) {
resolve({ authenticated: true, email: 'Logged in' });
} else {
resolve({ authenticated: false, email: null, error: 'Not logged in' });
}
} else {
resolve({ authenticated: false, email: null, error: stderr || 'Not logged in' });
}
});
childProcess.on('error', () => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
});
});
}

View File

@@ -0,0 +1,111 @@
/**
* Gemini Provider Status
*
* Checks whether Gemini CLI is installed and whether the user
* has valid authentication credentials.
*
* @module providers/gemini/status
*/
import { execFileSync } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Gemini CLI is installed.
* Uses GEMINI_PATH env var if set, otherwise looks for 'gemini' in PATH.
* @returns {boolean}
*/
export function checkInstalled() {
const cliPath = process.env.GEMINI_PATH || 'gemini';
try {
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
error: 'Gemini CLI is not installed'
};
}
const result = await checkCredentials();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function checkCredentials() {
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth' };
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await fs.readFile(credsPath, 'utf8');
const creds = JSON.parse(content);
if (creds.access_token) {
let email = 'OAuth Session';
try {
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
if (tokenRes.ok) {
const tokenInfo = await tokenRes.json();
if (tokenInfo.email) {
email = tokenInfo.email;
}
} else if (!creds.refresh_token) {
return {
authenticated: false,
email: null,
error: 'Access token invalid and no refresh token found'
};
} else {
// Token might be expired but we have a refresh token, so CLI will refresh it
email = await getActiveAccountEmail() || email;
}
} catch {
// Network error, fallback to checking local accounts file
email = await getActiveAccountEmail() || email;
}
return { authenticated: true, email };
}
return { authenticated: false, email: null, error: 'No valid tokens found in oauth_creds' };
} catch {
return { authenticated: false, email: null, error: 'Gemini CLI not configured' };
}
}
async function getActiveAccountEmail() {
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
return accounts.active || null;
} catch {
return null;
}
}

View File

@@ -1,8 +1,9 @@
/** /**
* Provider Registry * Provider Registry
* *
* Centralizes provider adapter lookup. All code that needs a provider adapter * Centralizes provider adapter and status checker lookup. All code that needs
* should go through this registry instead of importing individual adapters directly. * a provider adapter or status checker should go through this registry instead
* of importing individual modules directly.
* *
* @module providers/registry * @module providers/registry
*/ */
@@ -12,6 +13,11 @@ import { cursorAdapter } from './cursor/adapter.js';
import { codexAdapter } from './codex/adapter.js'; import { codexAdapter } from './codex/adapter.js';
import { geminiAdapter } from './gemini/adapter.js'; import { geminiAdapter } from './gemini/adapter.js';
import * as claudeStatus from './claude/status.js';
import * as cursorStatus from './cursor/status.js';
import * as codexStatus from './codex/status.js';
import * as geminiStatus from './gemini/status.js';
/** /**
* @typedef {import('./types.js').ProviderAdapter} ProviderAdapter * @typedef {import('./types.js').ProviderAdapter} ProviderAdapter
* @typedef {import('./types.js').SessionProvider} SessionProvider * @typedef {import('./types.js').SessionProvider} SessionProvider
@@ -20,12 +26,20 @@ import { geminiAdapter } from './gemini/adapter.js';
/** @type {Map<string, ProviderAdapter>} */ /** @type {Map<string, ProviderAdapter>} */
const providers = new Map(); const providers = new Map();
/** @type {Map<string, { checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> }>} */
const statusCheckers = new Map();
// Register built-in providers // Register built-in providers
providers.set('claude', claudeAdapter); providers.set('claude', claudeAdapter);
providers.set('cursor', cursorAdapter); providers.set('cursor', cursorAdapter);
providers.set('codex', codexAdapter); providers.set('codex', codexAdapter);
providers.set('gemini', geminiAdapter); providers.set('gemini', geminiAdapter);
statusCheckers.set('claude', claudeStatus);
statusCheckers.set('cursor', cursorStatus);
statusCheckers.set('codex', codexStatus);
statusCheckers.set('gemini', geminiStatus);
/** /**
* Get a provider adapter by name. * Get a provider adapter by name.
* @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini') * @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')
@@ -35,6 +49,15 @@ export function getProvider(name) {
return providers.get(name); return providers.get(name);
} }
/**
* Get a provider status checker by name.
* @param {string} name - Provider name
* @returns {{ checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> } | undefined}
*/
export function getStatusChecker(name) {
return statusCheckers.get(name);
}
/** /**
* Get all registered provider names. * Get all registered provider names.
* @returns {string[]} * @returns {string[]}

View File

@@ -69,6 +69,19 @@
* @property {object} [tokenUsage] - Token usage data (provider-specific) * @property {object} [tokenUsage] - Token usage data (provider-specific)
*/ */
// ─── Provider Status ────────────────────────────────────────────────────────
/**
* Result of a provider status check (installation + authentication).
*
* @typedef {Object} ProviderStatus
* @property {boolean} installed - Whether the provider's CLI/SDK is available
* @property {boolean} authenticated - Whether valid credentials exist
* @property {string|null} email - User email or auth method identifier
* @property {string|null} [method] - Auth method (e.g. 'api_key', 'credentials_file')
* @property {string|null} [error] - Error message if not installed or not authenticated
*/
// ─── Provider Adapter Interface ────────────────────────────────────────────── // ─── Provider Adapter Interface ──────────────────────────────────────────────
/** /**

View File

@@ -1,434 +1,27 @@
/**
* CLI Auth Routes
*
* Thin router that delegates to per-provider status checkers
* registered in the provider registry.
*
* @module routes/cli-auth
*/
import express from 'express'; import express from 'express';
import { spawn } from 'child_process'; import { getAllProviders, getStatusChecker } from '../providers/registry.js';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
const router = express.Router(); const router = express.Router();
router.get('/claude/status', async (req, res) => { for (const provider of getAllProviders()) {
try { router.get(`/${provider}/status`, async (req, res) => {
const credentialsResult = await checkClaudeCredentials();
if (credentialsResult.authenticated) {
return res.json({
authenticated: true,
email: credentialsResult.email || 'Authenticated',
method: credentialsResult.method // 'api_key' or 'credentials_file'
});
}
return res.json({
authenticated: false,
email: null,
method: null,
error: credentialsResult.error || 'Not authenticated'
});
} catch (error) {
console.error('Error checking Claude auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
method: null,
error: error.message
});
}
});
router.get('/cursor/status', async (req, res) => {
try {
const result = await checkCursorStatus();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Cursor auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
router.get('/codex/status', async (req, res) => {
try {
const result = await checkCodexCredentials();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Codex auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
router.get('/gemini/status', async (req, res) => {
try {
const result = await checkGeminiCredentials();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Gemini auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
async function loadClaudeSettingsEnv() {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
if (settings?.env && typeof settings.env === 'object') {
return settings.env;
}
} catch (error) {
// Ignore missing or malformed settings and fall back to other auth sources.
}
return {};
}
/**
* Checks Claude authentication credentials using two methods with priority order:
*
* Priority 1: ANTHROPIC_API_KEY environment variable
* Priority 1b: ~/.claude/settings.json env values
* Priority 2: ~/.claude/.credentials.json OAuth tokens
*
* The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
* This matching behavior ensures consistency with how the SDK authenticates.
*
* References:
* - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code
* "Claude Code prioritizes environment variable API keys over authenticated subscriptions"
* - https://platform.claude.com/docs/en/agent-sdk/overview
* SDK authentication documentation
*
* @returns {Promise<Object>} Authentication status with { authenticated, email, method }
* - authenticated: boolean indicating if valid credentials exist
* - email: user email or auth method identifier
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
*/
async function checkClaudeCredentials() {
// Priority 1: Check for ANTHROPIC_API_KEY environment variable
// The SDK checks this first and uses it if present, even if OAuth tokens exist.
// When set, API calls are charged via pay-as-you-go rates instead of subscription.
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth',
method: 'api_key'
};
}
// Priority 1b: Check ~/.claude/settings.json env values.
// Claude Code can read proxy/auth values from settings.json even when the
// CloudCLI server process itself was not started with those env vars exported.
const settingsEnv = await loadClaudeSettingsEnv();
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth',
method: 'api_key'
};
}
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
return {
authenticated: true,
email: 'Configured via settings.json',
method: 'api_key'
};
}
// Priority 2: Check ~/.claude/.credentials.json for OAuth tokens
// This is the standard authentication method used by Claude CLI after running
// 'claude /login' or 'claude setup-token' commands.
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await fs.readFile(credPath, 'utf8');
const creds = JSON.parse(content);
const oauth = creds.claudeAiOauth;
if (oauth && oauth.accessToken) {
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
if (!isExpired) {
return {
authenticated: true,
email: creds.email || creds.user || null,
method: 'credentials_file'
};
}
}
return {
authenticated: false,
email: null,
method: null
};
} catch (error) {
return {
authenticated: false,
email: null,
method: null
};
}
}
function checkCursorStatus() {
return new Promise((resolve) => {
let processCompleted = false;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
if (childProcess) {
childProcess.kill();
}
resolve({
authenticated: false,
email: null,
error: 'Command timeout'
});
}
}, 5000);
let childProcess;
try { try {
childProcess = spawn('cursor-agent', ['status']); const checker = getStatusChecker(provider);
} catch (err) { res.json(await checker.checkStatus());
clearTimeout(timeout); } catch (error) {
processCompleted = true; console.error(`Error checking ${provider} status:`, error);
resolve({ res.status(500).json({ authenticated: false, error: error.message });
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
return;
} }
let stdout = '';
let stderr = '';
childProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch) {
resolve({
authenticated: true,
email: emailMatch[1],
output: stdout
});
} else if (stdout.includes('Logged in')) {
resolve({
authenticated: true,
email: 'Logged in',
output: stdout
});
} else {
resolve({
authenticated: false,
email: null,
error: 'Not logged in'
});
}
} else {
resolve({
authenticated: false,
email: null,
error: stderr || 'Not logged in'
});
}
});
childProcess.on('error', (err) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
});
}); });
} }
async function checkCodexCredentials() {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await fs.readFile(authPath, 'utf8');
const auth = JSON.parse(content);
// Tokens are nested under 'tokens' key
const tokens = auth.tokens || {};
// Check for valid tokens (id_token or access_token)
if (tokens.id_token || tokens.access_token) {
// Try to extract email from id_token JWT payload
let email = 'Authenticated';
if (tokens.id_token) {
try {
// JWT is base64url encoded: header.payload.signature
const parts = tokens.id_token.split('.');
if (parts.length >= 2) {
// Decode the payload (second part)
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
email = payload.email || payload.user || 'Authenticated';
}
} catch {
// If JWT decoding fails, use fallback
email = 'Authenticated';
}
}
return {
authenticated: true,
email
};
}
// Also check for OPENAI_API_KEY as fallback auth method
if (auth.OPENAI_API_KEY) {
return {
authenticated: true,
email: 'API Key Auth'
};
}
return {
authenticated: false,
email: null,
error: 'No valid tokens found'
};
} catch (error) {
if (error.code === 'ENOENT') {
return {
authenticated: false,
email: null,
error: 'Codex not configured'
};
}
return {
authenticated: false,
email: null,
error: error.message
};
}
}
async function checkGeminiCredentials() {
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth'
};
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await fs.readFile(credsPath, 'utf8');
const creds = JSON.parse(content);
if (creds.access_token) {
let email = 'OAuth Session';
try {
// Validate token against Google API
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
if (tokenRes.ok) {
const tokenInfo = await tokenRes.json();
if (tokenInfo.email) {
email = tokenInfo.email;
}
} else if (!creds.refresh_token) {
// Token invalid and no refresh token available
return {
authenticated: false,
email: null,
error: 'Access token invalid and no refresh token found'
};
} else {
// Token might be expired but we have a refresh token, so CLI will refresh it
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
if (accounts.active) {
email = accounts.active;
}
} catch (e) { }
}
} catch (e) {
// Network error, fallback to checking local accounts file
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
if (accounts.active) {
email = accounts.active;
}
} catch (err) { }
}
return {
authenticated: true,
email: email
};
}
return {
authenticated: false,
email: null,
error: 'No valid tokens found in oauth_creds'
};
} catch (error) {
return {
authenticated: false,
email: null,
error: 'Gemini CLI not configured'
};
}
}
export default router; export default router;

View File

@@ -1,13 +1,15 @@
import express from 'express'; import express from 'express';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url';
import os from 'os'; import os from 'os';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { parseFrontmatter } from '../utils/frontmatter.js'; import { parseFrontmatter } from '../utils/frontmatter.js';
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
const __filename = fileURLToPath(import.meta.url); const __dirname = getModuleDir(import.meta.url);
const __dirname = path.dirname(__filename); // This route reads the top-level package.json for the status command, so it needs the real
// app root even after compilation moves the route file under dist-server/server/routes.
const APP_ROOT = findAppRoot(__dirname);
const router = express.Router(); const router = express.Router();
@@ -291,7 +293,7 @@ Custom commands can be created in:
'/status': async (args, context) => { '/status': async (args, context) => {
// Read version from package.json // Read version from package.json
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json'); const packageJsonPath = path.join(APP_ROOT, 'package.json');
let version = 'unknown'; let version = 'unknown';
let packageName = 'claude-code-ui'; let packageName = 'claude-code-ui';

View File

@@ -2,9 +2,7 @@ import express from 'express';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { spawn } from 'child_process'; import Database from 'better-sqlite3';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import crypto from 'crypto'; import crypto from 'crypto';
import { CURSOR_MODELS } from '../../shared/modelConstants.js'; import { CURSOR_MODELS } from '../../shared/modelConstants.js';
import { applyCustomSessionNames } from '../database/db.js'; import { applyCustomSessionNames } from '../database/db.js';
@@ -387,16 +385,10 @@ router.get('/sessions', async (req, res) => {
} catch (_) {} } catch (_) {}
// Open SQLite database // Open SQLite database
const db = await open({ const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
// Get metadata from meta table // Get metadata from meta table
const metaRows = await db.all(` const metaRows = db.prepare('SELECT key, value FROM meta').all();
SELECT key, value FROM meta
`);
let sessionData = { let sessionData = {
id: sessionId, id: sessionId,
@@ -458,20 +450,11 @@ router.get('/sessions', async (req, res) => {
// Get message count from JSON blobs only (actual messages, not DAG structure) // Get message count from JSON blobs only (actual messages, not DAG structure)
try { try {
const blobCount = await db.get(` const blobCount = db.prepare(`SELECT COUNT(*) as count FROM blobs WHERE substr(data, 1, 1) = X'7B'`).get();
SELECT COUNT(*) as count
FROM blobs
WHERE substr(data, 1, 1) = X'7B'
`);
sessionData.messageCount = blobCount.count; sessionData.messageCount = blobCount.count;
// Get the most recent JSON blob for preview (actual message, not DAG structure) // Get the most recent JSON blob for preview (actual message, not DAG structure)
const lastBlob = await db.get(` const lastBlob = db.prepare(`SELECT data FROM blobs WHERE substr(data, 1, 1) = X'7B' ORDER BY rowid DESC LIMIT 1`).get();
SELECT data FROM blobs
WHERE substr(data, 1, 1) = X'7B'
ORDER BY rowid DESC
LIMIT 1
`);
if (lastBlob && lastBlob.data) { if (lastBlob && lastBlob.data) {
try { try {
@@ -526,7 +509,7 @@ router.get('/sessions', async (req, res) => {
console.log('Could not read blobs:', e.message); console.log('Could not read blobs:', e.message);
} }
await db.close(); db.close();
// Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime // Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime
if (!sessionData.createdAt) { if (!sessionData.createdAt) {
@@ -578,221 +561,4 @@ router.get('/sessions', async (req, res) => {
}); });
} }
}); });
// GET /api/cursor/sessions/:sessionId - Get specific Cursor session from SQLite
router.get('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
const { projectPath } = req.query;
// Calculate cwdID hash for the project path
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
// Open SQLite database
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
// Get all blobs to build the DAG structure
const allBlobs = await db.all(`
SELECT rowid, id, data FROM blobs
`);
// Build the DAG structure from parent-child relationships
const blobMap = new Map(); // id -> blob data
const parentRefs = new Map(); // blob id -> [parent blob ids]
const childRefs = new Map(); // blob id -> [child blob ids]
const jsonBlobs = []; // Clean JSON messages
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
// Check if this is a JSON blob (actual message) or protobuf (DAG structure)
if (blob.data && blob.data[0] === 0x7B) { // Starts with '{' - JSON blob
try {
const parsed = JSON.parse(blob.data.toString('utf8'));
jsonBlobs.push({ ...blob, parsed });
} catch (e) {
console.log('Failed to parse JSON blob:', blob.rowid);
}
} else if (blob.data) { // Protobuf blob - extract parent references
const parents = [];
let i = 0;
// Scan for parent references (0x0A 0x20 followed by 32-byte hash)
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i+1] === 0x20) {
const parentHash = blob.data.slice(i+2, i+34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
// Update child references
for (const parentId of parents) {
if (!childRefs.has(parentId)) {
childRefs.set(parentId, []);
}
childRefs.get(parentId).push(blob.id);
}
}
}
}
// Perform topological sort to get chronological order
const visited = new Set();
const sorted = [];
// DFS-based topological sort
function visit(nodeId) {
if (visited.has(nodeId)) return;
visited.add(nodeId);
// Visit all parents first (dependencies)
const parents = parentRefs.get(nodeId) || [];
for (const parentId of parents) {
visit(parentId);
}
// Add this node after all its parents
const blob = blobMap.get(nodeId);
if (blob) {
sorted.push(blob);
}
}
// Start with nodes that have no parents (roots)
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) {
visit(blob.id);
}
}
// Visit any remaining nodes (disconnected components)
for (const blob of allBlobs) {
visit(blob.id);
}
// Now extract JSON messages in the order they appear in the sorted DAG
const messageOrder = new Map(); // JSON blob id -> order index
let orderIndex = 0;
for (const blob of sorted) {
// Check if this blob references any JSON messages
if (blob.data && blob.data[0] !== 0x7B) { // Protobuf blob
// Look for JSON blob references
for (const jsonBlob of jsonBlobs) {
try {
const jsonIdBytes = Buffer.from(jsonBlob.id, 'hex');
if (blob.data.includes(jsonIdBytes)) {
if (!messageOrder.has(jsonBlob.id)) {
messageOrder.set(jsonBlob.id, orderIndex++);
}
}
} catch (e) {
// Skip if can't convert ID
}
}
}
}
// Sort JSON blobs by their appearance order in the DAG
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const orderA = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const orderB = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
// Fallback to rowid if not in order map
return a.rowid - b.rowid;
});
// Use sorted JSON blobs
const blobs = sortedJsonBlobs.map((blob, idx) => ({
...blob,
sequence_num: idx + 1,
original_rowid: blob.rowid
}));
// Get metadata from meta table
const metaRows = await db.all(`
SELECT key, value FROM meta
`);
// Parse metadata
let metadata = {};
for (const row of metaRows) {
if (row.value) {
try {
// Try to decode as hex-encoded JSON
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
if (hexMatch) {
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
metadata[row.key] = JSON.parse(jsonStr);
} else {
metadata[row.key] = row.value.toString();
}
} catch (e) {
metadata[row.key] = row.value.toString();
}
}
}
// Extract messages from sorted JSON blobs
const messages = [];
for (const blob of blobs) {
try {
// We already parsed JSON blobs earlier
const parsed = blob.parsed;
if (parsed) {
// Filter out ONLY system messages at the server level
// Check both direct role and nested message.role
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') {
continue; // Skip only system messages
}
messages.push({
id: blob.id,
sequence: blob.sequence_num,
rowid: blob.original_rowid,
content: parsed
});
}
} catch (e) {
// Skip blobs that cause errors
console.log(`Skipping blob ${blob.id}: ${e.message}`);
}
}
await db.close();
res.json({
success: true,
session: {
id: sessionId,
projectPath: projectPath,
messages: messages,
metadata: metadata,
cwdId: cwdId
}
});
} catch (error) {
console.error('Error reading Cursor session:', error);
res.status(500).json({
error: 'Failed to read Cursor session',
details: error.message
});
}
});
export default router; export default router;

33
server/tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"baseUrl": ".",
"paths": {
// In the backend config, "@" maps to the /server directory itself.
"@/*": ["*"]
},
// The backend is still mostly JavaScript today, so allowJs lets us add a real
// TypeScript build without forcing a large rename before the tooling is usable.
"allowJs": true,
// Keep the migration incremental: existing JS keeps building, while any new TS files
// still go through the normal TypeScript pipeline and strict checks.
"checkJs": false,
"strict": true,
"noEmitOnError": true,
// The backend build emits both /server and /shared into dist-server, so rootDir must
// stay one level above this file even though the config itself now lives in /server.
"rootDir": "..",
"outDir": "../dist-server",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["./**/*.js", "./**/*.ts", "../shared/**/*.js", "../shared/**/*.ts"],
"exclude": ["../dist", "../dist-server", "../node_modules", "../src"]
}

21
server/utils/colors.js Normal file
View File

@@ -0,0 +1,21 @@
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
ok: (text) => `${colors.green}${text}${colors.reset}`,
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
tip: (text) => `${colors.blue}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
export { colors, c };

View File

@@ -0,0 +1,37 @@
import path from 'path';
import { fileURLToPath } from 'url';
export function getModuleDir(importMetaUrl) {
return path.dirname(fileURLToPath(importMetaUrl));
}
export function findServerRoot(startDir) {
// Source files live under /server, while compiled files live under /dist-server/server.
// Walking up to the nearest "server" folder gives every backend module one stable anchor
// that works in both layouts instead of relying on fragile "../.." assumptions.
let currentDir = startDir;
while (path.basename(currentDir) !== 'server') {
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
throw new Error(`Could not resolve the backend server root from "${startDir}".`);
}
currentDir = parentDir;
}
return currentDir;
}
export function findAppRoot(startDir) {
const serverRoot = findServerRoot(startDir);
const parentOfServerRoot = path.dirname(serverRoot);
// Source files live at <app>/server, while compiled files live at <app>/dist-server/server.
// When the nearest server folder sits inside dist-server we need to hop one extra level up
// so repo-level files still resolve from the real app root instead of the build directory.
return path.basename(parentOfServerRoot) === 'dist-server'
? path.dirname(parentOfServerRoot)
: parentOfServerRoot;
}

View File

@@ -0,0 +1,71 @@
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
function stripAnsiSequences(value = '') {
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
}
function normalizeDetectedUrl(url) {
if (!url || typeof url !== 'string') return null;
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
if (!cleaned) return null;
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function extractUrlsFromText(value = '') {
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
// Handle wrapped terminal URLs split across lines by terminal width.
const wrappedMatches = [];
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
const lines = value.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
if (!startMatch) continue;
let combined = startMatch[0];
let j = i + 1;
while (j < lines.length) {
const continuation = lines[j].trim();
if (!continuation) break;
if (!continuationRegex.test(continuation)) break;
combined += continuation;
j++;
}
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
}
return Array.from(new Set([...directMatches, ...wrappedMatches]));
}
function shouldAutoOpenUrlFromOutput(value = '') {
const normalized = value.toLowerCase();
return (
normalized.includes('browser didn\'t open') ||
normalized.includes('open this url') ||
normalized.includes('continue in your browser') ||
normalized.includes('press enter to open') ||
normalized.includes('open_url:')
);
}
export {
ANSI_ESCAPE_SEQUENCE_REGEX,
TRAILING_URL_PUNCTUATION_REGEX,
stripAnsiSequences,
normalizeDetectedUrl,
extractUrlsFromText,
shouldAutoOpenUrlFromOutput
};

View File

@@ -18,9 +18,10 @@ export const CLAUDE_MODELS = {
{ value: "haiku", label: "Haiku" }, { value: "haiku", label: "Haiku" },
{ value: "opusplan", label: "Opus Plan" }, { value: "opusplan", label: "Opus Plan" },
{ value: "sonnet[1m]", label: "Sonnet [1M]" }, { value: "sonnet[1m]", label: "Sonnet [1M]" },
{ value: "opus[1m]", label: "Opus [1M]" },
], ],
DEFAULT: "sonnet", DEFAULT: "opus",
}; };
/** /**
@@ -58,6 +59,7 @@ export const CURSOR_MODELS = {
export const CODEX_MODELS = { export const CODEX_MODELS = {
OPTIONS: [ OPTIONS: [
{ value: "gpt-5.4", label: "GPT-5.4" }, { 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.3-codex", label: "GPT-5.3 Codex" },
{ value: "gpt-5.2-codex", label: "GPT-5.2 Codex" }, { value: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
{ value: "gpt-5.2", label: "GPT-5.2" }, { value: "gpt-5.2", label: "GPT-5.2" },
@@ -88,5 +90,5 @@ export const GEMINI_MODELS = {
}, },
], ],
DEFAULT: "gemini-2.5-flash", DEFAULT: "gemini-3.1-pro-preview",
}; };

View File

@@ -122,8 +122,28 @@ export default function AppContent() {
} }
}, [isConnected, selectedSession?.id, sendMessage]); }, [isConnected, selectedSession?.id, sendMessage]);
// Adjust the app container to stay above the virtual keyboard on iOS Safari.
// On Chrome for Android the layout viewport already shrinks when the keyboard opens,
// so inset-0 adjusts automatically. On iOS the layout viewport stays full-height and
// the keyboard overlays it — we use the Visual Viewport API to track keyboard height
// and apply it as a CSS variable that shifts the container's bottom edge up.
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
// Only resize matters — keyboard open/close changes vv.height.
// Do NOT listen to scroll: on iOS Safari, scrolling content changes
// vv.offsetTop which would make --keyboard-height fluctuate during
// normal scrolling, causing the container to bounce up and down.
const kb = Math.max(0, window.innerHeight - vv.height);
document.documentElement.style.setProperty('--keyboard-height', `${kb}px`);
};
vv.addEventListener('resize', update);
return () => vv.removeEventListener('resize', update);
}, []);
return ( return (
<div className="fixed inset-0 flex bg-background"> <div className="fixed inset-0 flex bg-background" style={{ bottom: 'var(--keyboard-height, 0px)' }}>
{!isMobile ? ( {!isMobile ? (
<div className="h-full flex-shrink-0 border-r border-border/50"> <div className="h-full flex-shrink-0 border-r border-border/50">
<Sidebar {...sidebarSharedProps} /> <Sidebar {...sidebarSharedProps} />

View File

@@ -19,7 +19,7 @@ import type {
PendingPermissionRequest, PendingPermissionRequest,
PermissionMode, PermissionMode,
} from '../types/types'; } from '../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import { escapeRegExp } from '../utils/chatFormatting'; import { escapeRegExp } from '../utils/chatFormatting';
import { useFileMentions } from './useFileMentions'; import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands'; import { type SlashCommand, useSlashCommands } from './useSlashCommands';
@@ -33,7 +33,7 @@ interface UseChatComposerStateArgs {
selectedProject: Project | null; selectedProject: Project | null;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
currentSessionId: string | null; currentSessionId: string | null;
provider: SessionProvider; provider: LLMProvider;
permissionMode: PermissionMode | string; permissionMode: PermissionMode | string;
cyclePermissionMode: () => void; cyclePermissionMode: () => void;
cursorModel: string; cursorModel: string;
@@ -737,7 +737,7 @@ export function useChatComposerState({
} }
// Re-run when input changes so restored drafts get the same autosize behavior as typed text. // Re-run when input changes so restored drafts get the same autosize behavior as typed text.
textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
const expanded = textareaRef.current.scrollHeight > lineHeight * 2; const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(expanded); setIsTextareaExpanded(expanded);
@@ -824,7 +824,7 @@ export function useChatComposerState({
(event: FormEvent<HTMLTextAreaElement>) => { (event: FormEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget; const target = event.currentTarget;
target.style.height = 'auto'; target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`; target.style.height = `${Math.max(22, target.scrollHeight)}px`;
setCursorPosition(target.selectionStart); setCursorPosition(target.selectionStart);
syncInputOverlayScroll(target); syncInputOverlayScroll(target);
@@ -878,30 +878,6 @@ export function useChatComposerState({
}); });
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]); }, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
const handleTranscript = useCallback((text: string) => {
if (!text.trim()) {
return;
}
setInput((previousInput) => {
const newInput = previousInput.trim() ? `${previousInput} ${text}` : text;
inputValueRef.current = newInput;
setTimeout(() => {
if (!textareaRef.current) {
return;
}
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2);
}, 0);
return newInput;
});
}, []);
const handleGrantToolPermission = useCallback( const handleGrantToolPermission = useCallback(
(suggestion: { entry: string; toolName: string }) => { (suggestion: { entry: string; toolName: string }) => {
if (!suggestion || provider !== 'claude') { if (!suggestion || provider !== 'claude') {
@@ -994,7 +970,6 @@ export function useChatComposerState({
syncInputOverlayScroll, syncInputOverlayScroll,
handleClearInput, handleClearInput,
handleAbortSession, handleAbortSession,
handleTranscript,
handlePermissionDecision, handlePermissionDecision,
handleGrantToolPermission, handleGrantToolPermission,
handleInputFocusChange, handleInputFocusChange,

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants'; import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
import type { PendingPermissionRequest, PermissionMode } from '../types/types'; import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type { ProjectSession, SessionProvider } from '../../../types/app'; import type { ProjectSession, LLMProvider } from '../../../types/app';
interface UseChatProviderStateArgs { interface UseChatProviderStateArgs {
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
@@ -11,8 +11,8 @@ interface UseChatProviderStateArgs {
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) { export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default'); const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]); const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
const [provider, setProvider] = useState<SessionProvider>(() => { const [provider, setProvider] = useState<LLMProvider>(() => {
return (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
}); });
const [cursorModel, setCursorModel] = useState<string>(() => { const [cursorModel, setCursorModel] = useState<string>(() => {
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT; return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { PendingPermissionRequest } from '../types/types'; import type { PendingPermissionRequest } from '../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
type PendingViewSession = { type PendingViewSession = {
@@ -48,7 +48,7 @@ type LatestChatMessage = {
interface UseChatRealtimeHandlersArgs { interface UseChatRealtimeHandlersArgs {
latestMessage: LatestChatMessage | null; latestMessage: LatestChatMessage | null;
provider: SessionProvider; provider: LLMProvider;
selectedProject: Project | null; selectedProject: Project | null;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
currentSessionId: string | null; currentSessionId: string | null;

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import type { ChatMessage, Provider } from '../types/types'; import type { ChatMessage, Provider } from '../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
import { normalizedToChatMessages } from './useChatMessages'; import { normalizedToChatMessages } from './useChatMessages';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
@@ -40,7 +40,7 @@ interface ScrollRestoreState {
function chatMessageToNormalized( function chatMessageToNormalized(
msg: ChatMessage, msg: ChatMessage,
sessionId: string, sessionId: string,
provider: SessionProvider, provider: LLMProvider,
): NormalizedMessage | null { ): NormalizedMessage | null {
const id = `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const id = `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const ts = msg.timestamp instanceof Date const ts = msg.timestamp instanceof Date
@@ -151,7 +151,7 @@ export function useChatSessionState({
// When a real session ID arrives and we have a pending user message, flush it to the store // When a real session ID arrives and we have a pending user message, flush it to the store
const prevActiveSessionRef = useRef<string | null>(null); const prevActiveSessionRef = useRef<string | null>(null);
if (activeSessionId && activeSessionId !== prevActiveSessionRef.current && pendingUserMessage) { if (activeSessionId && activeSessionId !== prevActiveSessionRef.current && pendingUserMessage) {
const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; const prov = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov); const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov);
if (normalized) { if (normalized) {
sessionStore.appendRealtime(activeSessionId, normalized); sessionStore.appendRealtime(activeSessionId, normalized);
@@ -189,7 +189,7 @@ export function useChatSessionState({
setPendingUserMessage(msg); setPendingUserMessage(msg);
return; return;
} }
const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; const prov = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
const normalized = chatMessageToNormalized(msg, activeSessionId, prov); const normalized = chatMessageToNormalized(msg, activeSessionId, prov);
if (normalized) { if (normalized) {
sessionStore.appendRealtime(activeSessionId, normalized); sessionStore.appendRealtime(activeSessionId, normalized);
@@ -240,7 +240,7 @@ export function useChatSessionState({
try { try {
const slot = await sessionStore.fetchMore(selectedSession.id, { const slot = await sessionStore.fetchMore(selectedSession.id, {
provider: sessionProvider as SessionProvider, provider: sessionProvider as LLMProvider,
projectName: selectedProject.name, projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE, limit: MESSAGES_PER_PAGE,
@@ -374,7 +374,7 @@ export function useChatSessionState({
// Fetch from server → store updates → chatMessages re-derives automatically // Fetch from server → store updates → chatMessages re-derives automatically
setIsLoadingSessionMessages(true); setIsLoadingSessionMessages(true);
sessionStore.fetchFromServer(selectedSession.id, { sessionStore.fetchFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as SessionProvider, provider: (selectedSession.__provider || provider) as LLMProvider,
projectName: selectedProject.name, projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE, limit: MESSAGES_PER_PAGE,
@@ -410,7 +410,7 @@ export function useChatSessionState({
// Skip store refresh during active streaming // Skip store refresh during active streaming
if (!isLoading) { if (!isLoading) {
await sessionStore.refreshFromServer(selectedSession.id, { await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as SessionProvider, provider: (selectedSession.__provider || provider) as LLMProvider,
projectName: selectedProject.name, projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
}); });
@@ -468,7 +468,7 @@ export function useChatSessionState({
try { try {
// Load all messages into the store for search navigation // Load all messages into the store for search navigation
const slot = await sessionStore.fetchFromServer(selectedSession.id, { const slot = await sessionStore.fetchFromServer(selectedSession.id, {
provider: sessionProvider as SessionProvider, provider: sessionProvider as LLMProvider,
projectName: selectedProject.name, projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null, limit: null,
@@ -655,7 +655,7 @@ export function useChatSessionState({
try { try {
const slot = await sessionStore.fetchFromServer(requestSessionId, { const slot = await sessionStore.fetchFromServer(requestSessionId, {
provider: sessionProvider as SessionProvider, provider: sessionProvider as LLMProvider,
projectName: selectedProject.name, projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null, limit: null,

View File

@@ -1,8 +1,13 @@
import React, { memo, useMemo, useCallback } from 'react'; import React, { memo, useMemo, useCallback } from 'react';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import type { SubagentChildTool } from '../types/types'; import type { SubagentChildTool } from '../types/types';
import { getToolConfig } from './configs/toolConfigs'; import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components'; import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import { PlanDisplay } from './components/PlanDisplay';
import { ToolStatusBadge } from './components/ToolStatusBadge';
import type { ToolStatus } from './components/ToolStatusBadge';
type DiffLine = { type DiffLine = {
type: string; type: string;
@@ -36,12 +41,32 @@ function getToolCategory(toolName: string): string {
if (toolName === 'Bash') return 'bash'; if (toolName === 'Bash') return 'bash';
if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo'; if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo';
if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task'; if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
if (toolName === 'Task') return 'agent'; // Subagent task if (toolName === 'Task') return 'agent';
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan'; if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
if (toolName === 'AskUserQuestion') return 'question'; if (toolName === 'AskUserQuestion') return 'question';
return 'default'; return 'default';
} }
// Exact denial messages from server/claude-sdk.js — other providers can't reliably signal denial
const CLAUDE_DENIAL_MESSAGES = [
'user denied tool use',
'tool disallowed by settings',
'permission request timed out',
'permission request cancelled',
];
function deriveToolStatus(toolResult: any): ToolStatus {
if (!toolResult) return 'running';
if (toolResult.isError) {
const content = String(toolResult.content || '').toLowerCase().trim();
if (CLAUDE_DENIAL_MESSAGES.some((msg) => content.includes(msg))) {
return 'denied';
}
return 'error';
}
return 'completed';
}
/** /**
* Main tool renderer router * Main tool renderer router
* Routes to OneLineDisplay or CollapsibleDisplay based on tool config * Routes to OneLineDisplay or CollapsibleDisplay based on tool config
@@ -73,6 +98,12 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
} }
}, [mode, toolInput, toolResult]); }, [mode, toolInput, toolResult]);
// Only derive and show status badge on input renders
const toolStatus = useMemo(
() => mode === 'input' ? deriveToolStatus(toolResult) : undefined,
[mode, toolResult],
);
const handleAction = useCallback(() => { const handleAction = useCallback(() => {
if (displayConfig?.action === 'open-file' && onFileOpen) { if (displayConfig?.action === 'open-file' && onFileOpen) {
const value = displayConfig.getValue?.(parsedData) || ''; const value = displayConfig.getValue?.(parsedData) || '';
@@ -82,9 +113,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
// Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks) // Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks)
if (isSubagentContainer && subagentState) { if (isSubagentContainer && subagentState) {
if (mode === 'result') { if (mode === 'result') return null;
return null;
}
return ( return (
<SubagentContainer <SubagentContainer
toolInput={toolInput} toolInput={toolInput}
@@ -115,6 +144,34 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
wrapText={displayConfig.wrapText} wrapText={displayConfig.wrapText}
colorScheme={displayConfig.colorScheme} colorScheme={displayConfig.colorScheme}
resultId={mode === 'input' ? `tool-result-${toolId}` : undefined} resultId={mode === 'input' ? `tool-result-${toolId}` : undefined}
status={toolStatus !== 'completed' ? toolStatus : undefined}
/>
);
}
if (displayConfig.type === 'plan') {
const title = typeof displayConfig.title === 'function'
? displayConfig.title(parsedData)
: displayConfig.title || 'Plan';
const contentProps = displayConfig.getContentProps?.(parsedData, {
selectedProject,
createDiff,
onFileOpen
}) || {};
const isStreaming = mode === 'input' && !toolResult;
return (
<PlanDisplay
title={title}
content={contentProps.content || ''}
defaultOpen={displayConfig.defaultOpen ?? autoExpandTools}
isStreaming={isStreaming}
showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput}
toolName={toolName}
toolId={toolId}
/> />
); );
} }
@@ -134,7 +191,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
onFileOpen onFileOpen
}) || {}; }) || {};
// Build the content component based on contentType
let contentComponent: React.ReactNode = null; let contentComponent: React.ReactNode = null;
switch (displayConfig.contentType) { switch (displayConfig.contentType) {
@@ -211,7 +267,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
} }
} }
// For edit tools, make the title (filename) clickable to open the file
const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen
? () => onFileOpen(contentProps.filePath, { ? () => onFileOpen(contentProps.filePath, {
old_string: contentProps.oldContent, old_string: contentProps.oldContent,
@@ -219,6 +274,8 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
}) })
: undefined; : undefined;
const badgeElement = toolStatus && toolStatus !== 'completed' ? <ToolStatusBadge status={toolStatus} /> : undefined;
return ( return (
<CollapsibleDisplay <CollapsibleDisplay
toolName={toolName} toolName={toolName}
@@ -226,6 +283,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
title={title} title={title}
defaultOpen={defaultOpen} defaultOpen={defaultOpen}
onTitleClick={handleTitleClick} onTitleClick={handleTitleClick}
badge={badgeElement}
showRawParameters={mode === 'input' && showRawParameters} showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput} rawContent={rawToolInput}
toolCategory={getToolCategory(toolName)} toolCategory={getToolCategory(toolName)}

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
import { CollapsibleSection } from './CollapsibleSection'; import { CollapsibleSection } from './CollapsibleSection';
interface CollapsibleDisplayProps { interface CollapsibleDisplayProps {
@@ -7,6 +8,7 @@ interface CollapsibleDisplayProps {
title: string; title: string;
defaultOpen?: boolean; defaultOpen?: boolean;
action?: React.ReactNode; action?: React.ReactNode;
badge?: React.ReactNode;
onTitleClick?: () => void; onTitleClick?: () => void;
children: React.ReactNode; children: React.ReactNode;
showRawParameters?: boolean; showRawParameters?: boolean;
@@ -17,14 +19,14 @@ interface CollapsibleDisplayProps {
const borderColorMap: Record<string, string> = { const borderColorMap: Record<string, string> = {
edit: 'border-l-amber-500 dark:border-l-amber-400', edit: 'border-l-amber-500 dark:border-l-amber-400',
search: 'border-l-gray-400 dark:border-l-gray-500', search: 'border-l-muted-foreground/40',
bash: 'border-l-green-500 dark:border-l-green-400', bash: 'border-l-green-500 dark:border-l-green-400',
todo: 'border-l-violet-500 dark:border-l-violet-400', todo: 'border-l-violet-500 dark:border-l-violet-400',
task: 'border-l-violet-500 dark:border-l-violet-400', task: 'border-l-violet-500 dark:border-l-violet-400',
agent: 'border-l-purple-500 dark:border-l-purple-400', agent: 'border-l-purple-500 dark:border-l-purple-400',
plan: 'border-l-indigo-500 dark:border-l-indigo-400', plan: 'border-l-indigo-500 dark:border-l-indigo-400',
question: 'border-l-blue-500 dark:border-l-blue-400', question: 'border-l-blue-500 dark:border-l-blue-400',
default: 'border-l-gray-300 dark:border-l-gray-600', default: 'border-l-border',
}; };
export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
@@ -32,14 +34,14 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
title, title,
defaultOpen = false, defaultOpen = false,
action, action,
badge,
onTitleClick, onTitleClick,
children, children,
showRawParameters = false, showRawParameters = false,
rawContent, rawContent,
className = '', className = '',
toolCategory toolCategory,
}) => { }) => {
// Fall back to default styling for unknown/new categories so className never includes "undefined".
const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default; const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default;
return ( return (
@@ -49,15 +51,16 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
toolName={toolName} toolName={toolName}
open={defaultOpen} open={defaultOpen}
action={action} action={action}
badge={badge}
onTitleClick={onTitleClick} onTitleClick={onTitleClick}
> >
{children} {children}
{showRawParameters && rawContent && ( {showRawParameters && rawContent && (
<details className="group/raw relative mt-2"> <Collapsible className="mt-2">
<summary className="flex cursor-pointer items-center gap-1.5 py-0.5 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"> <CollapsibleTrigger className="flex items-center gap-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground">
<svg <svg
className="h-2.5 w-2.5 transition-transform duration-150 group-open/raw:rotate-90" className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 data-[state=open]:rotate-90"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -65,11 +68,13 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
raw params raw params
</summary> </CollapsibleTrigger>
<pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-gray-200/40 bg-gray-50 p-2 font-mono text-[11px] text-gray-600 dark:border-gray-700/40 dark:bg-gray-900/50 dark:text-gray-400"> <CollapsibleContent>
{rawContent} <pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-border/40 bg-muted p-2 font-mono text-[11px] text-muted-foreground">
</pre> {rawContent}
</details> </pre>
</CollapsibleContent>
</Collapsible>
)} )}
</CollapsibleSection> </CollapsibleSection>
</div> </div>

View File

@@ -1,10 +1,13 @@
import React from 'react'; import React from 'react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils';
interface CollapsibleSectionProps { interface CollapsibleSectionProps {
title: string; title: string;
toolName?: string; toolName?: string;
open?: boolean; open?: boolean;
action?: React.ReactNode; action?: React.ReactNode;
badge?: React.ReactNode;
onTitleClick?: () => void; onTitleClick?: () => void;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
@@ -18,44 +21,68 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
toolName, toolName,
open = false, open = false,
action, action,
badge,
onTitleClick, onTitleClick,
children, children,
className = '' className = '',
}) => { }) => {
return ( return (
<details className={`group/details relative ${className}`} open={open}> <Collapsible defaultOpen={open} className={cn('group/section', className)}>
<summary className="flex cursor-pointer select-none items-center gap-1.5 py-0.5 text-xs group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:-mx-1 group-open/details:bg-background group-open/details:px-1"> {/* When there's a clickable title (Edit/Write), only the chevron toggles collapse */}
<svg {onTitleClick ? (
className="h-3 w-3 flex-shrink-0 text-gray-400 transition-transform duration-150 group-open/details:rotate-90 dark:text-gray-500" <div className="flex cursor-default select-none items-center gap-1.5 py-0.5 text-xs group-data-[state=open]/section:sticky group-data-[state=open]/section:top-0 group-data-[state=open]/section:z-10 group-data-[state=open]/section:-mx-1 group-data-[state=open]/section:bg-background group-data-[state=open]/section:px-1">
fill="none" <CollapsibleTrigger className="flex flex-shrink-0 items-center p-0.5 text-muted-foreground hover:text-foreground">
stroke="currentColor" <svg
viewBox="0 0 24 24" className="h-3 w-3 transition-transform duration-150 group-data-[state=open]/section:rotate-90"
> fill="none"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> stroke="currentColor"
</svg> viewBox="0 0 24 24"
{toolName && ( >
<span className="flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">{toolName}</span> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
)} </svg>
{toolName && ( </CollapsibleTrigger>
<span className="flex-shrink-0 text-[10px] text-gray-300 dark:text-gray-600">/</span> {toolName && (
)} <span className="flex-shrink-0 font-medium text-muted-foreground">{toolName}</span>
{onTitleClick ? ( )}
{toolName && (
<span className="flex-shrink-0 text-[10px] text-muted-foreground/40">/</span>
)}
<button <button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }} onClick={onTitleClick}
className="flex-1 truncate text-left font-mono text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300" className="flex-1 truncate text-left font-mono text-primary transition-colors hover:text-primary/80 hover:underline"
> >
{title} {title}
</button> </button>
) : ( {badge && <span className="ml-auto flex-shrink-0">{badge}</span>}
<span className="flex-1 truncate text-gray-600 dark:text-gray-400"> {action && <span className="ml-1 flex-shrink-0">{action}</span>}
{title} </div>
</span> ) : (
)} <CollapsibleTrigger className="flex w-full select-none items-center gap-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground group-data-[state=open]/section:sticky group-data-[state=open]/section:top-0 group-data-[state=open]/section:z-10 group-data-[state=open]/section:-mx-1 group-data-[state=open]/section:bg-background group-data-[state=open]/section:px-1">
{action && <span className="ml-1 flex-shrink-0">{action}</span>} <svg
</summary> className="h-3 w-3 flex-shrink-0 transition-transform duration-150 group-data-[state=open]/section:rotate-90"
<div className="mt-1.5 pl-[18px]"> fill="none"
{children} stroke="currentColor"
</div> viewBox="0 0 24 24"
</details> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{toolName && (
<span className="flex-shrink-0 font-medium">{toolName}</span>
)}
{toolName && (
<span className="flex-shrink-0 text-[10px] text-muted-foreground/40">/</span>
)}
<span className="flex-1 truncate text-left">{title}</span>
{badge && <span className="ml-auto flex-shrink-0">{badge}</span>}
{action && <span className="ml-1 flex-shrink-0">{action}</span>}
</CollapsibleTrigger>
)}
<CollapsibleContent>
<div className="mt-1.5 pl-[18px]">
{children}
</div>
</CollapsibleContent>
</Collapsible>
); );
}; };

View File

@@ -1,114 +1,21 @@
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { CheckCircle2, Circle, Clock, type LucideIcon } from 'lucide-react'; import { Queue, QueueItem, QueueItemIndicator, QueueItemContent } from '../../../../../shared/view/ui';
import { Badge } from '../../../../../shared/view/ui'; import type { QueueItemStatus } from '../../../../../shared/view/ui';
type TodoStatus = 'completed' | 'in_progress' | 'pending';
type TodoPriority = 'high' | 'medium' | 'low';
export type TodoItem = { export type TodoItem = {
id?: string; id?: string;
content: string; content: string;
status: string; status: string;
priority?: string; priority?: string;
activeForm?: string;
}; };
type NormalizedTodoItem = { const normalizeStatus = (status: string): QueueItemStatus => {
id?: string; if (status === 'completed') return 'completed';
content: string; if (status === 'in_progress') return 'in_progress';
status: TodoStatus;
priority: TodoPriority;
};
type StatusConfig = {
icon: LucideIcon;
iconClassName: string;
badgeClassName: string;
textClassName: string;
};
// Centralized visual config keeps rendering logic compact and easier to scan.
const STATUS_CONFIG: Record<TodoStatus, StatusConfig> = {
completed: {
icon: CheckCircle2,
iconClassName: 'w-3.5 h-3.5 text-green-500 dark:text-green-400',
badgeClassName:
'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800',
textClassName: 'line-through text-gray-500 dark:text-gray-400',
},
in_progress: {
icon: Clock,
iconClassName: 'w-3.5 h-3.5 text-blue-500 dark:text-blue-400',
badgeClassName:
'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800',
textClassName: 'text-gray-900 dark:text-gray-100',
},
pending: {
icon: Circle,
iconClassName: 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500',
badgeClassName:
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
textClassName: 'text-gray-900 dark:text-gray-100',
},
};
const PRIORITY_BADGE_CLASS: Record<TodoPriority, string> = {
high: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800',
medium:
'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800',
low: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
};
// Incoming tool payloads can vary; normalize to supported UI states.
const normalizeStatus = (status: string): TodoStatus => {
if (status === 'completed' || status === 'in_progress') {
return status;
}
return 'pending'; return 'pending';
}; };
const normalizePriority = (priority?: string): TodoPriority => {
if (priority === 'high' || priority === 'medium') {
return priority;
}
return 'low';
};
const TodoRow = memo(
({ todo }: { todo: NormalizedTodoItem }) => {
const statusConfig = STATUS_CONFIG[todo.status];
const StatusIcon = statusConfig.icon;
return (
<div className="flex items-start gap-2 rounded border border-gray-200 bg-white p-2 transition-colors dark:border-gray-700 dark:bg-gray-800">
<div className="mt-0.5 flex-shrink-0">
<StatusIcon className={statusConfig.iconClassName} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-start justify-between gap-2">
<p className={`text-xs font-medium ${statusConfig.textClassName}`}>
{todo.content}
</p>
<div className="flex flex-shrink-0 gap-1">
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${PRIORITY_BADGE_CLASS[todo.priority]}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${statusConfig.badgeClassName}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
);
}
);
const TodoList = memo( const TodoList = memo(
({ ({
todos, todos,
@@ -117,36 +24,33 @@ const TodoList = memo(
todos: TodoItem[]; todos: TodoItem[];
isResult?: boolean; isResult?: boolean;
}) => { }) => {
// Memoize normalization to avoid recomputing list metadata on every render. const normalized = useMemo(
const normalizedTodos = useMemo<NormalizedTodoItem[]>( () => todos.map((todo) => ({ ...todo, queueStatus: normalizeStatus(todo.status) })),
() => [todos],
todos.map((todo) => ({
id: todo.id,
content: todo.content,
status: normalizeStatus(todo.status),
priority: normalizePriority(todo.priority),
})),
[todos]
); );
if (normalizedTodos.length === 0) { if (normalized.length === 0) return null;
return null;
}
return ( return (
<div className="space-y-1.5"> <div>
{isResult && ( {isResult && (
<div className="mb-1.5 text-xs font-medium text-gray-600 dark:text-gray-400"> <div className="mb-1.5 text-xs font-medium text-muted-foreground">
Todo List ({normalizedTodos.length}{' '} Todo List ({normalized.length} {normalized.length === 1 ? 'item' : 'items'})
{normalizedTodos.length === 1 ? 'item' : 'items'})
</div> </div>
)} )}
{normalizedTodos.map((todo, index) => ( <Queue>
<TodoRow key={todo.id ?? `${todo.content}-${index}`} todo={todo} /> {normalized.map((todo, index) => (
))} <QueueItem key={todo.id ?? `${todo.content}-${index}`} status={todo.queueStatus}>
<QueueItemIndicator />
<QueueItemContent>{todo.content}</QueueItemContent>
</QueueItem>
))}
</Queue>
</div> </div>
); );
} },
); );
TodoList.displayName = 'TodoList';
export default TodoList; export default TodoList;

View File

@@ -1,5 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { copyTextToClipboard } from '../../../../utils/clipboard'; import { copyTextToClipboard } from '../../../../utils/clipboard';
import { ToolStatusBadge } from './ToolStatusBadge';
import type { ToolStatus } from './ToolStatusBadge';
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none'; type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
@@ -23,6 +25,7 @@ interface OneLineDisplayProps {
resultId?: string; resultId?: string;
toolResult?: any; toolResult?: any;
toolId?: string; toolId?: string;
status?: ToolStatus;
} }
/** /**
@@ -40,14 +43,15 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
style, style,
wrapText = false, wrapText = false,
colorScheme = { colorScheme = {
primary: 'text-gray-700 dark:text-gray-300', primary: 'text-foreground',
secondary: 'text-gray-500 dark:text-gray-400', secondary: 'text-muted-foreground',
background: '', background: '',
border: 'border-gray-300 dark:border-gray-600', border: 'border-border',
icon: 'text-gray-500 dark:text-gray-400' icon: 'text-muted-foreground',
}, },
toolResult, toolResult,
toolId toolId,
status,
}) => { }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const isTerminal = style === 'terminal'; const isTerminal = style === 'terminal';
@@ -55,9 +59,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
const handleAction = async () => { const handleAction = async () => {
if (action === 'copy' && value) { if (action === 'copy' && value) {
const didCopy = await copyTextToClipboard(value); const didCopy = await copyTextToClipboard(value);
if (!didCopy) { if (!didCopy) return;
return;
}
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} else if (onAction) { } else if (onAction) {
@@ -68,7 +70,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
const renderCopyButton = () => ( const renderCopyButton = () => (
<button <button
onClick={handleAction} onClick={handleAction}
className="ml-1 flex-shrink-0 text-gray-400 opacity-0 transition-all hover:text-gray-600 group-hover:opacity-100 dark:hover:text-gray-200" className="ml-1 flex-shrink-0 text-muted-foreground/40 opacity-0 transition-all hover:text-muted-foreground group-hover:opacity-100"
title="Copy to clipboard" title="Copy to clipboard"
aria-label="Copy to clipboard" aria-label="Copy to clipboard"
> >
@@ -84,7 +86,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
</button> </button>
); );
// Terminal style: dark pill only around the command // Terminal style: dark pill around the command
if (isTerminal) { if (isTerminal) {
return ( return (
<div className="group my-1"> <div className="group my-1">
@@ -100,12 +102,13 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
<span className="select-none text-green-600 dark:text-green-500">$ </span>{value} <span className="select-none text-green-600 dark:text-green-500">$ </span>{value}
</code> </code>
</div> </div>
{status && <ToolStatusBadge status={status} className="mt-0.5" />}
{action === 'copy' && renderCopyButton()} {action === 'copy' && renderCopyButton()}
</div> </div>
</div> </div>
{secondary && ( {secondary && (
<div className="ml-7 mt-1"> <div className="ml-7 mt-1">
<span className="text-[11px] italic text-gray-400 dark:text-gray-500"> <span className="text-[11px] italic text-muted-foreground/60">
{secondary} {secondary}
</span> </span>
</div> </div>
@@ -114,20 +117,21 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
); );
} }
// File open style - show filename only, full path on hover // File open style
if (action === 'open-file') { if (action === 'open-file') {
const displayName = value.split('/').pop() || value; const displayName = value.split('/').pop() || value;
return ( return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}> <div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span> <span className="text-[10px] text-muted-foreground/40">/</span>
<button <button
onClick={handleAction} onClick={handleAction}
className="truncate font-mono text-xs text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300" className="truncate font-mono text-xs text-primary transition-colors hover:text-primary/80 hover:underline"
title={value} title={value}
> >
{displayName} {displayName}
</button> </button>
{status && <ToolStatusBadge status={status} className="ml-auto" />}
</div> </div>
); );
} }
@@ -136,20 +140,21 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
if (action === 'jump-to-results') { if (action === 'jump-to-results') {
return ( return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}> <div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span> <span className="text-[10px] text-muted-foreground/40">/</span>
<span className={`min-w-0 flex-1 truncate font-mono text-xs ${colorScheme.primary}`}> <span className={`min-w-0 flex-1 truncate font-mono text-xs ${colorScheme.primary}`}>
{value} {value}
</span> </span>
{secondary && ( {secondary && (
<span className="flex-shrink-0 text-[11px] italic text-gray-400 dark:text-gray-500"> <span className="flex-shrink-0 text-[11px] italic text-muted-foreground/60">
{secondary} {secondary}
</span> </span>
)} )}
{status && <ToolStatusBadge status={status} />}
{toolResult && ( {toolResult && (
<a <a
href={`#tool-result-${toolId}`} href={`#tool-result-${toolId}`}
className="flex flex-shrink-0 items-center gap-0.5 text-[11px] text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" className="flex flex-shrink-0 items-center gap-0.5 text-[11px] text-primary transition-colors hover:text-primary/80"
> >
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@@ -167,10 +172,10 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
<span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span> <span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span>
)} )}
{!icon && (label || toolName) && ( {!icon && (label || toolName) && (
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
)} )}
{(icon || label || toolName) && ( {(icon || label || toolName) && (
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span> <span className="text-[10px] text-muted-foreground/40">/</span>
)} )}
<span className={`font-mono text-xs ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} min-w-0 flex-1 ${colorScheme.primary}`}> <span className={`font-mono text-xs ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} min-w-0 flex-1 ${colorScheme.primary}`}>
{value} {value}
@@ -180,6 +185,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
{secondary} {secondary}
</span> </span>
)} )}
{status && <ToolStatusBadge status={status} />}
{action === 'copy' && renderCopyButton()} {action === 'copy' && renderCopyButton()}
</div> </div>
); );

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { ChevronsUpDown, FileText } from 'lucide-react';
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
Button,
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
Shimmer,
} from '../../../../shared/view/ui';
import { usePermission } from '../../../../contexts/PermissionContext';
import { MarkdownContent } from './ContentRenderers';
interface PlanDisplayProps {
title: string;
content: string;
defaultOpen?: boolean;
isStreaming?: boolean;
showRawParameters?: boolean;
rawContent?: string;
toolName: string;
toolId?: string;
}
export const PlanDisplay: React.FC<PlanDisplayProps> = ({
title,
content,
defaultOpen = false,
isStreaming = false,
showRawParameters = false,
rawContent,
toolName: _toolName,
}) => {
const permissionCtx = usePermission();
const pendingRequest = permissionCtx?.pendingPermissionRequests.find(
(r) => r.toolName === 'ExitPlanMode' || r.toolName === 'exit_plan_mode'
);
const handleBuild = () => {
if (pendingRequest && permissionCtx) {
permissionCtx.handlePermissionDecision(pendingRequest.requestId, { allow: true });
}
};
const handleRevise = () => {
if (pendingRequest && permissionCtx) {
permissionCtx.handlePermissionDecision(pendingRequest.requestId, {
allow: false,
message: 'User asked to revise the plan',
});
}
};
return (
<Collapsible defaultOpen={defaultOpen}>
<Card className="my-1 flex flex-col shadow-none">
{/* Header — always visible */}
<CardHeader className="flex flex-row items-start justify-between space-y-0 px-4 pb-0 pt-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<CardTitle className="text-sm font-semibold">
{isStreaming ? <Shimmer>{title}</Shimmer> : title}
</CardTitle>
</div>
<CollapsibleTrigger className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">Toggle plan</span>
</CollapsibleTrigger>
</CardHeader>
{/* Collapsible content */}
<CollapsibleContent>
<CardContent className="px-4 pb-4 pt-3">
{content ? (
<MarkdownContent
content={content}
className="prose prose-sm max-w-none dark:prose-invert"
/>
) : isStreaming ? (
<div className="py-2">
<Shimmer>Generating plan...</Shimmer>
</div>
) : null}
{showRawParameters && rawContent && (
<Collapsible className="mt-3">
<CollapsibleTrigger className="flex items-center gap-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground">
<svg
className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 data-[state=open]:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
raw params
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-border/40 bg-muted p-2 font-mono text-[11px] text-muted-foreground">
{rawContent}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</CollapsibleContent>
{/* Footer — always visible when permission is pending */}
{pendingRequest && (
<CardFooter className="justify-end gap-2 border-t border-border/40 px-4 pb-3 pt-3">
<Button
variant="ghost"
size="sm"
onClick={handleRevise}
className="text-muted-foreground"
>
Revise
</Button>
<Button size="sm" onClick={handleBuild}>
Build{' '}
<kbd className="ml-1 rounded bg-primary-foreground/20 px-1 py-0.5 font-mono text-[10px]">
</kbd>
</Button>
</CardFooter>
)}
</Card>
</Collapsible>
);
};

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import type { SubagentChildTool } from '../../types/types'; import type { SubagentChildTool } from '../../types/types';
import { CollapsibleSection } from './CollapsibleSection'; import { CollapsibleSection } from './CollapsibleSection';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
interface SubagentContainerProps { interface SubagentContainerProps {
toolInput: unknown; toolInput: unknown;
@@ -65,21 +66,21 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
> >
{/* Prompt/request to the subagent */} {/* Prompt/request to the subagent */}
{prompt && ( {prompt && (
<div className="mb-2 line-clamp-4 whitespace-pre-wrap break-words text-xs text-gray-600 dark:text-gray-400"> <div className="mb-2 line-clamp-4 whitespace-pre-wrap break-words text-xs text-muted-foreground">
{prompt} {prompt}
</div> </div>
)} )}
{/* Current tool indicator (while running) */} {/* Current tool indicator (while running) */}
{currentTool && !isComplete && ( {currentTool && !isComplete && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-purple-500 dark:bg-purple-400" /> <span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-purple-500 dark:bg-purple-400" />
<span className="text-gray-400 dark:text-gray-500">Currently:</span> <span className="text-muted-foreground/60">Currently:</span>
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span> <span className="font-medium text-foreground">{currentTool.toolName}</span>
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && ( {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
<> <>
<span className="text-gray-300 dark:text-gray-600">/</span> <span className="text-muted-foreground/40">/</span>
<span className="truncate font-mono text-gray-500 dark:text-gray-400"> <span className="truncate font-mono text-muted-foreground">
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)} {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
</span> </span>
</> </>
@@ -99,10 +100,10 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
{/* Tool history (collapsed) */} {/* Tool history (collapsed) */}
{childTools.length > 0 && ( {childTools.length > 0 && (
<details className="group/history mt-2"> <Collapsible className="mt-2">
<summary className="flex cursor-pointer items-center gap-1 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"> <CollapsibleTrigger className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
<svg <svg
className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 group-open/history:rotate-90" className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 data-[state=open]:rotate-90"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -110,29 +111,31 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
<span>View tool history ({childTools.length})</span> <span>View tool history ({childTools.length})</span>
</summary> </CollapsibleTrigger>
<div className="mt-1 space-y-0.5 border-l border-gray-200 pl-3 dark:border-gray-700"> <CollapsibleContent>
{childTools.map((child, index) => ( <div className="mt-1 space-y-0.5 border-l border-border pl-3">
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400"> {childTools.map((child, index) => (
<span className="w-4 flex-shrink-0 text-right text-gray-400 dark:text-gray-500">{index + 1}.</span> <div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="font-medium">{child.toolName}</span> <span className="w-4 flex-shrink-0 text-right text-muted-foreground/60">{index + 1}.</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && ( <span className="font-medium text-foreground">{child.toolName}</span>
<span className="truncate font-mono text-gray-400 dark:text-gray-500"> {getCompactToolDisplay(child.toolName, child.toolInput) && (
{getCompactToolDisplay(child.toolName, child.toolInput)} <span className="truncate font-mono text-muted-foreground/70">
</span> {getCompactToolDisplay(child.toolName, child.toolInput)}
)} </span>
{child.toolResult?.isError && ( )}
<span className="flex-shrink-0 text-red-500">(error)</span> {child.toolResult?.isError && (
)} <span className="flex-shrink-0 text-red-500">(error)</span>
</div> )}
))} </div>
</div> ))}
</details> </div>
</CollapsibleContent>
</Collapsible>
)} )}
{/* Final result */} {/* Final result */}
{isComplete && toolResult && ( {isComplete && toolResult && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400"> <div className="mt-2 text-xs text-muted-foreground">
{(() => { {(() => {
let content = toolResult.content; let content = toolResult.content;

View File

@@ -0,0 +1,42 @@
import { cn } from '../../../../lib/utils';
export type ToolStatus = 'running' | 'completed' | 'error' | 'denied';
const STATUS_CONFIG: Record<ToolStatus, { label: string; className: string }> = {
running: {
label: 'Running',
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
},
completed: {
label: 'Completed',
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
},
error: {
label: 'Error',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
},
denied: {
label: 'Denied',
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
},
};
interface ToolStatusBadgeProps {
status: ToolStatus;
className?: string;
}
export function ToolStatusBadge({ status, className }: ToolStatusBadgeProps) {
const config = STATUS_CONFIG[status];
return (
<span
className={cn(
'inline-flex items-center rounded px-1.5 py-px text-[10px] font-medium',
config.className,
className,
)}
>
{config.label}
</span>
);
}

View File

@@ -5,3 +5,5 @@ export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer'; export { SubagentContainer } from './SubagentContainer';
export * from './ContentRenderers'; export * from './ContentRenderers';
export * from './InteractiveRenderers'; export * from './InteractiveRenderers';
export { ToolStatusBadge } from './ToolStatusBadge';
export type { ToolStatus } from './ToolStatusBadge';

View File

@@ -5,7 +5,7 @@
export interface ToolDisplayConfig { export interface ToolDisplayConfig {
input: { input: {
type: 'one-line' | 'collapsible' | 'hidden'; type: 'one-line' | 'collapsible' | 'plan' | 'hidden';
// One-line config // One-line config
icon?: string; icon?: string;
label?: string; label?: string;
@@ -31,7 +31,7 @@ export interface ToolDisplayConfig {
result?: { result?: {
hidden?: boolean; hidden?: boolean;
hideOnSuccess?: boolean; hideOnSuccess?: boolean;
type?: 'one-line' | 'collapsible' | 'special'; type?: 'one-line' | 'collapsible' | 'plan' | 'special';
title?: string | ((result: any) => string); title?: string | ((result: any) => string);
defaultOpen?: boolean; defaultOpen?: boolean;
// Special result handlers // Special result handlers
@@ -494,7 +494,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
exit_plan_mode: { exit_plan_mode: {
input: { input: {
type: 'collapsible', type: 'plan',
title: 'Implementation plan', title: 'Implementation plan',
defaultOpen: true, defaultOpen: true,
contentType: 'markdown', contentType: 'markdown',
@@ -503,29 +503,14 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
}) })
}, },
result: { result: {
type: 'collapsible', hidden: true
contentType: 'markdown',
getContentProps: (result) => {
try {
let parsed = result.content;
if (typeof parsed === 'string') {
parsed = JSON.parse(parsed);
}
return {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
};
} catch (e) {
console.warn('Failed to parse plan content:', e);
return { content: '' };
}
}
} }
}, },
// Also register as ExitPlanMode (the actual tool name used by Claude) // Also register as ExitPlanMode (the actual tool name used by Claude)
ExitPlanMode: { ExitPlanMode: {
input: { input: {
type: 'collapsible', type: 'plan',
title: 'Implementation plan', title: 'Implementation plan',
defaultOpen: true, defaultOpen: true,
contentType: 'markdown', contentType: 'markdown',
@@ -534,22 +519,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
}) })
}, },
result: { result: {
type: 'collapsible', hidden: true
contentType: 'markdown',
getContentProps: (result) => {
try {
let parsed = result.content;
if (typeof parsed === 'string') {
parsed = JSON.parse(parsed);
}
return {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
};
} catch (e) {
console.warn('Failed to parse plan content:', e);
return { content: '' };
}
}
} }
}, },

View File

@@ -1,6 +1,6 @@
import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
export type Provider = SessionProvider; export type Provider = LLMProvider;
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';

View File

@@ -1,14 +1,17 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import PermissionContext from '../../../contexts/PermissionContext';
import { QuickSettingsPanel } from '../../quick-settings-panel'; import { QuickSettingsPanel } from '../../quick-settings-panel';
import type { ChatInterfaceProps, Provider } from '../types/types'; import type { ChatInterfaceProps, Provider } from '../types/types';
import type { SessionProvider } from '../../../types/app'; import type { LLMProvider } from '../../../types/app';
import { useChatProviderState } from '../hooks/useChatProviderState'; import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatSessionState } from '../hooks/useChatSessionState'; import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers'; import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/useChatComposerState'; import { useChatComposerState } from '../hooks/useChatComposerState';
import { useSessionStore } from '../../../stores/useSessionStore'; import { useSessionStore } from '../../../stores/useSessionStore';
import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer'; import ChatComposer from './subcomponents/ChatComposer';
@@ -165,7 +168,6 @@ function ChatInterface({
syncInputOverlayScroll, syncInputOverlayScroll,
handleClearInput, handleClearInput,
handleAbortSession, handleAbortSession,
handleTranscript,
handlePermissionDecision, handlePermissionDecision,
handleGrantToolPermission, handleGrantToolPermission,
handleInputFocusChange, handleInputFocusChange,
@@ -207,9 +209,9 @@ function ChatInterface({
// so missed streaming events are shown. Also reset isLoading. // so missed streaming events are shown. Also reset isLoading.
const handleWebSocketReconnect = useCallback(async () => { const handleWebSocketReconnect = useCallback(async () => {
if (!selectedProject || !selectedSession) return; if (!selectedProject || !selectedSession) return;
const providerVal = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
await sessionStore.refreshFromServer(selectedSession.id, { await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || providerVal) as SessionProvider, provider: (selectedSession.__provider || providerVal) as LLMProvider,
projectName: selectedProject.name, projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
}); });
@@ -268,6 +270,11 @@ function ChatInterface({
}; };
}, [resetStreamingState]); }, [resetStreamingState]);
const permissionContextValue = useMemo(() => ({
pendingPermissionRequests,
handlePermissionDecision,
}), [pendingPermissionRequests, handlePermissionDecision]);
if (!selectedProject) { if (!selectedProject) {
const selectedProviderLabel = const selectedProviderLabel =
provider === 'cursor' provider === 'cursor'
@@ -293,7 +300,7 @@ function ChatInterface({
} }
return ( return (
<> <PermissionContext.Provider value={permissionContextValue}>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<ChatMessagesPane <ChatMessagesPane
scrollContainerRef={scrollContainerRef} scrollContainerRef={scrollContainerRef}
@@ -394,7 +401,6 @@ function ChatInterface({
onTextareaScrollSync={syncInputOverlayScroll} onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput} onTextareaInput={handleTextareaInput}
onInputFocusChange={handleInputFocusChange} onInputFocusChange={handleInputFocusChange}
isInputFocused={isInputFocused}
placeholder={t('input.placeholder', { placeholder={t('input.placeholder', {
provider: provider:
provider === 'cursor' provider === 'cursor'
@@ -407,12 +413,11 @@ function ChatInterface({
})} })}
isTextareaExpanded={isTextareaExpanded} isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter} sendByCtrlEnter={sendByCtrlEnter}
onTranscript={handleTranscript}
/> />
</div> </div>
<QuickSettingsPanel /> <QuickSettingsPanel />
</> </PermissionContext.Provider>
); );
} }

View File

@@ -11,13 +11,24 @@ import type {
SetStateAction, SetStateAction,
TouchEvent, TouchEvent,
} from 'react'; } from 'react';
import MicButton from '../../../mic-button/view/MicButton'; import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types'; import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
import CommandMenu from './CommandMenu'; import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus'; import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment'; import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner'; import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls'; import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie';
import {
PromptInput,
PromptInputHeader,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
PromptInputButton,
PromptInputSubmit,
} from '../../../../shared/view/ui';
interface MentionableFile { interface MentionableFile {
name: string; name: string;
@@ -87,11 +98,9 @@ interface ChatComposerProps {
onTextareaScrollSync: (target: HTMLTextAreaElement) => void; onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void; onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
onInputFocusChange?: (focused: boolean) => void; onInputFocusChange?: (focused: boolean) => void;
isInputFocused?: boolean;
placeholder: string; placeholder: string;
isTextareaExpanded: boolean; isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean; sendByCtrlEnter?: boolean;
onTranscript: (text: string) => void;
} }
export default function ChatComposer({ export default function ChatComposer({
@@ -144,11 +153,9 @@ export default function ChatComposer({
onTextareaScrollSync, onTextareaScrollSync,
onTextareaInput, onTextareaInput,
onInputFocusChange, onInputFocusChange,
isInputFocused,
placeholder, placeholder,
isTextareaExpanded, isTextareaExpanded,
sendByCtrlEnter, sendByCtrlEnter,
onTranscript,
}: ChatComposerProps) { }: ChatComposerProps) {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const textareaRect = textareaRef.current?.getBoundingClientRect(); const textareaRect = textareaRef.current?.getBoundingClientRect();
@@ -163,81 +170,43 @@ export default function ChatComposer({
(r) => r.toolName === 'AskUserQuestion' (r) => r.toolName === 'AskUserQuestion'
); );
// On mobile, when input is focused, float the input box at the bottom // Hide the thinking/status bar while any permission request is pending
const mobileFloatingClass = isInputFocused const hasPendingPermissions = pendingPermissionRequests.length > 0;
? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'
: '';
return ( return (
<div className={`flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6 ${mobileFloatingClass}`}> <div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
{!hasQuestionPanel && ( {!hasPendingPermissions && (
<div className="flex-1"> <ClaudeStatus
<ClaudeStatus status={claudeStatus}
status={claudeStatus} isLoading={isLoading}
isLoading={isLoading} onAbort={onAbortSession}
onAbort={onAbortSession} provider={provider}
provider={provider} />
)}
{pendingPermissionRequests.length > 0 && (
<div className="mx-auto mb-3 max-w-4xl">
<PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
/> />
</div> </div>
)} )}
<div className="mx-auto mb-3 max-w-4xl"> {!hasQuestionPanel && <div className="relative mx-auto max-w-4xl">
<PermissionRequestsBanner {isUserScrolledUp && hasMessages && (
pendingPermissionRequests={pendingPermissionRequests} <div className="absolute -top-10 left-0 right-0 z-10 flex justify-center">
handlePermissionDecision={handlePermissionDecision} <button
handleGrantToolPermission={handleGrantToolPermission} type="button"
/> onClick={onScrollToBottom}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
{!hasQuestionPanel && <ChatInputControls title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
permissionMode={permissionMode} >
onModeSwitch={onModeSwitch} <ArrowDownIcon className="h-4 w-4" />
provider={provider} </button>
thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={onToggleCommandMenu}
hasInput={hasInput}
onClearInput={onClearInput}
isUserScrolledUp={isUserScrolledUp}
hasMessages={hasMessages}
onScrollToBottom={onScrollToBottom}
/>}
</div>
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative mx-auto max-w-4xl">
{isDragActive && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15">
<div className="rounded-xl border border-border/30 bg-card p-4 shadow-lg">
<svg className="mx-auto mb-2 h-8 w-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm font-medium">Drop images here</p>
</div>
</div> </div>
)} )}
{attachedImages.length > 0 && (
<div className="mb-2 rounded-xl bg-muted/40 p-2">
<div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => (
<ImageAttachment
key={index}
file={file}
onRemove={() => onRemoveImage(index)}
uploadProgress={uploadingImages.get(file.name)}
error={imageErrors.get(file.name)}
/>
))}
</div>
</div>
)}
{showFileDropdown && filteredFiles.length > 0 && ( {showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md"> <div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
{filteredFiles.map((file, index) => ( {filteredFiles.map((file, index) => (
@@ -275,21 +244,56 @@ export default function ChatComposer({
frequentCommands={frequentCommands} frequentCommands={frequentCommands}
/> />
<div <PromptInput
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
status={isLoading ? 'streaming' : 'ready'}
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
{...getRootProps()} {...getRootProps()}
className={`relative overflow-hidden rounded-2xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15 ${
isTextareaExpanded ? 'chat-input-expanded' : ''
}`}
> >
<input {...getInputProps()} /> {isDragActive && (
<div ref={inputHighlightRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl"> <div className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15">
<div className="chat-input-placeholder block w-full whitespace-pre-wrap break-words py-1.5 pl-12 pr-20 text-base leading-6 text-transparent sm:py-4 sm:pr-40"> <div className="rounded-xl border border-border/30 bg-card p-4 shadow-lg">
{renderInputWithMentions(input)} <svg className="mx-auto mb-2 h-8 w-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm font-medium">Drop images here</p>
</div>
</div> </div>
</div> )}
<div className="relative z-10"> {attachedImages.length > 0 && (
<textarea <PromptInputHeader>
<div className="rounded-xl bg-muted/40 p-2">
<div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => (
<ImageAttachment
key={index}
file={file}
onRemove={() => onRemoveImage(index)}
uploadProgress={uploadingImages.get(file.name)}
error={imageErrors.get(file.name)}
/>
))}
</div>
</div>
</PromptInputHeader>
)}
<input {...getInputProps()} />
<PromptInputBody>
<div ref={inputHighlightRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl">
<div className="chat-input-placeholder block w-full whitespace-pre-wrap break-words px-4 py-2 text-sm leading-6 text-transparent">
{renderInputWithMentions(input)}
</div>
</div>
<PromptInputTextarea
ref={textareaRef} ref={textareaRef}
value={input} value={input}
onChange={onInputChange} onChange={onInputChange}
@@ -301,58 +305,110 @@ export default function ChatComposer({
onBlur={() => onInputFocusChange?.(false)} onBlur={() => onInputFocusChange?.(false)}
onInput={onTextareaInput} onInput={onTextareaInput}
placeholder={placeholder} placeholder={placeholder}
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
style={{ height: '50px' }}
/> />
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputButton
tooltip={{ content: t('input.attachImages') }}
onClick={openImagePicker}
>
<ImageIcon />
</PromptInputButton>
<button <button
type="button" type="button"
onClick={openImagePicker} onClick={onModeSwitch}
className="absolute left-2 top-1/2 -translate-y-1/2 transform rounded-xl p-2 transition-colors hover:bg-accent/60" className={`rounded-lg border px-1.5 py-1 text-xs font-medium transition-all duration-200 sm:px-2.5 ${
title={t('input.attachImages')} permissionMode === 'default'
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits'
? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'
: permissionMode === 'bypassPermissions'
? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
: 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
}`}
title={t('input.clickToChangeMode')}
> >
<svg className="h-5 w-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div className="flex items-center gap-1.5">
<path <div
strokeLinecap="round" className={`h-2 w-2 rounded-full sm:h-1.5 sm:w-1.5 ${
strokeLinejoin="round" permissionMode === 'default'
strokeWidth={2} ? 'bg-muted-foreground'
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" : permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-primary'
}`}
/> />
</svg> <span className="hidden sm:inline">
{permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')}
</span>
</div>
</button> </button>
<div className="absolute right-16 top-1/2 -translate-y-1/2 transform sm:right-16" style={{ display: 'none' }}> {provider === 'claude' && (
<MicButton onTranscript={onTranscript} className="h-10 w-10 sm:h-10 sm:w-10" /> <ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
</div> )}
<button <TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
type="submit"
disabled={!input.trim() || isLoading} <PromptInputButton
onMouseDown={(event) => { tooltip={{ content: t('input.showAllCommands') }}
event.preventDefault(); onClick={onToggleCommandMenu}
onSubmit(event); className="relative"
}}
onTouchStart={(event) => {
event.preventDefault();
onSubmit(event);
}}
className="absolute right-2 top-1/2 flex h-10 w-10 -translate-y-1/2 transform items-center justify-center rounded-xl bg-primary transition-all duration-200 hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground sm:h-11 sm:w-11"
> >
<svg className="h-4 w-4 rotate-90 transform text-primary-foreground sm:h-[18px] sm:w-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <MessageSquareIcon />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /> {slashCommandsCount > 0 && (
</svg> <span
</button> className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground"
>
{slashCommandsCount}
</span>
)}
</PromptInputButton>
{hasInput && (
<PromptInputButton
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
onClick={onClearInput}
className="hidden sm:inline-flex"
>
<XIcon />
</PromptInputButton>
)}
</PromptInputTools>
<div className="flex items-center gap-2">
<div <div
className={`pointer-events-none absolute bottom-1 left-12 right-14 hidden text-xs text-muted-foreground/50 transition-opacity duration-200 sm:right-40 sm:block ${ className={`hidden text-xs text-muted-foreground/50 transition-opacity duration-200 lg:block ${
input.trim() ? 'opacity-0' : 'opacity-100' input.trim() ? 'opacity-0' : 'opacity-100'
}`} }`}
> >
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')} {sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
</div> </div>
<PromptInputSubmit
disabled={!input.trim() || isLoading}
className="h-10 w-10 sm:h-10 sm:w-10"
onMouseDown={(event) => {
event.preventDefault();
onSubmit(event as unknown as MouseEvent<HTMLButtonElement>);
}}
onTouchStart={(event) => {
event.preventDefault();
onSubmit(event as unknown as TouchEvent<HTMLButtonElement>);
}}
/>
</div> </div>
</div> </PromptInputFooter>
</form>} </PromptInput>
</div>}
</div> </div>
); );
} }

View File

@@ -1,137 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { PermissionMode, Provider } from '../../types/types';
import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie';
interface ChatInputControlsProps {
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
provider: Provider | string;
thinkingMode: string;
setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
}
export default function ChatInputControls({
permissionMode,
onModeSwitch,
provider,
thinkingMode,
setThinkingMode,
tokenBudget,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
}: ChatInputControlsProps) {
const { t } = useTranslation('chat');
return (
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3">
<button
type="button"
onClick={onModeSwitch}
className={`rounded-lg border px-2.5 py-1 text-sm font-medium transition-all duration-200 sm:px-3 sm:py-1.5 ${
permissionMode === 'default'
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits'
? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'
: permissionMode === 'bypassPermissions'
? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
: 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
}`}
title={t('input.clickToChangeMode')}
>
<div className="flex items-center gap-1.5">
<div
className={`h-1.5 w-1.5 rounded-full ${
permissionMode === 'default'
? 'bg-muted-foreground'
: permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-primary'
}`}
/>
<span>
{permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')}
</span>
</div>
</button>
{provider === 'claude' && (
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
<button
type="button"
onClick={onToggleCommandMenu}
className="relative flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground sm:h-8 sm:w-8"
title={t('input.showAllCommands')}
>
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
{slashCommandsCount > 0 && (
<span
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground sm:h-5 sm:w-5"
>
{slashCommandsCount}
</span>
)}
</button>
{hasInput && (
<button
type="button"
onClick={onClearInput}
className="group flex h-7 w-7 items-center justify-center rounded-lg border border-border/50 bg-card shadow-sm transition-all duration-200 hover:bg-accent/60 sm:h-8 sm:w-8"
title={t('input.clearInput', { defaultValue: 'Clear input' })}
>
<svg
className="h-3.5 w-3.5 text-muted-foreground transition-colors group-hover:text-foreground sm:h-4 sm:w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{isUserScrolledUp && hasMessages && (
<button
onClick={onScrollToBottom}
className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all duration-200 hover:scale-105 hover:bg-primary/90 sm:h-8 sm:w-8"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
>
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { Dispatch, RefObject, SetStateAction } from 'react';
import type { ChatMessage } from '../../types/types'; import type { ChatMessage } from '../../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import { getIntrinsicMessageKey } from '../../utils/messageKeys'; import { getIntrinsicMessageKey } from '../../utils/messageKeys';
import MessageComponent from './MessageComponent'; import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState'; import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
@@ -15,8 +15,8 @@ interface ChatMessagesPaneProps {
chatMessages: ChatMessage[]; chatMessages: ChatMessage[];
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
currentSessionId: string | null; currentSessionId: string | null;
provider: SessionProvider; provider: LLMProvider;
setProvider: (provider: SessionProvider) => void; setProvider: (provider: LLMProvider) => void;
textareaRef: RefObject<HTMLTextAreaElement>; textareaRef: RefObject<HTMLTextAreaElement>;
claudeModel: string; claudeModel: string;
setClaudeModel: (model: string) => void; setClaudeModel: (model: string) => void;

View File

@@ -11,6 +11,7 @@ import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions'; import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import type { Project } from '../../../../types/app'; import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools'; import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl'; import MessageCopyControl from './MessageCopyControl';
@@ -68,7 +69,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0; const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0;
const shouldShowAssistantCopyControl = message.type === 'assistant' && const shouldShowAssistantCopyControl = message.type === 'assistant' &&
assistantCopyContent.trim().length > 0 && assistantCopyContent.trim().length > 0 &&
!isCommandOrFileEditToolResponse; !isCommandOrFileEditToolResponse &&
!message.isThinking;
useEffect(() => { useEffect(() => {
@@ -378,36 +380,30 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</div> </div>
</div> </div>
) : message.isThinking ? ( ) : message.isThinking ? (
/* Thinking messages - collapsible by default */ /* Thinking messages — Reasoning component (ai-elements pattern) */
<div className="text-sm text-gray-700 dark:text-gray-300"> <Reasoning defaultOpen={false}>
<details className="group"> <ReasoningTrigger />
<summary className="flex cursor-pointer items-center gap-2 font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> <ReasoningContent>
<svg className="h-3 w-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> {message.content}
</svg> </Markdown>
<span>{t('thinking.emoji')}</span> <div className="mt-3 flex items-center text-[11px]">
</summary> <MessageCopyControl content={String(message.content || '')} messageType="assistant" />
<div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm text-gray-600 dark:border-gray-600 dark:text-gray-400">
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
{message.content}
</Markdown>
</div> </div>
</details> </ReasoningContent>
</div> </Reasoning>
) : ( ) : (
<div className="text-sm text-gray-700 dark:text-gray-300"> <div className="text-sm text-gray-700 dark:text-gray-300">
{/* Thinking accordion for reasoning */} {/* Reasoning accordion */}
{showThinking && message.reasoning && ( {showThinking && message.reasoning && (
<details className="mb-3"> <Reasoning className="mb-3" defaultOpen={false}>
<summary className="cursor-pointer font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"> <ReasoningTrigger />
{t('thinking.emoji')} <ReasoningContent>
</summary>
<div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
<div className="whitespace-pre-wrap"> <div className="whitespace-pre-wrap">
{message.reasoning} {message.reasoning}
</div> </div>
</div> </ReasoningContent>
</details> </Reasoning>
)} )}
{(() => { {(() => {

View File

@@ -1,9 +1,18 @@
import React from 'react'; import React from 'react';
import { ShieldAlertIcon } from 'lucide-react';
import type { PendingPermissionRequest } from '../../types/types'; import type { PendingPermissionRequest } from '../../types/types';
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions'; import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';
import { getClaudeSettings } from '../../utils/chatStorage'; import { getClaudeSettings } from '../../utils/chatStorage';
import { getPermissionPanel, registerPermissionPanel } from '../../tools/configs/permissionPanelRegistry'; import { getPermissionPanel, registerPermissionPanel } from '../../tools/configs/permissionPanelRegistry';
import { AskUserQuestionPanel } from '../../tools/components/InteractiveRenderers'; import { AskUserQuestionPanel } from '../../tools/components/InteractiveRenderers';
import {
Confirmation,
ConfirmationTitle,
ConfirmationRequest,
ConfirmationActions,
ConfirmationAction,
} from '../../../../shared/view/ui';
registerPermissionPanel('AskUserQuestion', AskUserQuestionPanel); registerPermissionPanel('AskUserQuestion', AskUserQuestionPanel);
@@ -21,13 +30,18 @@ export default function PermissionRequestsBanner({
handlePermissionDecision, handlePermissionDecision,
handleGrantToolPermission, handleGrantToolPermission,
}: PermissionRequestsBannerProps) { }: PermissionRequestsBannerProps) {
if (!pendingPermissionRequests.length) { // Filter out plan tool requests — they are handled inline by PlanDisplay
const filteredRequests = pendingPermissionRequests.filter(
(r) => r.toolName !== 'ExitPlanMode' && r.toolName !== 'exit_plan_mode'
);
if (!filteredRequests.length) {
return null; return null;
} }
return ( return (
<div className="mb-3 space-y-2"> <div className="mb-3 space-y-2">
{pendingPermissionRequests.map((request) => { {filteredRequests.map((request) => {
const CustomPanel = getPermissionPanel(request.toolName); const CustomPanel = getPermissionPanel(request.toolName);
if (CustomPanel) { if (CustomPanel) {
return ( return (
@@ -54,69 +68,62 @@ export default function PermissionRequestsBanner({
: [request.requestId]; : [request.requestId];
return ( return (
<div <Confirmation key={request.requestId} approval="pending">
key={request.requestId} <ConfirmationTitle className="flex items-start gap-3">
className="rounded-lg border border-amber-200 bg-amber-50 p-3 shadow-sm dark:border-amber-800 dark:bg-amber-900/20" <ShieldAlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
> <ConfirmationRequest>
<div className="flex flex-wrap items-start justify-between gap-3"> <div>
<div> <span className="font-medium text-foreground">Permission required</span>
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">Permission required</div> <span className="ml-2 text-muted-foreground">
<div className="text-xs text-amber-800 dark:text-amber-200"> Tool: <code className="rounded bg-muted px-1.5 py-0.5 text-xs">{request.toolName}</code>
Tool: <span className="font-mono">{request.toolName}</span> </span>
</div> </div>
</div> {permissionEntry && (
{permissionEntry && ( <div className="mt-1 text-xs text-muted-foreground">
<div className="text-xs text-amber-700 dark:text-amber-300"> Allow rule: <code className="rounded bg-muted px-1 py-0.5 text-xs">{permissionEntry}</code>
Allow rule: <span className="font-mono">{permissionEntry}</span> </div>
</div> )}
)} </ConfirmationRequest>
</div> </ConfirmationTitle>
{rawInput && ( {rawInput && (
<details className="mt-2"> <details className="mt-2">
<summary className="cursor-pointer text-xs text-amber-800 hover:text-amber-900 dark:text-amber-200 dark:hover:text-amber-100"> <summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
View tool input View tool input
</summary> </summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md border border-amber-200/60 bg-white/80 p-2 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-gray-900/60 dark:text-amber-100"> <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md border bg-muted/50 p-2 text-xs text-muted-foreground">
{rawInput} {rawInput}
</pre> </pre>
</details> </details>
)} )}
<div className="mt-3 flex flex-wrap gap-2"> <ConfirmationActions>
<button <ConfirmationAction
type="button" variant="outline"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })} onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
className="inline-flex items-center gap-2 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-amber-700"
> >
Allow once Deny
</button> </ConfirmationAction>
<button <ConfirmationAction
type="button" variant="outline"
onClick={() => { onClick={() => {
if (permissionEntry && !alreadyAllowed) { if (permissionEntry && !alreadyAllowed) {
handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName }); handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
} }
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry }); handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
}} }}
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
permissionEntry
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
: 'cursor-not-allowed border-gray-300 text-gray-400'
}`}
disabled={!permissionEntry} disabled={!permissionEntry}
> >
{rememberLabel} {rememberLabel}
</button> </ConfirmationAction>
<button <ConfirmationAction
type="button" variant="default"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })} onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
className="inline-flex items-center gap-2 rounded-md border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30"
> >
Deny Allow once
</button> </ConfirmationAction>
</div> </ConfirmationActions>
</div> </Confirmation>
); );
})} })}
</div> </div>

View File

@@ -1,6 +1,7 @@
import React from "react"; import React, { useCallback, useMemo, useState } from "react";
import { Check, ChevronDown } from "lucide-react"; import { Check, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo"; import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import { import {
CLAUDE_MODELS, CLAUDE_MODELS,
@@ -8,14 +9,27 @@ import {
CODEX_MODELS, CODEX_MODELS,
GEMINI_MODELS, GEMINI_MODELS,
} from "../../../../../shared/modelConstants"; } from "../../../../../shared/modelConstants";
import type { ProjectSession, SessionProvider } from "../../../../types/app"; import type { ProjectSession, LLMProvider } from "../../../../types/app";
import { NextTaskBanner } from "../../../task-master"; import { NextTaskBanner } from "../../../task-master";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
Card,
} from "../../../../shared/view/ui";
type ProviderSelectionEmptyStateProps = { type ProviderSelectionEmptyStateProps = {
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
currentSessionId: string | null; currentSessionId: string | null;
provider: SessionProvider; provider: LLMProvider;
setProvider: (next: SessionProvider) => void; setProvider: (next: LLMProvider) => void;
textareaRef: React.RefObject<HTMLTextAreaElement>; textareaRef: React.RefObject<HTMLTextAreaElement>;
claudeModel: string; claudeModel: string;
setClaudeModel: (model: string) => void; setClaudeModel: (model: string) => void;
@@ -31,59 +45,28 @@ type ProviderSelectionEmptyStateProps = {
setInput: React.Dispatch<React.SetStateAction<string>>; setInput: React.Dispatch<React.SetStateAction<string>>;
}; };
type ProviderDef = { interface ProviderGroup {
id: SessionProvider; id: LLMProvider;
name: string; name: string;
infoKey: string; models: { value: string; label: string }[];
accent: string; }
ring: string;
check: string;
};
const PROVIDERS: ProviderDef[] = [ const PROVIDER_GROUPS: ProviderGroup[] = [
{ { id: "claude", name: "Anthropic", models: CLAUDE_MODELS.OPTIONS },
id: "claude", { id: "cursor", name: "Cursor", models: CURSOR_MODELS.OPTIONS },
name: "Claude Code", { id: "codex", name: "OpenAI", models: CODEX_MODELS.OPTIONS },
infoKey: "providerSelection.providerInfo.anthropic", { id: "gemini", name: "Google", models: GEMINI_MODELS.OPTIONS },
accent: "border-primary",
ring: "ring-primary/15",
check: "bg-primary text-primary-foreground",
},
{
id: "cursor",
name: "Cursor",
infoKey: "providerSelection.providerInfo.cursorEditor",
accent: "border-violet-500 dark:border-violet-400",
ring: "ring-violet-500/15",
check: "bg-violet-500 text-white",
},
{
id: "codex",
name: "Codex",
infoKey: "providerSelection.providerInfo.openai",
accent: "border-emerald-600 dark:border-emerald-400",
ring: "ring-emerald-600/15",
check: "bg-emerald-600 dark:bg-emerald-500 text-white",
},
{
id: "gemini",
name: "Gemini",
infoKey: "providerSelection.providerInfo.google",
accent: "border-blue-500 dark:border-blue-400",
ring: "ring-blue-500/15",
check: "bg-blue-500 text-white",
},
]; ];
function getModelConfig(p: SessionProvider) { function getModelConfig(p: LLMProvider) {
if (p === "claude") return CLAUDE_MODELS; if (p === "claude") return CLAUDE_MODELS;
if (p === "codex") return CODEX_MODELS; if (p === "codex") return CODEX_MODELS;
if (p === "gemini") return GEMINI_MODELS; if (p === "gemini") return GEMINI_MODELS;
return CURSOR_MODELS; return CURSOR_MODELS;
} }
function getModelValue( function getCurrentModel(
p: SessionProvider, p: LLMProvider,
c: string, c: string,
cu: string, cu: string,
co: string, co: string,
@@ -95,6 +78,13 @@ function getModelValue(
return cu; return cu;
} }
function getProviderDisplayName(p: LLMProvider) {
if (p === "claude") return "Claude";
if (p === "cursor") return "Cursor";
if (p === "codex") return "Codex";
return "Gemini";
}
export default function ProviderSelectionEmptyState({ export default function ProviderSelectionEmptyState({
selectedSession, selectedSession,
currentSessionId, currentSessionId,
@@ -115,34 +105,12 @@ export default function ProviderSelectionEmptyState({
setInput, setInput,
}: ProviderSelectionEmptyStateProps) { }: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const [dialogOpen, setDialogOpen] = useState(false);
const nextTaskPrompt = t("tasks.nextTaskPrompt", { const nextTaskPrompt = t("tasks.nextTaskPrompt", {
defaultValue: "Start the next task", defaultValue: "Start the next task",
}); });
const selectProvider = (next: SessionProvider) => { const currentModel = getCurrentModel(
setProvider(next);
localStorage.setItem("selected-provider", next);
setTimeout(() => textareaRef.current?.focus(), 100);
};
const handleModelChange = (value: string) => {
if (provider === "claude") {
setClaudeModel(value);
localStorage.setItem("claude-model", value);
} else if (provider === "codex") {
setCodexModel(value);
localStorage.setItem("codex-model", value);
} else if (provider === "gemini") {
setGeminiModel(value);
localStorage.setItem("gemini-model", value);
} else {
setCursorModel(value);
localStorage.setItem("cursor-model", value);
}
};
const modelConfig = getModelConfig(provider);
const currentModel = getModelValue(
provider, provider,
claudeModel, claudeModel,
cursorModel, cursorModel,
@@ -150,7 +118,42 @@ export default function ProviderSelectionEmptyState({
geminiModel, geminiModel,
); );
/* ── New session — provider picker ── */ const currentModelLabel = useMemo(() => {
const config = getModelConfig(provider);
const found = config.OPTIONS.find(
(o: { value: string; label: string }) => o.value === currentModel,
);
return found?.label || currentModel;
}, [provider, currentModel]);
const handleModelSelect = useCallback(
(providerId: LLMProvider, modelValue: string) => {
// Set provider
setProvider(providerId);
localStorage.setItem("selected-provider", providerId);
// Set model for the correct provider
if (providerId === "claude") {
setClaudeModel(modelValue);
localStorage.setItem("claude-model", modelValue);
} else if (providerId === "codex") {
setCodexModel(modelValue);
localStorage.setItem("codex-model", modelValue);
} else if (providerId === "gemini") {
setGeminiModel(modelValue);
localStorage.setItem("gemini-model", modelValue);
} else {
setCursorModel(modelValue);
localStorage.setItem("cursor-model", modelValue);
}
setDialogOpen(false);
setTimeout(() => textareaRef.current?.focus(), 100);
},
[setProvider, setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, textareaRef],
);
/* ── New session — provider + model picker ── */
if (!selectedSession && !currentSessionId) { if (!selectedSession && !currentSessionId) {
return ( return (
<div className="flex h-full items-center justify-center px-4"> <div className="flex h-full items-center justify-center px-4">
@@ -165,96 +168,100 @@ export default function ProviderSelectionEmptyState({
</p> </p>
</div> </div>
{/* Provider cards — horizontal row, equal width */} {/* Model selector trigger — hero card style */}
<div className="mb-6 grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-2.5"> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{PROVIDERS.map((p) => { <DialogTrigger asChild>
const active = provider === p.id; <Card
return ( className="group mx-auto max-w-sm cursor-pointer border-border/60 transition-all duration-150 hover:border-border hover:shadow-md active:scale-[0.99]"
<button role="button"
key={p.id} tabIndex={0}
onClick={() => selectProvider(p.id)} >
className={` <div className="flex items-center gap-3 p-4">
relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2
pb-4 pt-5 transition-all duration-150
active:scale-[0.97]
${
active
? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
: "border-border bg-card/60 hover:border-border/80 hover:bg-card"
}
`}
>
<SessionProviderLogo <SessionProviderLogo
provider={p.id} provider={provider}
className={`h-9 w-9 transition-transform duration-150 ${active ? "scale-110" : ""}`} className="h-8 w-8 shrink-0"
/> />
<div className="text-center"> <div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold leading-none text-foreground"> <div className="flex items-center gap-1.5">
{p.name} <span className="text-sm font-semibold text-foreground">
</p> {getProviderDisplayName(provider)}
<p className="mt-1 text-[10px] leading-tight text-muted-foreground"> </span>
{t(p.infoKey)} <span className="text-xs text-muted-foreground">·</span>
<span className="truncate text-sm text-foreground">
{currentModelLabel}
</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("providerSelection.clickToChange", {
defaultValue: "Click to change model",
})}
</p> </p>
</div> </div>
{/* Check badge */} <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-y-0.5" />
{active && ( </div>
<div </Card>
className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`} </DialogTrigger>
<DialogContent className="max-w-md overflow-hidden p-0">
<DialogTitle>Model Selector</DialogTitle>
<Command>
<CommandInput placeholder={t("providerSelection.searchModels", { defaultValue: "Search models..." })} />
<CommandList className="max-h-[350px]">
<CommandEmpty>
{t("providerSelection.noModelsFound", { defaultValue: "No models found." })}
</CommandEmpty>
{PROVIDER_GROUPS.map((group) => (
<CommandGroup
key={group.id}
heading={
<span className="flex items-center gap-1.5">
<SessionProviderLogo provider={group.id} className="h-3.5 w-3.5 shrink-0" />
{group.name}
</span>
}
> >
<Check className="h-2.5 w-2.5" strokeWidth={3} /> {group.models.map((model) => {
</div> const isSelected =
)} provider === group.id && currentModel === model.value;
</button> return (
); <CommandItem
})} key={`${group.id}-${model.value}`}
</div> value={`${group.name} ${model.label}`}
onSelect={() => handleModelSelect(group.id, model.value)}
>
<span className="flex-1 truncate">{model.label}</span>
{isSelected && (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
)}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogContent>
</Dialog>
{/* Model picker — appears after provider is chosen */} {/* Ready prompt */}
<div <p className="mt-4 text-center text-sm text-muted-foreground/70">
className={`transition-all duration-200 ${provider ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1 opacity-0"}`} {
>
<div className="mb-5 flex items-center justify-center gap-2">
<span className="text-sm text-muted-foreground">
{t("providerSelection.selectModel")}
</span>
<div className="relative">
<select
value={currentModel}
onChange={(e) => handleModelChange(e.target.value)}
tabIndex={-1}
className="cursor-pointer appearance-none rounded-lg border border-border/60 bg-muted/50 py-1.5 pl-3 pr-7 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{modelConfig.OPTIONS.map(
({ value, label }: { value: string; label: string }) => (
<option key={value + label} value={value}>
{label}
</option>
),
)}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
<p className="text-center text-sm text-muted-foreground/70">
{ {
{ claude: t("providerSelection.readyPrompt.claude", {
claude: t("providerSelection.readyPrompt.claude", { model: claudeModel,
model: claudeModel, }),
}), cursor: t("providerSelection.readyPrompt.cursor", {
cursor: t("providerSelection.readyPrompt.cursor", { model: cursorModel,
model: cursorModel, }),
}), codex: t("providerSelection.readyPrompt.codex", {
codex: t("providerSelection.readyPrompt.codex", { model: codexModel,
model: codexModel, }),
}), gemini: t("providerSelection.readyPrompt.gemini", {
gemini: t("providerSelection.readyPrompt.gemini", { model: geminiModel,
model: geminiModel, }),
}), }[provider]
}[provider] }
} </p>
</p>
</div>
{/* Task banner */} {/* Task banner */}
{provider && tasksEnabled && isTaskMasterInstalled && ( {provider && tasksEnabled && isTaskMasterInstalled && (

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { Brain, X } from 'lucide-react'; import { Brain, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { thinkingModes } from '../../constants/thinkingModes'; import { thinkingModes } from '../../constants/thinkingModes';
@@ -12,6 +13,11 @@ type ThinkingModeSelectorProps = {
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) { function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
const { t } = useTranslation('chat'); 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 // Mapping from mode ID to translation key
const modeKeyMap: Record<string, string> = { const modeKeyMap: Record<string, string> = {
@@ -29,50 +35,143 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
}; };
}); });
const [isOpen, setIsOpen] = useState(false); const closeDropdown = useCallback(() => {
const dropdownRef = useRef<HTMLDivElement>(null); 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(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { if (!isOpen) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setDropdownStyle(null);
setIsOpen(false); return;
if (onClose) onClose(); }
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('mousedown', handleClickOutside); document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleKeyDown);
}, [onClose]);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, closeDropdown]);
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0]; const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
const IconComponent = currentMode.icon || Brain; const IconComponent = currentMode.icon || Brain;
return ( return (
<div className={`relative ${className}`} ref={dropdownRef}> <div className={`relative ${className}`} ref={containerRef}>
<button <button
ref={triggerRef}
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} 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' 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-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' : 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
}`} }`}
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })} title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
aria-haspopup="dialog"
aria-expanded={isOpen}
> >
<IconComponent className={`h-5 w-5 ${currentMode.color}`} /> <IconComponent className={`h-5 w-5 ${currentMode.color}`} />
</button> </button>
{isOpen && ( {isOpen && typeof document !== 'undefined' && createPortal(
<div className="absolute bottom-full right-0 mb-2 w-64 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"> <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="border-b border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white"> <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('thinkingMode.selector.title')} {t('thinkingMode.selector.title')}
</h3> </h3>
<button <button
onClick={() => { type="button"
setIsOpen(false); onClick={closeDropdown}
if (onClose) onClose();
}}
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700" className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
> >
<X className="h-4 w-4 text-gray-500" /> <X className="h-4 w-4 text-gray-500" />
@@ -83,7 +182,7 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
</p> </p>
</div> </div>
<div className="py-1"> <div className="min-h-0 overflow-y-auto py-1">
{translatedModes.map((mode) => { {translatedModes.map((mode) => {
const ModeIcon = mode.icon; const ModeIcon = mode.icon;
const isSelected = mode.id === selectedMode; const isSelected = mode.id === selectedMode;
@@ -91,10 +190,10 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
return ( return (
<button <button
key={mode.id} key={mode.id}
type="button"
onClick={() => { onClick={() => {
onModeChange(mode.id); onModeChange(mode.id);
setIsOpen(false); closeDropdown();
if (onClose) onClose();
}} }}
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' : '' 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' : ''
}`} }`}
@@ -135,10 +234,11 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
<strong>Tip:</strong> {t('thinkingMode.selector.tip')} <strong>Tip:</strong> {t('thinkingMode.selector.tip')}
</p> </p>
</div> </div>
</div> </div>,
document.body
)} )}
</div> </div>
); );
} }
export default ThinkingModeSelector; export default ThinkingModeSelector;

View File

@@ -1,6 +1,5 @@
import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react'; import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import MicButton from '../../../mic-button/view/MicButton';
import type { ConfirmationRequest } from '../../types/types'; import type { ConfirmationRequest } from '../../types/types';
// Persists commit messages across unmount/remount, keyed by project path // Persists commit messages across unmount/remount, keyed by project path
@@ -147,13 +146,6 @@ export default function CommitComposer({
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
)} )}
</button> </button>
<div style={{ display: 'none' }}>
<MicButton
onTranscript={(transcript) => setCommitMessage(transcript)}
mode="default"
className="p-1.5"
/>
</div>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,11 @@
import type { SessionProvider } from '../../types/app'; import type { LLMProvider } from '../../types/app';
import ClaudeLogo from './ClaudeLogo'; import ClaudeLogo from './ClaudeLogo';
import CodexLogo from './CodexLogo'; import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo'; import CursorLogo from './CursorLogo';
import GeminiLogo from './GeminiLogo'; import GeminiLogo from './GeminiLogo';
type SessionProviderLogoProps = { type SessionProviderLogoProps = {
provider?: SessionProvider | string | null; provider?: LLMProvider | string | null;
className?: string; className?: string;
}; };

View File

@@ -1,45 +0,0 @@
import type { MicButtonState } from '../types/types';
export const MIC_BUTTON_STATES = {
IDLE: 'idle',
RECORDING: 'recording',
TRANSCRIBING: 'transcribing',
PROCESSING: 'processing',
} as const;
export const MIC_TAP_DEBOUNCE_MS = 300;
export const PROCESSING_STATE_DELAY_MS = 2000;
export const DEFAULT_WHISPER_MODE = 'default';
// Modes that use post-transcription enhancement on the backend.
export const ENHANCEMENT_WHISPER_MODES = new Set([
'prompt',
'vibe',
'instructions',
'architect',
]);
export const BUTTON_BACKGROUND_BY_STATE: Record<MicButtonState, string> = {
idle: '#374151',
recording: '#ef4444',
transcribing: '#3b82f6',
processing: '#a855f7',
};
export const MIC_ERROR_BY_NAME = {
NotAllowedError: 'Microphone access denied. Please allow microphone permissions.',
NotFoundError: 'No microphone found. Please check your audio devices.',
NotSupportedError: 'Microphone not supported by this browser.',
NotReadableError: 'Microphone is being used by another application.',
} as const;
export const MIC_NOT_AVAILABLE_ERROR =
'Microphone access not available. Please use HTTPS or a supported browser.';
export const MIC_NOT_SUPPORTED_ERROR =
'Microphone not supported. Please use HTTPS or a modern browser.';
export const MIC_SECURE_CONTEXT_ERROR =
'Microphone requires HTTPS. Please use a secure connection.';

View File

@@ -1,52 +0,0 @@
import { api } from '../../../utils/api';
type WhisperStatus = 'transcribing';
type WhisperResponse = {
text?: string;
error?: string;
};
export async function transcribeWithWhisper(
audioBlob: Blob,
onStatusChange?: (status: WhisperStatus) => void,
): Promise<string> {
const formData = new FormData();
const fileName = `recording_${Date.now()}.webm`;
const file = new File([audioBlob], fileName, { type: audioBlob.type });
formData.append('audio', file);
const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
formData.append('mode', whisperMode);
try {
// Keep existing status callback behavior.
if (onStatusChange) {
onStatusChange('transcribing');
}
const response = (await api.transcribe(formData)) as Response;
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as WhisperResponse;
throw new Error(
errorData.error ||
`Transcription error: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as WhisperResponse;
return data.text || '';
} catch (error) {
if (
error instanceof Error
&& error.name === 'TypeError'
&& error.message.includes('fetch')
) {
throw new Error('Cannot connect to server. Please ensure the backend is running.');
}
throw error;
}
}

View File

@@ -1,204 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import type { MouseEvent } from 'react';
import { transcribeWithWhisper } from '../data/whisper';
import {
DEFAULT_WHISPER_MODE,
ENHANCEMENT_WHISPER_MODES,
MIC_BUTTON_STATES,
MIC_ERROR_BY_NAME,
MIC_NOT_AVAILABLE_ERROR,
MIC_NOT_SUPPORTED_ERROR,
MIC_SECURE_CONTEXT_ERROR,
MIC_TAP_DEBOUNCE_MS,
PROCESSING_STATE_DELAY_MS,
} from '../constants/constants';
import type { MicButtonState } from '../types/types';
type UseMicButtonControllerArgs = {
onTranscript?: (transcript: string) => void;
};
type UseMicButtonControllerResult = {
state: MicButtonState;
error: string | null;
isSupported: boolean;
handleButtonClick: (event?: MouseEvent<HTMLButtonElement>) => void;
};
const getRecordingErrorMessage = (error: unknown): string => {
if (error instanceof Error && error.message.includes('HTTPS')) {
return error.message;
}
if (error instanceof DOMException) {
return MIC_ERROR_BY_NAME[error.name as keyof typeof MIC_ERROR_BY_NAME] || 'Microphone access failed';
}
return 'Microphone access failed';
};
const getRecorderMimeType = (): string => (
MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'
);
export function useMicButtonController({
onTranscript,
}: UseMicButtonControllerArgs): UseMicButtonControllerResult {
const [state, setState] = useState<MicButtonState>(MIC_BUTTON_STATES.IDLE);
const [error, setError] = useState<string | null>(null);
const [isSupported, setIsSupported] = useState(true);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const lastTapRef = useRef(0);
const processingTimerRef = useRef<number | null>(null);
const clearProcessingTimer = (): void => {
if (processingTimerRef.current !== null) {
window.clearTimeout(processingTimerRef.current);
processingTimerRef.current = null;
}
};
const stopStreamTracks = (): void => {
if (!streamRef.current) {
return;
}
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
};
const handleStopRecording = async (mimeType: string): Promise<void> => {
const audioBlob = new Blob(chunksRef.current, { type: mimeType });
// Release the microphone immediately once recording ends.
stopStreamTracks();
setState(MIC_BUTTON_STATES.TRANSCRIBING);
const whisperMode = window.localStorage.getItem('whisperMode') || DEFAULT_WHISPER_MODE;
const shouldShowProcessingState = ENHANCEMENT_WHISPER_MODES.has(whisperMode);
if (shouldShowProcessingState) {
processingTimerRef.current = window.setTimeout(() => {
setState(MIC_BUTTON_STATES.PROCESSING);
}, PROCESSING_STATE_DELAY_MS);
}
try {
const transcript = await transcribeWithWhisper(audioBlob);
if (transcript && onTranscript) {
onTranscript(transcript);
}
} catch (transcriptionError) {
const message = transcriptionError instanceof Error ? transcriptionError.message : 'Transcription error';
setError(message);
} finally {
clearProcessingTimer();
setState(MIC_BUTTON_STATES.IDLE);
}
};
const startRecording = async (): Promise<void> => {
try {
setError(null);
chunksRef.current = [];
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error(MIC_NOT_AVAILABLE_ERROR);
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mimeType = getRecorderMimeType();
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
void handleStopRecording(mimeType);
};
recorder.start();
setState(MIC_BUTTON_STATES.RECORDING);
} catch (recordingError) {
stopStreamTracks();
setError(getRecordingErrorMessage(recordingError));
setState(MIC_BUTTON_STATES.IDLE);
}
};
const stopRecording = (): void => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
return;
}
stopStreamTracks();
setState(MIC_BUTTON_STATES.IDLE);
};
const handleButtonClick = (event?: MouseEvent<HTMLButtonElement>): void => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!isSupported) {
return;
}
// Mobile tap handling can trigger duplicate click events in quick succession.
const now = Date.now();
if (now - lastTapRef.current < MIC_TAP_DEBOUNCE_MS) {
return;
}
lastTapRef.current = now;
if (state === MIC_BUTTON_STATES.IDLE) {
void startRecording();
return;
}
if (state === MIC_BUTTON_STATES.RECORDING) {
stopRecording();
}
};
useEffect(() => {
// getUserMedia needs both browser support and a secure context.
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
setIsSupported(false);
setError(MIC_NOT_SUPPORTED_ERROR);
return;
}
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
setIsSupported(false);
setError(MIC_SECURE_CONTEXT_ERROR);
return;
}
setIsSupported(true);
setError(null);
}, []);
useEffect(() => () => {
clearProcessingTimer();
stopStreamTracks();
}, []);
return {
state,
error,
isSupported,
handleButtonClick,
};
}

View File

@@ -1,2 +0,0 @@
export type MicButtonState = 'idle' | 'recording' | 'transcribing' | 'processing';

View File

@@ -1,32 +0,0 @@
import { useMicButtonController } from '../hooks/useMicButtonController';
import MicButtonView from './MicButtonView';
type MicButtonProps = {
onTranscript?: (transcript: string) => void;
className?: string;
mode?: string;
};
export default function MicButton({
onTranscript,
className = '',
mode: _mode,
}: MicButtonProps) {
const { state, error, isSupported, handleButtonClick } = useMicButtonController({
onTranscript,
});
// Keep `mode` in the public props for backwards compatibility.
void _mode;
return (
<MicButtonView
state={state}
error={error}
isSupported={isSupported}
className={className}
onButtonClick={handleButtonClick}
/>
);
}

View File

@@ -1,86 +0,0 @@
import { Brain, Loader2, Mic } from 'lucide-react';
import type { MouseEvent, ReactElement } from 'react';
import { BUTTON_BACKGROUND_BY_STATE, MIC_BUTTON_STATES } from '../constants/constants';
import type { MicButtonState } from '../types/types';
type MicButtonViewProps = {
state: MicButtonState;
error: string | null;
isSupported: boolean;
className: string;
onButtonClick: (event?: MouseEvent<HTMLButtonElement>) => void;
};
const getButtonIcon = (state: MicButtonState, isSupported: boolean): ReactElement => {
if (!isSupported) {
return <Mic className="h-5 w-5" />;
}
if (state === MIC_BUTTON_STATES.TRANSCRIBING) {
return <Loader2 className="h-5 w-5 animate-spin" />;
}
if (state === MIC_BUTTON_STATES.PROCESSING) {
return <Brain className="h-5 w-5 animate-pulse" />;
}
if (state === MIC_BUTTON_STATES.RECORDING) {
return <Mic className="h-5 w-5 text-white" />;
}
return <Mic className="h-5 w-5" />;
};
export default function MicButtonView({
state,
error,
isSupported,
className,
onButtonClick,
}: MicButtonViewProps) {
const isDisabled = !isSupported || state === MIC_BUTTON_STATES.TRANSCRIBING || state === MIC_BUTTON_STATES.PROCESSING;
const icon = getButtonIcon(state, isSupported);
return (
<div className="relative">
<button
type="button"
style={{ backgroundColor: BUTTON_BACKGROUND_BY_STATE[state] }}
className={`
touch-action-manipulation flex h-12
w-12 items-center justify-center
rounded-full text-white transition-all
duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500
focus:ring-offset-2
dark:ring-offset-gray-800
${isDisabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
${state === MIC_BUTTON_STATES.RECORDING ? 'animate-pulse' : ''}
hover:opacity-90
${className}
`}
onClick={onButtonClick}
disabled={isDisabled}
>
{icon}
</button>
{error && (
<div
className="animate-fade-in absolute left-1/2 top-full z-10 mt-2
-translate-x-1/2 transform whitespace-nowrap rounded bg-red-500 px-2 py-1 text-xs
text-white"
>
{error}
</div>
)}
{state === MIC_BUTTON_STATES.RECORDING && (
<div className="pointer-events-none absolute -inset-1 animate-ping rounded-full border-2 border-red-500" />
)}
{state === MIC_BUTTON_STATES.PROCESSING && (
<div className="pointer-events-none absolute -inset-1 animate-ping rounded-full border-2 border-purple-500" />
)}
</div>
);
}

View File

@@ -1,17 +1,15 @@
import { Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { LLMProvider } from '../../../types/app';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal'; import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
import AgentConnectionsStep from './subcomponents/AgentConnectionsStep'; import AgentConnectionsStep from './subcomponents/AgentConnectionsStep';
import GitConfigurationStep from './subcomponents/GitConfigurationStep'; import GitConfigurationStep from './subcomponents/GitConfigurationStep';
import OnboardingStepProgress from './subcomponents/OnboardingStepProgress'; import OnboardingStepProgress from './subcomponents/OnboardingStepProgress';
import type { CliProvider, ProviderStatusMap } from './types';
import { import {
cliProviders,
createInitialProviderStatuses,
gitEmailPattern, gitEmailPattern,
readErrorMessageFromResponse, readErrorMessageFromResponse,
selectedProject,
} from './utils'; } from './utils';
type OnboardingProps = { type OnboardingProps = {
@@ -24,59 +22,14 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
const [gitEmail, setGitEmail] = useState(''); const [gitEmail, setGitEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [activeLoginProvider, setActiveLoginProvider] = useState<CliProvider | null>(null); const [activeLoginProvider, setActiveLoginProvider] = useState<LLMProvider | null>(null);
const [providerStatuses, setProviderStatuses] = useState<ProviderStatusMap>(createInitialProviderStatuses); const {
providerAuthStatus,
checkProviderAuthStatus,
refreshProviderAuthStatuses,
} = useProviderAuthStatus();
const previousActiveLoginProviderRef = useRef<CliProvider | null | undefined>(undefined); const previousActiveLoginProviderRef = useRef<LLMProvider | null | undefined>(undefined);
const checkProviderAuthStatus = useCallback(async (provider: CliProvider) => {
try {
const response = await authenticatedFetch(`/api/cli/${provider}/status`);
if (!response.ok) {
setProviderStatuses((previous) => ({
...previous,
[provider]: {
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status',
},
}));
return;
}
const payload = (await response.json()) as {
authenticated?: boolean;
email?: string | null;
error?: string | null;
};
setProviderStatuses((previous) => ({
...previous,
[provider]: {
authenticated: Boolean(payload.authenticated),
email: payload.email ?? null,
loading: false,
error: payload.error ?? null,
},
}));
} catch (caughtError) {
console.error(`Error checking ${provider} auth status:`, caughtError);
setProviderStatuses((previous) => ({
...previous,
[provider]: {
authenticated: false,
email: null,
loading: false,
error: caughtError instanceof Error ? caughtError.message : 'Unknown error',
},
}));
}
}, []);
const refreshAllProviderStatuses = useCallback(async () => {
await Promise.all(cliProviders.map((provider) => checkProviderAuthStatus(provider)));
}, [checkProviderAuthStatus]);
const loadGitConfig = useCallback(async () => { const loadGitConfig = useCallback(async () => {
try { try {
@@ -99,23 +52,24 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
useEffect(() => { useEffect(() => {
void loadGitConfig(); void loadGitConfig();
void refreshAllProviderStatuses(); void refreshProviderAuthStatuses();
}, [loadGitConfig, refreshAllProviderStatuses]); }, [loadGitConfig, refreshProviderAuthStatuses]);
useEffect(() => { useEffect(() => {
const previousProvider = previousActiveLoginProviderRef.current; const previousProvider = previousActiveLoginProviderRef.current;
previousActiveLoginProviderRef.current = activeLoginProvider; previousActiveLoginProviderRef.current = activeLoginProvider;
const isInitialMount = previousProvider === undefined; const didCloseModal = previousProvider !== undefined
const didCloseModal = previousProvider !== null && activeLoginProvider === null; && previousProvider !== null
&& activeLoginProvider === null;
// Refresh statuses once on mount and again after the login modal is closed. // Refresh statuses after the login modal is closed.
if (isInitialMount || didCloseModal) { if (didCloseModal) {
void refreshAllProviderStatuses(); void refreshProviderAuthStatuses();
} }
}, [activeLoginProvider, refreshAllProviderStatuses]); }, [activeLoginProvider, refreshProviderAuthStatuses]);
const handleProviderLoginOpen = (provider: CliProvider) => { const handleProviderLoginOpen = (provider: LLMProvider) => {
setActiveLoginProvider(provider); setActiveLoginProvider(provider);
}; };
@@ -209,7 +163,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
/> />
) : ( ) : (
<AgentConnectionsStep <AgentConnectionsStep
providerStatuses={providerStatuses} providerStatuses={providerAuthStatus}
onOpenProviderLogin={handleProviderLoginOpen} onOpenProviderLogin={handleProviderLoginOpen}
/> />
)} )}
@@ -279,7 +233,6 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
isOpen={Boolean(activeLoginProvider)} isOpen={Boolean(activeLoginProvider)}
onClose={() => setActiveLoginProvider(null)} onClose={() => setActiveLoginProvider(null)}
provider={activeLoginProvider} provider={activeLoginProvider}
project={selectedProject}
onComplete={handleLoginComplete} onComplete={handleLoginComplete}
/> />
)} )}

View File

@@ -1,9 +1,10 @@
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { CliProvider, ProviderAuthStatus } from '../types'; import type { LLMProvider } from '../../../../types/app';
import type { ProviderAuthStatus } from '../../../provider-auth/types';
type AgentConnectionCardProps = { type AgentConnectionCardProps = {
provider: CliProvider; provider: LLMProvider;
title: string; title: string;
status: ProviderAuthStatus; status: ProviderAuthStatus;
connectedClassName: string; connectedClassName: string;

View File

@@ -1,9 +1,10 @@
import type { CliProvider, ProviderStatusMap } from '../types'; import type { LLMProvider } from '../../../../types/app';
import type { ProviderAuthStatusMap } from '../../../provider-auth/types';
import AgentConnectionCard from './AgentConnectionCard'; import AgentConnectionCard from './AgentConnectionCard';
type AgentConnectionsStepProps = { type AgentConnectionsStepProps = {
providerStatuses: ProviderStatusMap; providerStatuses: ProviderAuthStatusMap;
onOpenProviderLogin: (provider: CliProvider) => void; onOpenProviderLogin: (provider: LLMProvider) => void;
}; };
const providerCards = [ const providerCards = [

View File

@@ -1,12 +0,0 @@
import type { CliProvider } from '../../provider-auth/types';
export type { CliProvider };
export type ProviderAuthStatus = {
authenticated: boolean;
email: string | null;
loading: boolean;
error: string | null;
};
export type ProviderStatusMap = Record<CliProvider, ProviderAuthStatus>;

View File

@@ -1,24 +1,5 @@
import { IS_PLATFORM } from '../../../constants/config';
import type { CliProvider, ProviderStatusMap } from './types';
export const cliProviders: CliProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
export const gitEmailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export const gitEmailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const selectedProject = {
name: 'default',
displayName: 'default',
fullPath: IS_PLATFORM ? '/workspace' : '',
path: IS_PLATFORM ? '/workspace' : '',
};
export const createInitialProviderStatuses = (): ProviderStatusMap => ({
claude: { authenticated: false, email: null, loading: true, error: null },
cursor: { authenticated: false, email: null, loading: true, error: null },
codex: { authenticated: false, email: null, loading: true, error: null },
gemini: { authenticated: false, email: null, loading: true, error: null },
});
export const readErrorMessageFromResponse = async (response: Response, fallback: string) => { export const readErrorMessageFromResponse = async (response: Response, fallback: string) => {
try { try {
const payload = (await response.json()) as { error?: string }; const payload = (await response.json()) as { error?: string };

View File

@@ -0,0 +1,109 @@
import { useCallback, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { LLMProvider } from '../../../types/app';
import {
CLI_AUTH_STATUS_ENDPOINTS,
CLI_PROVIDERS,
createInitialProviderAuthStatusMap,
} from '../types';
import type {
ProviderAuthStatus,
ProviderAuthStatusMap,
} from '../types';
type ProviderAuthStatusPayload = {
authenticated?: boolean;
email?: string | null;
method?: string | null;
error?: string | null;
};
const FALLBACK_STATUS_ERROR = 'Failed to check authentication status';
const FALLBACK_UNKNOWN_ERROR = 'Unknown error';
const toErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : FALLBACK_UNKNOWN_ERROR
);
const toProviderAuthStatus = (
payload: ProviderAuthStatusPayload,
fallbackError: string | null = null,
): ProviderAuthStatus => ({
authenticated: Boolean(payload.authenticated),
email: payload.email ?? null,
method: payload.method ?? null,
error: payload.error ?? fallbackError,
loading: false,
});
type UseProviderAuthStatusOptions = {
initialLoading?: boolean;
};
export function useProviderAuthStatus(
{ initialLoading = true }: UseProviderAuthStatusOptions = {},
) {
const [providerAuthStatus, setProviderAuthStatus] = useState<ProviderAuthStatusMap>(() => (
createInitialProviderAuthStatusMap(initialLoading)
));
const setProviderLoading = useCallback((provider: LLMProvider) => {
setProviderAuthStatus((previous) => ({
...previous,
[provider]: {
...previous[provider],
loading: true,
error: null,
},
}));
}, []);
const setProviderStatus = useCallback((provider: LLMProvider, status: ProviderAuthStatus) => {
setProviderAuthStatus((previous) => ({
...previous,
[provider]: status,
}));
}, []);
const checkProviderAuthStatus = useCallback(async (provider: LLMProvider) => {
setProviderLoading(provider);
try {
const response = await authenticatedFetch(CLI_AUTH_STATUS_ENDPOINTS[provider]);
if (!response.ok) {
setProviderStatus(provider, {
authenticated: false,
email: null,
method: null,
loading: false,
error: FALLBACK_STATUS_ERROR,
});
return;
}
const payload = (await response.json()) as ProviderAuthStatusPayload;
setProviderStatus(provider, toProviderAuthStatus(payload));
} catch (caughtError) {
console.error(`Error checking ${provider} auth status:`, caughtError);
setProviderStatus(provider, {
authenticated: false,
email: null,
method: null,
loading: false,
error: toErrorMessage(caughtError),
});
}
}, [setProviderLoading, setProviderStatus]);
const refreshProviderAuthStatuses = useCallback(async (providers: LLMProvider[] = CLI_PROVIDERS) => {
await Promise.all(providers.map((provider) => checkProviderAuthStatus(provider)));
}, [checkProviderAuthStatus]);
return {
providerAuthStatus,
setProviderAuthStatus,
checkProviderAuthStatus,
refreshProviderAuthStatuses,
};
}

View File

@@ -1 +1,27 @@
export type CliProvider = 'claude' | 'cursor' | 'codex' | 'gemini'; import type { LLMProvider } from '../../types/app';
export type ProviderAuthStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error: string | null;
loading: boolean;
};
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
export const CLI_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
claude: '/api/cli/claude/status',
cursor: '/api/cli/cursor/status',
codex: '/api/cli/codex/status',
gemini: '/api/cli/gemini/status',
};
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
claude: { authenticated: false, email: null, method: null, error: null, loading },
cursor: { authenticated: false, email: null, method: null, error: null, loading },
codex: { authenticated: false, email: null, method: null, error: null, loading },
gemini: { authenticated: false, email: null, method: null, error: null, loading },
});

View File

@@ -1,21 +1,12 @@
import { ExternalLink, KeyRound, X } from 'lucide-react'; import { ExternalLink, KeyRound, X } from 'lucide-react';
import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import { IS_PLATFORM } from '../../../constants/config'; import { DEFAULT_PROJECT_FOR_EMPTY_SHELL, IS_PLATFORM } from '../../../constants/config';
import type { CliProvider } from '../types'; import type { LLMProvider } from '../../../types/app';
type LoginModalProject = {
name?: string;
displayName?: string;
fullPath?: string;
path?: string;
[key: string]: unknown;
};
type ProviderLoginModalProps = { type ProviderLoginModalProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
provider?: CliProvider; provider?: LLMProvider;
project?: LoginModalProject | null;
onComplete?: (exitCode: number) => void; onComplete?: (exitCode: number) => void;
customCommand?: string; customCommand?: string;
isAuthenticated?: boolean; isAuthenticated?: boolean;
@@ -26,7 +17,7 @@ const getProviderCommand = ({
customCommand, customCommand,
isAuthenticated: _isAuthenticated, isAuthenticated: _isAuthenticated,
}: { }: {
provider: CliProvider; provider: LLMProvider;
customCommand?: string; customCommand?: string;
isAuthenticated: boolean; isAuthenticated: boolean;
}) => { }) => {
@@ -49,30 +40,17 @@ const getProviderCommand = ({
return 'gemini status'; return 'gemini status';
}; };
const getProviderTitle = (provider: CliProvider) => { const getProviderTitle = (provider: LLMProvider) => {
if (provider === 'claude') return 'Claude CLI Login'; if (provider === 'claude') return 'Claude CLI Login';
if (provider === 'cursor') return 'Cursor CLI Login'; if (provider === 'cursor') return 'Cursor CLI Login';
if (provider === 'codex') return 'Codex CLI Login'; if (provider === 'codex') return 'Codex CLI Login';
return 'Gemini CLI Configuration'; return 'Gemini CLI Configuration';
}; };
const normalizeProject = (project?: LoginModalProject | null) => {
const normalizedName = project?.name || 'default';
const normalizedFullPath = project?.fullPath ?? project?.path ?? (IS_PLATFORM ? '/workspace' : '');
return {
name: normalizedName,
displayName: project?.displayName || normalizedName,
fullPath: normalizedFullPath,
path: project?.path ?? normalizedFullPath,
};
};
export default function ProviderLoginModal({ export default function ProviderLoginModal({
isOpen, isOpen,
onClose, onClose,
provider = 'claude', provider = 'claude',
project = null,
onComplete, onComplete,
customCommand, customCommand,
isAuthenticated = false, isAuthenticated = false,
@@ -83,7 +61,6 @@ export default function ProviderLoginModal({
const command = getProviderCommand({ provider, customCommand, isAuthenticated }); const command = getProviderCommand({ provider, customCommand, isAuthenticated });
const title = getProviderTitle(provider); const title = getProviderTitle(provider);
const shellProject = normalizeProject(project);
const handleComplete = (exitCode: number) => { const handleComplete = (exitCode: number) => {
onComplete?.(exitCode); onComplete?.(exitCode);
@@ -158,7 +135,7 @@ export default function ProviderLoginModal({
</button> </button>
</div> </div>
) : ( ) : (
<StandaloneShell project={shellProject} command={command} onComplete={handleComplete} minimal={true} /> <StandaloneShell project={DEFAULT_PROJECT_FOR_EMPTY_SHELL} command={command} onComplete={handleComplete} minimal={true} />
)} )}
</div> </div>
</div> </div>

View File

@@ -2,21 +2,12 @@ import {
ArrowDown, ArrowDown,
Brain, Brain,
Eye, Eye,
FileText,
Languages, Languages,
Maximize2, Maximize2,
Mic,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import type { import type { PreferenceToggleItem } from './types';
PreferenceToggleItem,
WhisperMode,
WhisperOption,
} from './types';
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition'; export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
export const WHISPER_MODE_STORAGE_KEY = 'whisperMode';
export const WHISPER_MODE_CHANGED_EVENT = 'whisperModeChanged';
export const DEFAULT_HANDLE_POSITION = 50; export const DEFAULT_HANDLE_POSITION = 50;
export const HANDLE_POSITION_MIN = 10; export const HANDLE_POSITION_MIN = 10;
@@ -64,30 +55,3 @@ export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
icon: Languages, icon: Languages,
}, },
]; ];
export const WHISPER_OPTIONS: WhisperOption[] = [
{
value: 'default',
titleKey: 'quickSettings.whisper.modes.default',
descriptionKey: 'quickSettings.whisper.modes.defaultDescription',
icon: Mic,
},
{
value: 'prompt',
titleKey: 'quickSettings.whisper.modes.prompt',
descriptionKey: 'quickSettings.whisper.modes.promptDescription',
icon: Sparkles,
},
{
value: 'vibe',
titleKey: 'quickSettings.whisper.modes.vibe',
descriptionKey: 'quickSettings.whisper.modes.vibeDescription',
icon: FileText,
},
];
export const VIBE_MODE_ALIASES: WhisperMode[] = [
'vibe',
'instructions',
'architect',
];

View File

@@ -1,59 +0,0 @@
import { useCallback, useState } from 'react';
import {
VIBE_MODE_ALIASES,
WHISPER_MODE_CHANGED_EVENT,
WHISPER_MODE_STORAGE_KEY,
} from '../constants';
import type { WhisperMode, WhisperOptionValue } from '../types';
const ALL_VALID_MODES: WhisperMode[] = [
'default',
'prompt',
'vibe',
'instructions',
'architect',
];
const isWhisperMode = (value: string): value is WhisperMode => (
ALL_VALID_MODES.includes(value as WhisperMode)
);
const readStoredMode = (): WhisperMode => {
if (typeof window === 'undefined') {
return 'default';
}
const storedValue = localStorage.getItem(WHISPER_MODE_STORAGE_KEY);
if (!storedValue) {
return 'default';
}
return isWhisperMode(storedValue) ? storedValue : 'default';
};
export function useWhisperMode() {
const [whisperMode, setWhisperModeState] = useState<WhisperMode>(readStoredMode);
const setWhisperMode = useCallback((value: WhisperOptionValue) => {
setWhisperModeState(value);
localStorage.setItem(WHISPER_MODE_STORAGE_KEY, value);
window.dispatchEvent(new Event(WHISPER_MODE_CHANGED_EVENT));
}, []);
const isOptionSelected = useCallback(
(value: WhisperOptionValue) => {
if (value === 'vibe') {
return VIBE_MODE_ALIASES.includes(whisperMode);
}
return whisperMode === value;
},
[whisperMode],
);
return {
whisperMode,
setWhisperMode,
isOptionSelected,
};
}

View File

@@ -16,20 +16,4 @@ export type PreferenceToggleItem = {
icon: LucideIcon; icon: LucideIcon;
}; };
export type WhisperMode =
| 'default'
| 'prompt'
| 'vibe'
| 'instructions'
| 'architect';
export type WhisperOptionValue = 'default' | 'prompt' | 'vibe';
export type WhisperOption = {
value: WhisperOptionValue;
titleKey: string;
descriptionKey: string;
icon: LucideIcon;
};
export type QuickSettingsHandleStyle = CSSProperties; export type QuickSettingsHandleStyle = CSSProperties;

View File

@@ -15,7 +15,6 @@ import type {
} from '../types'; } from '../types';
import QuickSettingsSection from './QuickSettingsSection'; import QuickSettingsSection from './QuickSettingsSection';
import QuickSettingsToggleRow from './QuickSettingsToggleRow'; import QuickSettingsToggleRow from './QuickSettingsToggleRow';
import QuickSettingsWhisperSection from './QuickSettingsWhisperSection';
type QuickSettingsContentProps = { type QuickSettingsContentProps = {
isDarkMode: boolean; isDarkMode: boolean;
@@ -73,8 +72,6 @@ export default function QuickSettingsContent({
{t('quickSettings.sendByCtrlEnterDescription')} {t('quickSettings.sendByCtrlEnterDescription')}
</p> </p>
</QuickSettingsSection> </QuickSettingsSection>
<QuickSettingsWhisperSection />
</div> </div>
); );
} }

View File

@@ -1,44 +0,0 @@
import { useTranslation } from 'react-i18next';
import { TOGGLE_ROW_CLASS, WHISPER_OPTIONS } from '../constants';
import { useWhisperMode } from '../hooks/useWhisperMode';
import QuickSettingsSection from './QuickSettingsSection';
export default function QuickSettingsWhisperSection() {
const { t } = useTranslation('settings');
const { setWhisperMode, isOptionSelected } = useWhisperMode();
return (
// This section stays hidden intentionally until dictation modes are reintroduced.
<QuickSettingsSection
title={t('quickSettings.sections.whisperDictation')}
className="hidden"
>
<div className="space-y-2">
{WHISPER_OPTIONS.map(({ value, icon: Icon, titleKey, descriptionKey }) => (
<label
key={value}
className={`${TOGGLE_ROW_CLASS} flex items-start`}
>
<input
type="radio"
name="whisperMode"
value={value}
checked={isOptionSelected(value)}
onChange={() => setWhisperMode(value)}
className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-blue-500 dark:checked:bg-blue-600 dark:focus:ring-blue-400"
/>
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Icon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t(titleKey)}
</span>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t(descriptionKey)}
</p>
</div>
</label>
))}
</div>
</QuickSettingsSection>
);
}

View File

@@ -1,7 +1,6 @@
import type { import type {
AgentCategory, AgentCategory,
AgentProvider, AgentProvider,
AuthStatus,
ClaudeMcpFormState, ClaudeMcpFormState,
CodexMcpFormState, CodexMcpFormState,
CodeEditorSettingsState, CodeEditorSettingsState,
@@ -34,13 +33,6 @@ export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
fontSize: '14', fontSize: '14',
}; };
export const DEFAULT_AUTH_STATUS: AuthStatus = {
authenticated: false,
email: null,
loading: true,
error: null,
};
export const DEFAULT_MCP_TEST_RESULT: McpTestResult = { export const DEFAULT_MCP_TEST_RESULT: McpTestResult = {
success: false, success: false,
message: '', message: '',
@@ -88,9 +80,3 @@ export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {
skipPermissions: false, skipPermissions: false,
}; };
export const AUTH_STATUS_ENDPOINTS: Record<AgentProvider, string> = {
claude: '/api/cli/claude/status',
cursor: '/api/cli/cursor/status',
codex: '/api/cli/codex/status',
gemini: '/api/cli/gemini/status',
};

View File

@@ -1,15 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
import { import {
AUTH_STATUS_ENDPOINTS,
DEFAULT_AUTH_STATUS,
DEFAULT_CODE_EDITOR_SETTINGS, DEFAULT_CODE_EDITOR_SETTINGS,
DEFAULT_CURSOR_PERMISSIONS, DEFAULT_CURSOR_PERMISSIONS,
} from '../constants/constants'; } from '../constants/constants';
import type { import type {
AgentProvider, AgentProvider,
AuthStatus,
ClaudeMcpFormState, ClaudeMcpFormState,
ClaudePermissionsState, ClaudePermissionsState,
CodeEditorSettingsState, CodeEditorSettingsState,
@@ -23,7 +21,6 @@ import type {
NotificationPreferencesState, NotificationPreferencesState,
ProjectSortOrder, ProjectSortOrder,
SettingsMainTab, SettingsMainTab,
SettingsProject,
} from '../types/types'; } from '../types/types';
type ThemeContextValue = { type ThemeContextValue = {
@@ -34,15 +31,6 @@ type ThemeContextValue = {
type UseSettingsControllerArgs = { type UseSettingsControllerArgs = {
isOpen: boolean; isOpen: boolean;
initialTab: string; initialTab: string;
projects: SettingsProject[];
onClose: () => void;
};
type StatusApiResponse = {
authenticated?: boolean;
email?: string | null;
error?: string | null;
method?: string;
}; };
type JsonResult = { type JsonResult = {
@@ -166,20 +154,6 @@ const mapCliServersToMcpServers = (servers: McpCliServer[] = []): McpServer[] =>
})) }))
); );
const getDefaultProject = (projects: SettingsProject[]): SettingsProject => {
if (projects.length > 0) {
return projects[0];
}
const cwd = typeof process !== 'undefined' && process.cwd ? process.cwd() : '';
return {
name: 'default',
displayName: 'default',
fullPath: cwd,
path: cwd,
};
};
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>; const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
const createEmptyClaudePermissions = (): ClaudePermissionsState => ({ const createEmptyClaudePermissions = (): ClaudePermissionsState => ({
@@ -204,7 +178,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
}, },
}); });
export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) { export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue; const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
const closeTimerRef = useRef<number | null>(null); const closeTimerRef = useRef<number | null>(null);
@@ -242,64 +216,11 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
const [showLoginModal, setShowLoginModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false);
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>(''); const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
const [selectedProject, setSelectedProject] = useState<SettingsProject | null>(null); const {
providerAuthStatus,
const [claudeAuthStatus, setClaudeAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS); checkProviderAuthStatus,
const [cursorAuthStatus, setCursorAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS); refreshProviderAuthStatuses,
const [codexAuthStatus, setCodexAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS); } = useProviderAuthStatus();
const [geminiAuthStatus, setGeminiAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
const setAuthStatusByProvider = useCallback((provider: AgentProvider, status: AuthStatus) => {
if (provider === 'claude') {
setClaudeAuthStatus(status);
return;
}
if (provider === 'cursor') {
setCursorAuthStatus(status);
return;
}
if (provider === 'gemini') {
setGeminiAuthStatus(status);
return;
}
setCodexAuthStatus(status);
}, []);
const checkAuthStatus = useCallback(async (provider: AgentProvider) => {
try {
const response = await authenticatedFetch(AUTH_STATUS_ENDPOINTS[provider]);
if (!response.ok) {
setAuthStatusByProvider(provider, {
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status',
});
return;
}
const data = await toResponseJson<StatusApiResponse>(response);
setAuthStatusByProvider(provider, {
authenticated: Boolean(data.authenticated),
email: data.email || null,
loading: false,
error: data.error || null,
method: data.method,
});
} catch (error) {
console.error(`Error checking ${provider} auth status:`, error);
setAuthStatusByProvider(provider, {
authenticated: false,
email: null,
loading: false,
error: getErrorMessage(error),
});
}
}, [setAuthStatusByProvider]);
const fetchCursorMcpServers = useCallback(async () => { const fetchCursorMcpServers = useCallback(async () => {
try { try {
@@ -724,9 +645,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
const openLoginForProvider = useCallback((provider: AgentProvider) => { const openLoginForProvider = useCallback((provider: AgentProvider) => {
setLoginProvider(provider); setLoginProvider(provider);
setSelectedProject(getDefaultProject(projects));
setShowLoginModal(true); setShowLoginModal(true);
}, [projects]); }, []);
const handleLoginComplete = useCallback((exitCode: number) => { const handleLoginComplete = useCallback((exitCode: number) => {
if (exitCode !== 0 || !loginProvider) { if (exitCode !== 0 || !loginProvider) {
@@ -734,8 +654,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
} }
setSaveStatus('success'); setSaveStatus('success');
void checkAuthStatus(loginProvider); void checkProviderAuthStatus(loginProvider);
}, [checkAuthStatus, loginProvider]); }, [checkProviderAuthStatus, loginProvider]);
const saveSettings = useCallback(async () => { const saveSettings = useCallback(async () => {
setSaveStatus(null); setSaveStatus(null);
@@ -827,11 +747,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
setActiveTab(normalizeMainTab(initialTab)); setActiveTab(normalizeMainTab(initialTab));
void loadSettings(); void loadSettings();
void checkAuthStatus('claude'); void refreshProviderAuthStatuses();
void checkAuthStatus('cursor'); }, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
void checkAuthStatus('codex');
void checkAuthStatus('gemini');
}, [checkAuthStatus, initialTab, isOpen, loadSettings]);
useEffect(() => { useEffect(() => {
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme); localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
@@ -935,17 +852,13 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
closeCodexMcpForm, closeCodexMcpForm,
submitCodexMcpForm, submitCodexMcpForm,
handleCodexMcpDelete, handleCodexMcpDelete,
claudeAuthStatus, providerAuthStatus,
cursorAuthStatus,
codexAuthStatus,
geminiAuthStatus,
geminiPermissionMode, geminiPermissionMode,
setGeminiPermissionMode, setGeminiPermissionMode,
openLoginForProvider, openLoginForProvider,
showLoginModal, showLoginModal,
setShowLoginModal, setShowLoginModal,
loginProvider, loginProvider,
selectedProject,
handleLoginComplete, handleLoginComplete,
}; };
} }

View File

@@ -1,7 +1,9 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import type { LLMProvider } from '../../../types/app';
import type { ProviderAuthStatus } from '../../provider-auth/types';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins' | 'about'; export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins' | 'about';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini'; export type AgentProvider = LLMProvider;
export type AgentCategory = 'account' | 'permissions' | 'mcp'; export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date'; export type ProjectSortOrder = 'name' | 'date';
export type SaveStatus = 'success' | 'error' | null; export type SaveStatus = 'success' | 'error' | null;
@@ -18,13 +20,7 @@ export type SettingsProject = {
path?: string; path?: string;
}; };
export type AuthStatus = { export type AuthStatus = ProviderAuthStatus;
authenticated: boolean;
email: string | null;
loading: boolean;
error: string | null;
method?: string;
};
export type KeyValueMap = Record<string, string>; export type KeyValueMap = Record<string, string>;

View File

@@ -56,23 +56,17 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
closeCodexMcpForm, closeCodexMcpForm,
submitCodexMcpForm, submitCodexMcpForm,
handleCodexMcpDelete, handleCodexMcpDelete,
claudeAuthStatus, providerAuthStatus,
cursorAuthStatus,
codexAuthStatus,
geminiAuthStatus,
geminiPermissionMode, geminiPermissionMode,
setGeminiPermissionMode, setGeminiPermissionMode,
openLoginForProvider, openLoginForProvider,
showLoginModal, showLoginModal,
setShowLoginModal, setShowLoginModal,
loginProvider, loginProvider,
selectedProject,
handleLoginComplete, handleLoginComplete,
} = useSettingsController({ } = useSettingsController({
isOpen, isOpen,
initialTab, initialTab
projects,
onClose,
}); });
const { const {
@@ -105,13 +99,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
return null; return null;
} }
const isAuthenticated = loginProvider === 'claude' const isAuthenticated = Boolean(loginProvider && providerAuthStatus[loginProvider].authenticated);
? claudeAuthStatus.authenticated
: loginProvider === 'cursor'
? cursorAuthStatus.authenticated
: loginProvider === 'codex'
? codexAuthStatus.authenticated
: false;
return ( return (
<div className="modal-backdrop fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm md:p-4"> <div className="modal-backdrop fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm md:p-4">
@@ -121,7 +109,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
<h2 className="text-base font-semibold text-foreground">{t('title')}</h2> <h2 className="text-base font-semibold text-foreground">{t('title')}</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{saveStatus === 'success' && ( {saveStatus === 'success' && (
<span className="text-xs text-muted-foreground animate-in fade-in">{t('saveStatus.success')}</span> <span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>
)} )}
<Button <Button
variant="ghost" variant="ghost"
@@ -158,14 +146,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
{activeTab === 'agents' && ( {activeTab === 'agents' && (
<AgentsSettingsTab <AgentsSettingsTab
claudeAuthStatus={claudeAuthStatus} providerAuthStatus={providerAuthStatus}
cursorAuthStatus={cursorAuthStatus} onProviderLogin={openLoginForProvider}
codexAuthStatus={codexAuthStatus}
geminiAuthStatus={geminiAuthStatus}
onClaudeLogin={() => openLoginForProvider('claude')}
onCursorLogin={() => openLoginForProvider('cursor')}
onCodexLogin={() => openLoginForProvider('codex')}
onGeminiLogin={() => openLoginForProvider('gemini')}
claudePermissions={claudePermissions} claudePermissions={claudePermissions}
onClaudePermissionsChange={setClaudePermissions} onClaudePermissionsChange={setClaudePermissions}
cursorPermissions={cursorPermissions} cursorPermissions={cursorPermissions}
@@ -219,7 +201,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
isOpen={showLoginModal} isOpen={showLoginModal}
onClose={() => setShowLoginModal(false)} onClose={() => setShowLoginModal(false)}
provider={loginProvider || 'claude'} provider={loginProvider || 'claude'}
project={selectedProject}
onComplete={handleLoginComplete} onComplete={handleLoginComplete}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
/> />

View File

@@ -6,14 +6,8 @@ import AgentSelectorSection from './sections/AgentSelectorSection';
import type { AgentContext, AgentsSettingsTabProps } from './types'; import type { AgentContext, AgentsSettingsTabProps } from './types';
export default function AgentsSettingsTab({ export default function AgentsSettingsTab({
claudeAuthStatus, providerAuthStatus,
cursorAuthStatus, onProviderLogin,
codexAuthStatus,
geminiAuthStatus,
onClaudeLogin,
onCursorLogin,
onCodexLogin,
onGeminiLogin,
claudePermissions, claudePermissions,
onClaudePermissionsChange, onClaudePermissionsChange,
cursorPermissions, cursorPermissions,
@@ -41,30 +35,27 @@ export default function AgentsSettingsTab({
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({ const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
claude: { claude: {
authStatus: claudeAuthStatus, authStatus: providerAuthStatus.claude,
onLogin: onClaudeLogin, onLogin: () => onProviderLogin('claude'),
}, },
cursor: { cursor: {
authStatus: cursorAuthStatus, authStatus: providerAuthStatus.cursor,
onLogin: onCursorLogin, onLogin: () => onProviderLogin('cursor'),
}, },
codex: { codex: {
authStatus: codexAuthStatus, authStatus: providerAuthStatus.codex,
onLogin: onCodexLogin, onLogin: () => onProviderLogin('codex'),
}, },
gemini: { gemini: {
authStatus: geminiAuthStatus, authStatus: providerAuthStatus.gemini,
onLogin: onGeminiLogin, onLogin: () => onProviderLogin('gemini'),
}, },
}), [ }), [
claudeAuthStatus, onProviderLogin,
codexAuthStatus, providerAuthStatus.claude,
cursorAuthStatus, providerAuthStatus.codex,
geminiAuthStatus, providerAuthStatus.cursor,
onClaudeLogin, providerAuthStatus.gemini,
onCodexLogin,
onCursorLogin,
onGeminiLogin,
]); ]);
return ( return (

View File

@@ -17,16 +17,11 @@ export type AgentContext = {
}; };
export type AgentContextByProvider = Record<AgentProvider, AgentContext>; export type AgentContextByProvider = Record<AgentProvider, AgentContext>;
export type ProviderAuthStatusByProvider = Record<AgentProvider, AuthStatus>;
export type AgentsSettingsTabProps = { export type AgentsSettingsTabProps = {
claudeAuthStatus: AuthStatus; providerAuthStatus: ProviderAuthStatusByProvider;
cursorAuthStatus: AuthStatus; onProviderLogin: (provider: AgentProvider) => void;
codexAuthStatus: AuthStatus;
geminiAuthStatus: AuthStatus;
onClaudeLogin: () => void;
onCursorLogin: () => void;
onCodexLogin: () => void;
onGeminiLogin: () => void;
claudePermissions: ClaudePermissionsState; claudePermissions: ClaudePermissionsState;
onClaudePermissionsChange: (value: ClaudePermissionsState) => void; onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
cursorPermissions: CursorPermissionsState; cursorPermissions: CursorPermissionsState;

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type React from 'react'; import type React from 'react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { api } from '../../../utils/api'; import { api } from '../../../utils/api';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { import type {
AdditionalSessionsByProject, AdditionalSessionsByProject,
DeleteProjectConfirmation, DeleteProjectConfirmation,
@@ -452,7 +452,7 @@ export function useSidebarController({
[getProjectSessions], [getProjectSessions],
); );
const confirmDeleteProject = useCallback(async () => { const confirmDeleteProject = useCallback(async (deleteData = false) => {
if (!deleteConfirmation) { if (!deleteConfirmation) {
return; return;
} }
@@ -464,7 +464,7 @@ export function useSidebarController({
setDeletingProjects((prev) => new Set([...prev, project.name])); setDeletingProjects((prev) => new Set([...prev, project.name]));
try { try {
const response = await api.deleteProject(project.name, !isEmpty); const response = await api.deleteProject(project.name, !isEmpty, deleteData);
if (response.ok) { if (response.ok) {
onProjectDelete?.(project.name); onProjectDelete?.(project.name);
@@ -545,7 +545,7 @@ export function useSidebarController({
}, [onRefresh]); }, [onRefresh]);
const updateSessionSummary = useCallback( const updateSessionSummary = useCallback(
async (_projectName: string, sessionId: string, summary: string, provider: SessionProvider) => { async (_projectName: string, sessionId: string, summary: string, provider: LLMProvider) => {
const trimmed = summary.trim(); const trimmed = summary.trim();
if (!trimmed) { if (!trimmed) {
setEditingSession(null); setEditingSession(null);

View File

@@ -1,9 +1,9 @@
import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../../types/app'; import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app';
export type ProjectSortOrder = 'name' | 'date'; export type ProjectSortOrder = 'name' | 'date';
export type SessionWithProvider = ProjectSession & { export type SessionWithProvider = ProjectSession & {
__provider: SessionProvider; __provider: LLMProvider;
}; };
export type AdditionalSessionsByProject = Record<string, ProjectSession[]>; export type AdditionalSessionsByProject = Record<string, ProjectSession[]>;
@@ -18,7 +18,7 @@ export type SessionDeleteConfirmation = {
projectName: string; projectName: string;
sessionId: string; sessionId: string;
sessionTitle: string; sessionTitle: string;
provider: SessionProvider; provider: LLMProvider;
}; };
export type SidebarProps = { export type SidebarProps = {

View File

@@ -6,7 +6,7 @@ import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { useSidebarController } from '../hooks/useSidebarController'; import { useSidebarController } from '../hooks/useSidebarController';
import { useTaskMaster } from '../../../contexts/TaskMasterContext'; import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import type { Project, SessionProvider } from '../../../types/app'; import type { Project, LLMProvider } from '../../../types/app';
import type { MCPServerStatus, SidebarProps } from '../types/types'; import type { MCPServerStatus, SidebarProps } from '../types/types';
import SidebarCollapsed from './subcomponents/SidebarCollapsed'; import SidebarCollapsed from './subcomponents/SidebarCollapsed';
import SidebarContent from './subcomponents/SidebarContent'; import SidebarContent from './subcomponents/SidebarContent';
@@ -177,7 +177,7 @@ function Sidebar({
setEditingSession(null); setEditingSession(null);
setEditingSessionName(''); setEditingSessionName('');
}, },
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => { onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: LLMProvider) => {
void updateSessionSummary(projectName, sessionId, summary, provider); void updateSessionSummary(projectName, sessionId, summary, provider);
}, },
t, t,
@@ -235,7 +235,7 @@ function Sidebar({
isSearching={isSearching} isSearching={isSearching}
searchProgress={searchProgress} searchProgress={searchProgress}
onConversationResultClick={(projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => { onConversationResultClick={(projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => {
const resolvedProvider = (provider || 'claude') as SessionProvider; const resolvedProvider = (provider || 'claude') as LLMProvider;
const project = projects.find(p => p.name === projectName); const project = projects.find(p => p.name === projectName);
const searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null }; const searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null };
const sessionObj = { const sessionObj = {

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { AlertTriangle, Trash2 } from 'lucide-react'; import { AlertTriangle, EyeOff, Trash2 } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { Button } from '../../../../shared/view/ui'; import { Button } from '../../../../shared/view/ui';
import Settings from '../../../settings/view/Settings'; import Settings from '../../../settings/view/Settings';
@@ -22,7 +22,7 @@ type SidebarModalsProps = {
onProjectCreated: () => void; onProjectCreated: () => void;
deleteConfirmation: DeleteProjectConfirmation | null; deleteConfirmation: DeleteProjectConfirmation | null;
onCancelDeleteProject: () => void; onCancelDeleteProject: () => void;
onConfirmDeleteProject: () => void; onConfirmDeleteProject: (deleteData?: boolean) => void;
sessionDeleteConfirmation: SessionDeleteConfirmation | null; sessionDeleteConfirmation: SessionDeleteConfirmation | null;
onCancelDeleteSession: () => void; onCancelDeleteSession: () => void;
onConfirmDeleteSession: () => void; onConfirmDeleteSession: () => void;
@@ -104,8 +104,8 @@ export default function SidebarModals({
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl"> <div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
<div className="p-6"> <div className="p-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30"> <div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/30">
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" /> <AlertTriangle className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="mb-2 text-lg font-semibold text-foreground"> <h3 className="mb-2 text-lg font-semibold text-foreground">
@@ -119,32 +119,32 @@ export default function SidebarModals({
? ?
</p> </p>
{deleteConfirmation.sessionCount > 0 && ( {deleteConfirmation.sessionCount > 0 && (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20"> <p className="mt-2 text-sm text-muted-foreground">
<p className="text-sm font-medium text-red-700 dark:text-red-300"> {t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })} </p>
</p>
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{t('deleteConfirmation.allConversationsDeleted')}
</p>
</div>
)} )}
<p className="mt-3 text-xs text-muted-foreground">
{t('deleteConfirmation.cannotUndo')}
</p>
</div> </div>
</div> </div>
</div> </div>
<div className="flex gap-3 border-t border-border bg-muted/30 p-4"> <div className="flex flex-col gap-2 border-t border-border bg-muted/30 p-4">
<Button variant="outline" className="flex-1" onClick={onCancelDeleteProject}> <Button
{t('actions.cancel')} variant="outline"
className="w-full justify-start"
onClick={() => onConfirmDeleteProject(false)}
>
<EyeOff className="mr-2 h-4 w-4" />
{t('deleteConfirmation.removeFromSidebar')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
className="flex-1 bg-red-600 text-white hover:bg-red-700" className="w-full justify-start bg-red-600 text-white hover:bg-red-700"
onClick={onConfirmDeleteProject} onClick={() => onConfirmDeleteProject(true)}
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{t('actions.delete')} {t('deleteConfirmation.deleteAllData')}
</Button>
<Button variant="ghost" className="w-full" onClick={onCancelDeleteProject}>
{t('actions.cancel')}
</Button> </Button>
</div> </div>
</div> </div>

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