Compare commits

..

31 Commits

Author SHA1 Message Date
simosmik
863a98d583 chore: merge refactor/split-server-index into refactor/providers (brings main up to v1.29.5) 2026-04-17 08:47:36 +00:00
simosmik
acfa7cfffb chore: merge main into refactor/split-server-index (up to v1.29.5) 2026-04-17 08:39:36 +00:00
simosmik
3e99187a01 chore: add dist-server to gitignore 2026-04-17 08:21:18 +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
simosmik
ef916615f8 refactor: creating providers placeholders and barrel file 2026-04-16 07:51:17 +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
Simos Mikelatos
9ddda5ba5e Merge branch 'main' into refactor/split-server-index 2026-04-16 09:45:52 +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
9f99f6ab53 refactor(server): extract URL detection and color utils from index.js
No behavioral changes — 1:1 code move with imports replacing inline definitions.
2026-04-14 22:15:05 +00: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
131 changed files with 4527 additions and 3877 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

4
.gitignore vendored
View File

@@ -8,7 +8,7 @@ lerna-debug.log*
# Build outputs # Build outputs
dist/ dist/
dist-ssr/ dist-server/
build/ build/
out/ out/
@@ -138,4 +138,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

@@ -585,7 +585,7 @@
<p>Server-sent events (SSE) format with real-time updates. Content-Type: <code>text/event-stream</code></p> <p>Server-sent events (SSE) format with real-time updates. Content-Type: <code>text/event-stream</code></p>
<h4>Response (Non-Streaming)</h4> <h4>Response (Non-Streaming)</h4>
<p>JSON object containing session details and assistant messages only (filtered). Content-Type: <code>application/json</code></p> <p>JSON object containing session details, assistant messages only (filtered), and token usage summary. Content-Type: <code>application/json</code></p>
<h4>Error Response</h4> <h4>Error Response</h4>
<p>Returns error details with appropriate HTTP status code.</p> <p>Returns error details with appropriate HTTP status code.</p>
@@ -674,10 +674,21 @@ data: {"type":"done"}</code></pre>
"type": "text", "type": "text",
"text": "I've completed the task..." "text": "I've completed the task..."
} }
] ],
"usage": {
"input_tokens": 150,
"output_tokens": 50
}
} }
} }
], ],
"tokens": {
"inputTokens": 150,
"outputTokens": 50,
"cacheReadTokens": 0,
"cacheCreationTokens": 0,
"totalTokens": 200
},
"projectPath": "/path/to/project", "projectPath": "/path/to/project",
"branch": { "branch": {
"name": "fix-authentication-bug-abc123", "name": "fix-authentication-bug-abc123",

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

@@ -24,15 +24,16 @@ import {
notifyRunStopped, notifyRunStopped,
notifyUserIfEnabled notifyUserIfEnabled
} from './services/notification-orchestrator.js'; } from './services/notification-orchestrator.js';
import { claudeAdapter } from './providers/claude/adapter.js'; import { claudeAdapter } from './providers/claude/index.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;
@@ -274,6 +279,46 @@ function transformMessage(sdkMessage) {
return sdkMessage; return sdkMessage;
} }
/**
* Extracts token usage from SDK result messages
* @param {Object} resultMessage - SDK result message
* @returns {Object|null} Token budget object or null
*/
function extractTokenBudget(resultMessage) {
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
return null;
}
// Get the first model's usage data
const modelKey = Object.keys(resultMessage.modelUsage)[0];
const modelData = resultMessage.modelUsage[modelKey];
if (!modelData) {
return null;
}
// Use cumulative tokens if available (tracks total for the session)
// Otherwise fall back to per-request tokens
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
// Total used = input + output + cache tokens
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
// Use configured context window budget from environment (default 160000)
// This is the user's budget limit, not the model's context window
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
// Token calc logged via token-budget WS event
return {
used: totalUsed,
total: contextWindow
};
}
/** /**
* Handles image processing for SDK queries * Handles image processing for SDK queries
* Saves base64 images to temporary files and returns modified prompt with file paths * Saves base64 images to temporary files and returns modified prompt with file paths
@@ -617,6 +662,18 @@ async function queryClaudeSDK(command, options = {}, ws) {
} }
ws.send(msg); ws.send(msg);
} }
// Extract and send token budget updates from result messages
if (message.type === 'result') {
const models = Object.keys(message.modelUsage || {});
if (models.length > 0) {
// Model info available in result message
}
const tokenBudgetData = extractTokenBudget(message);
if (tokenBudgetData) {
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
}
}
} }
// Clean up session on completion // Clean up session on completion
@@ -649,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',
@@ -658,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

@@ -1,8 +1,9 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn'; 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/index.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

@@ -1,5 +1,5 @@
// Gemini Response Handler - JSON Stream processing // Gemini Response Handler - JSON Stream processing
import { geminiAdapter } from './providers/gemini/adapter.js'; import { geminiAdapter } from './providers/gemini/index.js';
class GeminiResponseHandler { class GeminiResponseHandler {
constructor(ws, options = {}) { constructor(ws, options = {}) {

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 {
@@ -2218,6 +2000,194 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
} }
}); });
// Get token usage for a specific session
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
const { provider = 'claude' } = req.query;
const homeDir = os.homedir();
// Allow only safe characters in sessionId
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeSessionId || safeSessionId !== String(sessionId)) {
return res.status(400).json({ error: 'Invalid sessionId' });
}
// Handle Cursor sessions - they use SQLite and don't have token usage info
if (provider === 'cursor') {
return res.json({
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking not available for Cursor sessions'
});
}
// Handle Gemini sessions - they are raw logs in our current setup
if (provider === 'gemini') {
return res.json({
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking not available for Gemini sessions'
});
}
// Handle Codex sessions
if (provider === 'codex') {
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
// Find the session file by searching for the session ID
const findSessionFile = async (dir) => {
try {
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = await findSessionFile(fullPath);
if (found) return found;
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
return fullPath;
}
}
} catch (error) {
// Skip directories we can't read
}
return null;
};
const sessionFilePath = await findSessionFile(codexSessionsDir);
if (!sessionFilePath) {
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
}
// Read and parse the Codex JSONL file
let fileContent;
try {
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
}
throw error;
}
const lines = fileContent.trim().split('\n');
let totalTokens = 0;
let contextWindow = 200000; // Default for Codex/OpenAI
// Find the latest token_count event with info (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
// Codex stores token info in event_msg with type: "token_count"
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const tokenInfo = entry.payload.info;
if (tokenInfo.total_token_usage) {
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
}
if (tokenInfo.model_context_window) {
contextWindow = tokenInfo.model_context_window;
}
break; // Stop after finding the latest token count
}
} catch (parseError) {
// Skip lines that can't be parsed
continue;
}
}
return res.json({
used: totalTokens,
total: contextWindow
});
}
// Handle Claude sessions (default)
// Extract actual project path
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
console.error('Error extracting project directory:', error);
return res.status(500).json({ error: 'Failed to determine project path' });
}
// Construct the JSONL file path
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
// The encoding replaces any non-alphanumeric character (except -) with -
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
// Constrain to projectDir
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}
// Read and parse the JSONL file
let fileContent;
try {
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
}
throw error; // Re-throw other errors to be caught by outer try-catch
}
const lines = fileContent.trim().split('\n');
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
let inputTokens = 0;
let cacheCreationTokens = 0;
let cacheReadTokens = 0;
// Find the latest assistant message with usage data (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
// Only count assistant messages which have usage data
if (entry.type === 'assistant' && entry.message?.usage) {
const usage = entry.message.usage;
// Use token counts from latest assistant message only
inputTokens = usage.input_tokens || 0;
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
cacheReadTokens = usage.cache_read_input_tokens || 0;
break; // Stop after finding the latest assistant message
}
} catch (parseError) {
// Skip lines that can't be parsed
continue;
}
}
// Calculate total context usage (excluding output_tokens, as per ccusage)
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
res.json({
used: totalUsed,
total: contextWindow,
breakdown: {
input: inputTokens,
cacheCreation: cacheCreationTokens,
cacheRead: cacheReadTokens
}
});
} catch (error) {
console.error('Error reading session token usage:', error);
res.status(500).json({ error: 'Failed to read session token usage' });
}
});
// Serve React app for all other routes (excluding static files) // Serve React app for all other routes (excluding static files)
app.get('*', (req, res) => { app.get('*', (req, res) => {
// Skip requests for static assets (files with extensions) // Skip requests for static assets (files with extensions)
@@ -2227,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)) {
@@ -2342,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
@@ -2356,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

@@ -15,8 +15,9 @@
import { Codex } from '@openai/codex-sdk'; 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/index.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();
@@ -129,7 +130,8 @@ function transformCodexEvent(event) {
case 'turn.completed': case 'turn.completed':
return { return {
type: 'turn_complete' type: 'turn_complete',
usage: event.usage
}; };
case 'turn.failed': case 'turn.failed':
@@ -278,6 +280,12 @@ export async function queryCodex(command, options = {}, ws) {
error: terminalFailure error: terminalFailure
}); });
} }
// Extract and send token usage if available (normalized to match Claude format)
if (event.type === 'turn.completed' && event.usage) {
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: currentSessionId, provider: 'codex' }));
}
} }
// Send completion event // Send completion event
@@ -301,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';
@@ -1618,6 +1612,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
} }
const messages = []; const messages = [];
let tokenUsage = null;
const fileStream = fsSync.createReadStream(sessionFilePath); const fileStream = fsSync.createReadStream(sessionFilePath);
const rl = readline.createInterface({ const rl = readline.createInterface({
input: fileStream, input: fileStream,
@@ -1646,6 +1641,17 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
try { try {
const entry = JSON.parse(line); const entry = JSON.parse(line);
// Extract token usage from token_count events (keep latest)
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const info = entry.payload.info;
if (info.total_token_usage) {
tokenUsage = {
used: info.total_token_usage.total_tokens || 0,
total: info.model_context_window || 200000
};
}
}
// Use event_msg.user_message for user-visible inputs. // Use event_msg.user_message for user-visible inputs.
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) { if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
messages.push({ messages.push({
@@ -1808,10 +1814,11 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
hasMore, hasMore,
offset, offset,
limit, limit,
tokenUsage
}; };
} }
return { messages }; return { messages, tokenUsage };
} catch (error) { } catch (error) {
console.error(`Error reading Codex session messages for ${sessionId}:`, error); console.error(`Error reading Codex session messages for ${sessionId}:`, error);

View File

@@ -5,7 +5,6 @@
* @module adapters/claude * @module adapters/claude
*/ */
import { getSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js'; import { createNormalizedMessage, generateMessageId } from '../types.js';
import { isInternalContent } from '../utils.js'; import { isInternalContent } from '../utils.js';
@@ -200,79 +199,3 @@ export function normalizeMessage(raw, sessionId) {
return messages; return messages;
} }
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const claudeAdapter = {
normalizeMessage,
/**
* Fetch session history from JSONL files, returning normalized messages.
*/
async fetchHistory(sessionId, opts = {}) {
const { projectName, limit = null, offset = 0 } = opts;
if (!projectName) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
let result;
try {
result = await getSessionMessages(projectName, sessionId, limit, offset);
} catch (error) {
console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
// getSessionMessages returns either an array (no limit) or { messages, total, hasMore }
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
// First pass: collect tool results for attachment to tool_use messages
const toolResultMap = new Map();
for (const raw of rawMessages) {
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result') {
toolResultMap.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
timestamp: raw.timestamp,
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
});
}
}
}
}
// Second pass: normalize all messages
const normalized = [];
for (const raw of rawMessages) {
const entries = normalizeMessage(raw, sessionId);
normalized.push(...entries);
}
// Attach tool results to their corresponding tool_use messages
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = {
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
isError: tr.isError,
toolUseResult: tr.toolUseResult,
};
msg.subagentTools = tr.subagentTools;
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
};
},
};

View File

@@ -0,0 +1 @@
// TODO: migrate Claude session list/delete endpoints from server/index.js

View File

@@ -0,0 +1,8 @@
/**
* Claude provider barrel.
* Assembles the ProviderAdapter from adapter + sessions.
*/
import { normalizeMessage } from './adapter.js';
import { fetchHistory } from './sessions.js';
export const claudeAdapter = { normalizeMessage, fetchHistory };

View File

@@ -0,0 +1,82 @@
/**
* Claude provider session history.
*
* Fetches and normalizes persisted JSONL session data.
* @module adapters/claude/sessions
*/
import { normalizeMessage } from './adapter.js';
import { getSessionMessages } from '../../projects.js';
import { createNormalizedMessage } from '../types.js';
/**
* Fetch session history from JSONL files, returning normalized messages.
* @param {string} sessionId
* @param {import('../types.js').FetchHistoryOptions} opts
* @returns {Promise<import('../types.js').FetchHistoryResult>}
*/
export async function fetchHistory(sessionId, opts = {}) {
const { projectName, limit = null, offset = 0 } = opts;
if (!projectName) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
let result;
try {
result = await getSessionMessages(projectName, sessionId, limit, offset);
} catch (error) {
console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
// getSessionMessages returns either an array (no limit) or { messages, total, hasMore }
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
// First pass: collect tool results for attachment to tool_use messages
const toolResultMap = new Map();
for (const raw of rawMessages) {
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result') {
toolResultMap.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
timestamp: raw.timestamp,
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
});
}
}
}
}
// Second pass: normalize all messages
const normalized = [];
for (const raw of rawMessages) {
const entries = normalizeMessage(raw, sessionId);
normalized.push(...entries);
}
// Attach tool results to their corresponding tool_use messages
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = {
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
isError: tr.isError,
toolUseResult: tr.toolUseResult,
};
msg.subagentTools = tr.subagentTools;
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
};
}

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

@@ -5,7 +5,6 @@
* @module adapters/codex * @module adapters/codex
*/ */
import { getCodexSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js'; import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'codex'; const PROVIDER = 'codex';
@@ -16,7 +15,7 @@ const PROVIDER = 'codex';
* @param {string} sessionId * @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]} * @returns {import('../types.js').NormalizedMessage[]}
*/ */
function normalizeCodexHistoryEntry(raw, sessionId) { export function normalizeCodexHistoryEntry(raw, sessionId) {
const ts = raw.timestamp || new Date().toISOString(); const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex'); const baseId = raw.uuid || generateMessageId('codex');
@@ -191,56 +190,3 @@ export function normalizeMessage(raw, sessionId) {
return []; return [];
} }
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const codexAdapter = {
normalizeMessage,
/**
* Fetch session history from Codex JSONL files.
*/
async fetchHistory(sessionId, opts = {}) {
const { limit = null, offset = 0 } = opts;
let result;
try {
result = await getCodexSessionMessages(sessionId, limit, offset);
} catch (error) {
console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const normalized = [];
for (const raw of rawMessages) {
const entries = normalizeCodexHistoryEntry(raw, sessionId);
normalized.push(...entries);
}
// Attach tool results to tool_use messages
const toolResultMap = new Map();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = { content: tr.content, isError: tr.isError };
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
};
},
};

View File

@@ -0,0 +1 @@
// TODO: migrate GET /config from server/routes/codex.js

View File

@@ -0,0 +1,8 @@
/**
* Codex provider barrel.
* Assembles the ProviderAdapter from adapter + sessions.
*/
import { normalizeMessage } from './adapter.js';
import { fetchHistory } from './sessions.js';
export const codexAdapter = { normalizeMessage, fetchHistory };

View File

@@ -0,0 +1 @@
// TODO: migrate MCP CRUD endpoints from server/routes/codex.js

View File

@@ -0,0 +1,63 @@
/**
* Codex session history fetcher.
*
* Extracted from adapter.js — pure data-access concern.
* @module providers/codex/sessions
*/
import { normalizeCodexHistoryEntry } from './adapter.js';
import { getCodexSessionMessages } from '../../projects.js';
/**
* Fetch session history from Codex JSONL files.
* @param {string} sessionId
* @param {object} opts
* @param {number|null} [opts.limit]
* @param {number} [opts.offset]
* @returns {Promise<{messages: import('../../providers/types.js').NormalizedMessage[], total: number, hasMore: boolean, offset: number, limit: number|null, tokenUsage: object|null}>}
*/
export async function fetchHistory(sessionId, opts = {}) {
const { limit = null, offset = 0 } = opts;
let result;
try {
result = await getCodexSessionMessages(sessionId, limit, offset);
} catch (error) {
console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const tokenUsage = result.tokenUsage || null;
const normalized = [];
for (const raw of rawMessages) {
const entries = normalizeCodexHistoryEntry(raw, sessionId);
normalized.push(...entries);
}
// Attach tool results to tool_use messages
const toolResultMap = new Map();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = { content: tr.content, isError: tr.isError };
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
tokenUsage,
};
}

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

@@ -1,18 +1,18 @@
/** /**
* Cursor provider adapter. * Cursor provider adapter.
* *
* Normalizes Cursor CLI session history into NormalizedMessage format. * Normalizes Cursor CLI realtime NDJSON events into NormalizedMessage format.
* History loading lives in ./sessions.js.
* @module adapters/cursor * @module adapters/cursor
*/ */
import path from 'path'; import { createNormalizedMessage } from '../types.js';
import os from 'os';
import crypto from 'crypto';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'cursor'; const PROVIDER = 'cursor';
/** /**
<<<<<<< HEAD
=======
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure, * Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
* and return sorted message blobs in chronological order. * and return sorted message blobs in chronological order.
* @param {string} sessionId * @param {string} sessionId
@@ -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,11 +124,12 @@ async function loadCursorBlobs(sessionId, projectPath) {
return messages; return messages;
} finally { } finally {
await db.close(); db.close();
} }
} }
/** /**
>>>>>>> refactor/split-server-index
* Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s). * Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).
* History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON. * History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.
* @param {object|string} raw - A parsed NDJSON event or a raw text line * @param {object|string} raw - A parsed NDJSON event or a raw text line
@@ -151,203 +147,3 @@ export function normalizeMessage(raw, sessionId) {
} }
return []; return [];
} }
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const cursorAdapter = {
normalizeMessage,
/**
* Fetch session history for Cursor from SQLite store.db.
*/
async fetchHistory(sessionId, opts = {}) {
const { projectPath = '', limit = null, offset = 0 } = opts;
try {
const blobs = await loadCursorBlobs(sessionId, projectPath);
const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId);
// Apply pagination
if (limit !== null && limit > 0) {
const start = offset;
const page = allNormalized.slice(start, start + limit);
return {
messages: page,
total: allNormalized.length,
hasMore: start + limit < allNormalized.length,
offset,
limit,
};
}
return {
messages: allNormalized,
total: allNormalized.length,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
// DB doesn't exist or is unreadable — return empty
console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
},
/**
* Normalize raw Cursor blob messages into NormalizedMessage[].
* @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content})
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
normalizeCursorBlobs(blobs, sessionId) {
const messages = [];
const toolUseMap = new Map();
// Use a fixed base timestamp so messages have stable, monotonically-increasing
// timestamps based on their sequence number rather than wall-clock time.
const baseTime = Date.now();
for (let i = 0; i < blobs.length; i++) {
const blob = blobs[i];
const content = blob.content;
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
const baseId = blob.id || generateMessageId('cursor');
try {
if (!content?.role || !content?.content) {
// Try nested message format
if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') continue;
const role = content.message.role === 'user' ? 'user' : 'assistant';
let text = '';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map(p => typeof p === 'string' ? p : p?.text || '')
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
if (text?.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
}
continue;
}
if (content.role === 'system') continue;
// Tool results
if (content.role === 'tool') {
const toolItems = Array.isArray(content.content) ? content.content : [];
for (const item of toolItems) {
if (item?.type !== 'tool-result') continue;
const toolCallId = item.toolCallId || content.id;
messages.push(createNormalizedMessage({
id: `${baseId}_tr`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: toolCallId,
content: item.result || '',
isError: false,
}));
}
continue;
}
const role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
const part = content.content[partIdx];
if (part?.type === 'text' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: part.text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
} else if (part?.type === 'reasoning' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.text,
}));
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch'
? 'Edit' : (part.toolName || part.name || 'Unknown Tool');
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: part.args || part.input,
toolId,
}));
toolUseMap.set(toolId, messages[messages.length - 1]);
}
}
} else if (typeof content.content === 'string' && content.content.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: content.content,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
} catch (error) {
console.warn('Error normalizing cursor blob:', error);
}
}
// Attach tool results to tool_use messages
for (const msg of messages) {
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
const toolUse = toolUseMap.get(msg.toolId);
toolUse.toolResult = {
content: msg.content,
isError: msg.isError,
};
}
}
// Sort by sequence/rowid
messages.sort((a, b) => {
if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence;
if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid;
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
return messages;
},
};

View File

@@ -0,0 +1 @@
// TODO: migrate GET/POST /config from server/routes/cursor.js

View File

@@ -0,0 +1,8 @@
/**
* Cursor provider barrel.
* Assembles the ProviderAdapter from adapter + sessions.
*/
import { normalizeMessage } from './adapter.js';
import { fetchHistory, normalizeCursorBlobs } from './sessions.js';
export const cursorAdapter = { normalizeMessage, fetchHistory, normalizeCursorBlobs };

View File

@@ -0,0 +1 @@
// TODO: migrate MCP CRUD endpoints from server/routes/cursor.js

View File

@@ -0,0 +1,330 @@
/**
* Cursor provider session history.
*
* Reads Cursor's SQLite store.db, walks the DAG, and returns
* NormalizedMessage[] for a given session.
* @module providers/cursor/sessions
*/
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'cursor';
/**
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
* and return sorted message blobs in chronological order.
* @param {string} sessionId
* @param {string} projectPath - Absolute project path (used to compute cwdId hash)
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
*/
async function loadCursorBlobs(sessionId, projectPath) {
// Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable
const { default: Database } = await import('better-sqlite3');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
try {
const allBlobs = db.prepare('SELECT rowid, id, data FROM blobs').all();
const blobMap = new Map();
const parentRefs = new Map();
const childRefs = new Map();
const jsonBlobs = [];
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
if (blob.data && blob.data[0] === 0x7B) {
try {
const parsed = JSON.parse(blob.data.toString('utf8'));
jsonBlobs.push({ ...blob, parsed });
} catch {
// skip unparseable blobs
}
} else if (blob.data) {
const parents = [];
let i = 0;
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);
for (const parentId of parents) {
if (!childRefs.has(parentId)) childRefs.set(parentId, []);
childRefs.get(parentId).push(blob.id);
}
}
}
}
// Topological sort (DFS)
const visited = new Set();
const sorted = [];
function visit(nodeId) {
if (visited.has(nodeId)) return;
visited.add(nodeId);
for (const pid of (parentRefs.get(nodeId) || [])) visit(pid);
const b = blobMap.get(nodeId);
if (b) sorted.push(b);
}
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) visit(blob.id);
}
for (const blob of allBlobs) visit(blob.id);
// Order JSON blobs by DAG appearance
const messageOrder = new Map();
let orderIndex = 0;
for (const blob of sorted) {
if (blob.data && blob.data[0] !== 0x7B) {
for (const jb of jsonBlobs) {
try {
const idBytes = Buffer.from(jb.id, 'hex');
if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) {
messageOrder.set(jb.id, orderIndex++);
}
} catch { /* skip */ }
}
}
}
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return oa !== ob ? oa - ob : a.rowid - b.rowid;
});
const messages = [];
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
const blob = sortedJsonBlobs[idx];
const parsed = blob.parsed;
if (!parsed) continue;
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') continue;
messages.push({
id: blob.id,
sequence: idx + 1,
rowid: blob.rowid,
content: parsed,
});
}
return messages;
} finally {
db.close();
}
}
/**
* Normalize raw Cursor blob messages into NormalizedMessage[].
* @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content})
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeCursorBlobs(blobs, sessionId) {
const messages = [];
const toolUseMap = new Map();
// Use a fixed base timestamp so messages have stable, monotonically-increasing
// timestamps based on their sequence number rather than wall-clock time.
const baseTime = Date.now();
for (let i = 0; i < blobs.length; i++) {
const blob = blobs[i];
const content = blob.content;
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
const baseId = blob.id || generateMessageId('cursor');
try {
if (!content?.role || !content?.content) {
// Try nested message format
if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') continue;
const role = content.message.role === 'user' ? 'user' : 'assistant';
let text = '';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map(p => typeof p === 'string' ? p : p?.text || '')
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
if (text?.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
}
continue;
}
if (content.role === 'system') continue;
// Tool results
if (content.role === 'tool') {
const toolItems = Array.isArray(content.content) ? content.content : [];
for (const item of toolItems) {
if (item?.type !== 'tool-result') continue;
const toolCallId = item.toolCallId || content.id;
messages.push(createNormalizedMessage({
id: `${baseId}_tr`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: toolCallId,
content: item.result || '',
isError: false,
}));
}
continue;
}
const role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
const part = content.content[partIdx];
if (part?.type === 'text' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: part.text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
} else if (part?.type === 'reasoning' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.text,
}));
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch'
? 'Edit' : (part.toolName || part.name || 'Unknown Tool');
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: part.args || part.input,
toolId,
}));
toolUseMap.set(toolId, messages[messages.length - 1]);
}
}
} else if (typeof content.content === 'string' && content.content.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: content.content,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
} catch (error) {
console.warn('Error normalizing cursor blob:', error);
}
}
// Attach tool results to tool_use messages
for (const msg of messages) {
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
const toolUse = toolUseMap.get(msg.toolId);
toolUse.toolResult = {
content: msg.content,
isError: msg.isError,
};
}
}
// Sort by sequence/rowid
messages.sort((a, b) => {
if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence;
if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid;
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
return messages;
}
/**
* Fetch session history for Cursor from SQLite store.db.
* @param {string} sessionId
* @param {object} opts
* @param {string} [opts.projectPath='']
* @param {number|null} [opts.limit=null]
* @param {number} [opts.offset=0]
* @returns {Promise<{messages: import('../types.js').NormalizedMessage[], total: number, hasMore: boolean, offset: number, limit: number|null}>}
*/
export async function fetchHistory(sessionId, opts = {}) {
const { projectPath = '', limit = null, offset = 0 } = opts;
try {
const blobs = await loadCursorBlobs(sessionId, projectPath);
const allNormalized = normalizeCursorBlobs(blobs, sessionId);
// Apply pagination
if (limit !== null && limit > 0) {
const start = offset;
const page = allNormalized.slice(start, start + limit);
return {
messages: page,
total: allNormalized.length,
hasMore: start + limit < allNormalized.length,
offset,
limit,
};
}
return {
messages: allNormalized,
total: allNormalized.length,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
// DB doesn't exist or is unreadable — return empty
console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
}

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

@@ -5,8 +5,6 @@
* @module adapters/gemini * @module adapters/gemini
*/ */
import sessionManager from '../../sessionManager.js';
import { getGeminiCliSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js'; import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'gemini'; const PROVIDER = 'gemini';
@@ -53,7 +51,14 @@ export function normalizeMessage(raw, sessionId) {
} }
if (raw.type === 'result') { if (raw.type === 'result') {
return [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })]; const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
if (raw.stats?.total_tokens) {
msgs.push(createNormalizedMessage({
sessionId, timestamp: ts, provider: PROVIDER,
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
}));
}
return msgs;
} }
if (raw.type === 'error') { if (raw.type === 'error') {
@@ -65,115 +70,3 @@ export function normalizeMessage(raw, sessionId) {
return []; return [];
} }
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const geminiAdapter = {
normalizeMessage,
/**
* Fetch session history for Gemini.
* First tries in-memory session manager, then falls back to CLI sessions on disk.
*/
async fetchHistory(sessionId, opts = {}) {
let rawMessages;
try {
rawMessages = sessionManager.getSessionMessages(sessionId);
// Fallback to Gemini CLI sessions on disk
if (rawMessages.length === 0) {
rawMessages = await getGeminiCliSessionMessages(sessionId);
}
} catch (error) {
console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const normalized = [];
for (let i = 0; i < rawMessages.length; i++) {
const raw = rawMessages[i];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
// sessionManager format: { type: 'message', message: { role, content }, timestamp }
// CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }
const role = raw.message?.role || raw.role;
const content = raw.message?.content || raw.content;
if (!role || !content) continue;
const normalizedRole = (role === 'user') ? 'user' : 'assistant';
if (Array.isArray(content)) {
for (let partIdx = 0; partIdx < content.length; partIdx++) {
const part = content[partIdx];
if (part.type === 'text' && part.text) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part.text,
}));
} else if (part.type === 'tool_use') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id || generateMessageId('gemini_tool'),
}));
} else if (part.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id || '',
content: part.content === undefined ? '' : String(part.content),
isError: Boolean(part.is_error),
}));
}
}
} else if (typeof content === 'string' && content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content,
}));
}
}
// Attach tool results to tool_use messages
const toolResultMap = new Map();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = { content: tr.content, isError: tr.isError };
}
}
return {
messages: normalized,
total: normalized.length,
hasMore: false,
offset: 0,
limit: null,
};
},
};

View File

@@ -0,0 +1,8 @@
/**
* Gemini provider barrel.
* Assembles the ProviderAdapter from adapter + sessions.
*/
import { normalizeMessage } from './adapter.js';
import { fetchHistory } from './sessions.js';
export const geminiAdapter = { normalizeMessage, fetchHistory };

View File

@@ -0,0 +1,121 @@
/**
* Gemini session history fetcher.
*
* Extracted from adapter.js — pure data-access concern.
* @module providers/gemini/sessions
*/
import sessionManager from '../../sessionManager.js';
import { getGeminiCliSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'gemini';
/**
* Fetch session history for Gemini.
* First tries in-memory session manager, then falls back to CLI sessions on disk.
* @param {string} sessionId
* @param {object} opts
* @returns {Promise<{messages: import('../types.js').NormalizedMessage[], total: number, hasMore: boolean, offset: number, limit: number|null}>}
*/
export async function fetchHistory(sessionId, opts = {}) {
let rawMessages;
try {
rawMessages = sessionManager.getSessionMessages(sessionId);
// Fallback to Gemini CLI sessions on disk
if (rawMessages.length === 0) {
rawMessages = await getGeminiCliSessionMessages(sessionId);
}
} catch (error) {
console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const normalized = [];
for (let i = 0; i < rawMessages.length; i++) {
const raw = rawMessages[i];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
// sessionManager format: { type: 'message', message: { role, content }, timestamp }
// CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }
const role = raw.message?.role || raw.role;
const content = raw.message?.content || raw.content;
if (!role || !content) continue;
const normalizedRole = (role === 'user') ? 'user' : 'assistant';
if (Array.isArray(content)) {
for (let partIdx = 0; partIdx < content.length; partIdx++) {
const part = content[partIdx];
if (part.type === 'text' && part.text) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part.text,
}));
} else if (part.type === 'tool_use') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id || generateMessageId('gemini_tool'),
}));
} else if (part.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id || '',
content: part.content === undefined ? '' : String(part.content),
isError: Boolean(part.is_error),
}));
}
}
} else if (typeof content === 'string' && content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content,
}));
}
}
// Attach tool results to tool_use messages
const toolResultMap = new Map();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = { content: tr.content, isError: tr.isError };
}
}
return {
messages: normalized,
total: normalized.length,
hasMore: false,
offset: 0,
limit: null,
};
}

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,16 +1,22 @@
/** /**
* 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
*/ */
import { claudeAdapter } from './claude/adapter.js'; import { claudeAdapter } from './claude/index.js';
import { cursorAdapter } from './cursor/adapter.js'; import { cursorAdapter } from './cursor/index.js';
import { codexAdapter } from './codex/adapter.js'; import { codexAdapter } from './codex/index.js';
import { geminiAdapter } from './gemini/adapter.js'; import { geminiAdapter } from './gemini/index.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
@@ -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

@@ -41,7 +41,7 @@
* - stream_end: (no extra fields) * - stream_end: (no extra fields)
* - error: content * - error: content
* - complete: (no extra fields) * - complete: (no extra fields)
* - status: text, canInterrupt? * - status: text, tokens?, canInterrupt?
* - permission_request: requestId, toolName, input, context? * - permission_request: requestId, toolName, input, context?
* - permission_cancelled: requestId * - permission_cancelled: requestId
* - session_created: newSessionId * - session_created: newSessionId
@@ -66,6 +66,20 @@
* @property {boolean} hasMore - Whether more messages exist before the current page * @property {boolean} hasMore - Whether more messages exist before the current page
* @property {number} offset - Current offset * @property {number} offset - Current offset
* @property {number|null} limit - Page size used * @property {number|null} limit - Page size used
* @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

@@ -546,12 +546,7 @@ class ResponseCollector {
const parsed = JSON.parse(msg); const parsed = JSON.parse(msg);
// Only include claude-response messages with assistant type // Only include claude-response messages with assistant type
if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') { if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
const assistantMessage = { ...parsed.data }; assistantMessages.push(parsed.data);
if (assistantMessage.message?.usage) {
assistantMessage.message = { ...assistantMessage.message };
delete assistantMessage.message.usage;
}
assistantMessages.push(assistantMessage);
} }
} catch (e) { } catch (e) {
// Not JSON, skip // Not JSON, skip
@@ -561,6 +556,49 @@ class ResponseCollector {
return assistantMessages; return assistantMessages;
} }
/**
* Calculate total tokens from all messages
*/
getTotalTokens() {
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheCreation = 0;
for (const msg of this.messages) {
let data = msg;
// Parse if string
if (typeof msg === 'string') {
try {
data = JSON.parse(msg);
} catch (e) {
continue;
}
}
// Extract usage from claude-response messages
if (data && data.type === 'claude-response' && data.data) {
const msgData = data.data;
if (msgData.message && msgData.message.usage) {
const usage = msgData.message.usage;
totalInput += usage.input_tokens || 0;
totalOutput += usage.output_tokens || 0;
totalCacheRead += usage.cache_read_input_tokens || 0;
totalCacheCreation += usage.cache_creation_input_tokens || 0;
}
}
}
return {
inputTokens: totalInput,
outputTokens: totalOutput,
cacheReadTokens: totalCacheRead,
cacheCreationTokens: totalCacheCreation,
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
};
}
} }
// =============================== // ===============================
@@ -751,6 +789,13 @@ class ResponseCollector {
* success: true, * success: true,
* sessionId: "session-123", * sessionId: "session-123",
* messages: [...], // Assistant messages only (filtered) * messages: [...], // Assistant messages only (filtered)
* tokens: {
* inputTokens: 150,
* outputTokens: 50,
* cacheReadTokens: 0,
* cacheCreationTokens: 0,
* totalTokens: 200
* },
* projectPath: "/path/to/project", * projectPath: "/path/to/project",
* branch: { // Only if createBranch=true * branch: { // Only if createBranch=true
* name: "feature/xyz", * name: "feature/xyz",
@@ -1128,13 +1173,15 @@ router.post('/', validateExternalApiKey, async (req, res) => {
// Streaming mode: end the SSE stream // Streaming mode: end the SSE stream
writer.end(); writer.end();
} else { } else {
// Non-streaming mode: send filtered messages as JSON // Non-streaming mode: send filtered messages and token summary as JSON
const assistantMessages = writer.getAssistantMessages(); const assistantMessages = writer.getAssistantMessages();
const tokenSummary = writer.getTotalTokens();
const response = { const response = {
success: true, success: true,
sessionId: writer.getSessionId(), sessionId: writer.getSessionId(),
messages: assistantMessages, messages: assistantMessages,
tokens: tokenSummary,
projectPath: finalProjectPath projectPath: finalProjectPath
}; };

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();
@@ -97,6 +99,12 @@ const builtInCommands = [
namespace: 'builtin', namespace: 'builtin',
metadata: { type: 'builtin' } metadata: { type: 'builtin' }
}, },
{
name: '/cost',
description: 'Display token usage and cost information',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{ {
name: '/memory', name: '/memory',
description: 'Open CLAUDE.md memory file for editing', description: 'Open CLAUDE.md memory file for editing',
@@ -203,9 +211,89 @@ Custom commands can be created in:
}; };
}, },
'/cost': async (args, context) => {
const tokenUsage = context?.tokenUsage || {};
const provider = context?.provider || 'claude';
const model =
context?.model ||
(provider === 'cursor'
? CURSOR_MODELS.DEFAULT
: provider === 'codex'
? CODEX_MODELS.DEFAULT
: CLAUDE_MODELS.DEFAULT);
const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
const total =
Number(
tokenUsage.total ??
tokenUsage.contextWindow ??
parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
) || 160000;
const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
const inputTokensRaw =
Number(
tokenUsage.inputTokens ??
tokenUsage.input ??
tokenUsage.cumulativeInputTokens ??
tokenUsage.promptTokens ??
0,
) || 0;
const outputTokens =
Number(
tokenUsage.outputTokens ??
tokenUsage.output ??
tokenUsage.cumulativeOutputTokens ??
tokenUsage.completionTokens ??
0,
) || 0;
const cacheTokens =
Number(
tokenUsage.cacheReadTokens ??
tokenUsage.cacheCreationTokens ??
tokenUsage.cacheTokens ??
tokenUsage.cachedTokens ??
0,
) || 0;
// If we only have total used tokens, treat them as input for display/estimation.
const inputTokens =
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
// Rough default rates by provider (USD / 1M tokens).
const pricingByProvider = {
claude: { input: 3, output: 15 },
cursor: { input: 3, output: 15 },
codex: { input: 1.5, output: 6 },
};
const rates = pricingByProvider[provider] || pricingByProvider.claude;
const inputCost = (inputTokens / 1_000_000) * rates.input;
const outputCost = (outputTokens / 1_000_000) * rates.output;
const totalCost = inputCost + outputCost;
return {
type: 'builtin',
action: 'cost',
data: {
tokenUsage: {
used,
total,
percentage,
},
cost: {
input: inputCost.toFixed(4),
output: outputCost.toFixed(4),
total: totalCost.toFixed(4),
},
model,
},
};
},
'/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

@@ -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;
@@ -42,6 +42,7 @@ interface UseChatComposerStateArgs {
geminiModel: string; geminiModel: string;
isLoading: boolean; isLoading: boolean;
canAbortSession: boolean; canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
sendMessage: (message: unknown) => void; sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean; sendByCtrlEnter?: boolean;
onSessionActive?: (sessionId?: string | null) => void; onSessionActive?: (sessionId?: string | null) => void;
@@ -56,7 +57,7 @@ interface UseChatComposerStateArgs {
rewindMessages: (count: number) => void; rewindMessages: (count: number) => void;
setIsLoading: (loading: boolean) => void; setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void; setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; can_interrupt: boolean } | null) => void; setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setIsUserScrolledUp: (isScrolledUp: boolean) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>; setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
} }
@@ -113,6 +114,7 @@ export function useChatComposerState({
geminiModel, geminiModel,
isLoading, isLoading,
canAbortSession, canAbortSession,
tokenBudget,
sendMessage, sendMessage,
sendByCtrlEnter, sendByCtrlEnter,
onSessionActive, onSessionActive,
@@ -174,6 +176,12 @@ export function useChatComposerState({
}); });
break; break;
case 'cost': {
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });
break;
}
case 'status': { case 'status': {
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`; const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() }); addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
@@ -274,6 +282,7 @@ export function useChatComposerState({
sessionId: currentSessionId, sessionId: currentSessionId,
provider, provider,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel, model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
tokenUsage: tokenBudget,
}; };
const response = await authenticatedFetch('/api/commands/execute', { const response = await authenticatedFetch('/api/commands/execute', {
@@ -330,6 +339,7 @@ export function useChatComposerState({
provider, provider,
selectedProject, selectedProject,
addMessage, addMessage,
tokenBudget,
], ],
); );
@@ -533,6 +543,7 @@ export function useChatComposerState({
setCanAbortSession(true); setCanAbortSession(true);
setClaudeStatus({ setClaudeStatus({
text: 'Processing', text: 'Processing',
tokens: 0,
can_interrupt: true, can_interrupt: true,
}); });
@@ -867,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') {
@@ -983,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 = {
@@ -38,7 +38,9 @@ type LatestChatMessage = {
provider?: string; provider?: string;
content?: string; content?: string;
text?: string; text?: string;
tokens?: number;
canInterrupt?: boolean; canInterrupt?: boolean;
tokenBudget?: unknown;
newSessionId?: string; newSessionId?: string;
aborted?: boolean; aborted?: boolean;
[key: string]: any; [key: string]: any;
@@ -46,14 +48,15 @@ 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;
setCurrentSessionId: (sessionId: string | null) => void; setCurrentSessionId: (sessionId: string | null) => void;
setIsLoading: (loading: boolean) => void; setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void; setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; can_interrupt: boolean } | null) => void; setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>; setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>; pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
streamBufferRef: MutableRefObject<string>; streamBufferRef: MutableRefObject<string>;
@@ -82,6 +85,7 @@ export function useChatRealtimeHandlers({
setIsLoading, setIsLoading,
setCanAbortSession, setCanAbortSession,
setClaudeStatus, setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests, setPendingPermissionRequests,
pendingViewSessionRef, pendingViewSessionRef,
streamBufferRef, streamBufferRef,
@@ -136,6 +140,7 @@ export function useChatRealtimeHandlers({
if (status) { if (status) {
const statusInfo = { const statusInfo = {
text: status.text || 'Working...', text: status.text || 'Working...',
tokens: status.tokens || 0,
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true, can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
}; };
setClaudeStatus(statusInfo); setClaudeStatus(statusInfo);
@@ -306,7 +311,7 @@ export function useChatRealtimeHandlers({
}); });
setIsLoading(true); setIsLoading(true);
setCanAbortSession(true); setCanAbortSession(true);
setClaudeStatus({ text: 'Waiting for permission', can_interrupt: true }); setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
break; break;
} }
@@ -318,9 +323,12 @@ export function useChatRealtimeHandlers({
} }
case 'status': { case 'status': {
if (msg.text) { if (msg.text === 'token_budget' && msg.tokenBudget) {
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
} else if (msg.text) {
setClaudeStatus({ setClaudeStatus({
text: msg.text, text: msg.text,
tokens: msg.tokens || 0,
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true, can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
}); });
setIsLoading(true); setIsLoading(true);
@@ -344,6 +352,7 @@ export function useChatRealtimeHandlers({
setIsLoading, setIsLoading,
setCanAbortSession, setCanAbortSession,
setClaudeStatus, setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests, setPendingPermissionRequests,
pendingViewSessionRef, pendingViewSessionRef,
streamBufferRef, streamBufferRef,

View File

@@ -1,7 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
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';
@@ -39,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
@@ -107,8 +108,9 @@ export function useChatSessionState({
const [totalMessages, setTotalMessages] = useState(0); const [totalMessages, setTotalMessages] = useState(0);
const [canAbortSession, setCanAbortSession] = useState(false); const [canAbortSession, setCanAbortSession] = useState(false);
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES); const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
const [claudeStatus, setClaudeStatus] = useState<{ text: string; can_interrupt: boolean } | null>(null); const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false); const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false); const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false); const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
@@ -149,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);
@@ -187,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);
@@ -238,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,
@@ -317,6 +319,7 @@ export function useChatSessionState({
messagesOffsetRef.current = 0; messagesOffsetRef.current = 0;
setHasMoreMessages(false); setHasMoreMessages(false);
setTotalMessages(0); setTotalMessages(0);
setTokenBudget(null);
lastLoadedSessionKeyRef.current = null; lastLoadedSessionKeyRef.current = null;
return; return;
} }
@@ -352,6 +355,7 @@ export function useChatSessionState({
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
if (sessionChanged) { if (sessionChanged) {
setTokenBudget(null);
setIsLoading(false); setIsLoading(false);
} }
@@ -370,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,
@@ -379,6 +383,7 @@ export function useChatSessionState({
if (slot) { if (slot) {
setHasMoreMessages(slot.hasMore); setHasMoreMessages(slot.hasMore);
setTotalMessages(slot.total); setTotalMessages(slot.total);
if (slot.tokenUsage) setTokenBudget(slot.tokenUsage as Record<string, unknown>);
} }
setIsLoadingSessionMessages(false); setIsLoadingSessionMessages(false);
}).catch(() => { }).catch(() => {
@@ -405,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 || '',
}); });
@@ -463,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,
@@ -534,6 +539,31 @@ export function useChatSessionState({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]); }, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
// Token usage fetch for Claude
useEffect(() => {
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
setTokenBudget(null);
return;
}
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider !== 'claude') return;
const fetchInitialTokenUsage = async () => {
try {
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
const response = await authenticatedFetch(url);
if (response.ok) {
setTokenBudget(await response.json());
} else {
setTokenBudget(null);
}
} catch (error) {
console.error('Failed to fetch initial token usage:', error);
}
};
fetchInitialTokenUsage();
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
const visibleMessages = useMemo(() => { const visibleMessages = useMemo(() => {
if (chatMessages.length <= visibleMessageCount) return chatMessages; if (chatMessages.length <= visibleMessageCount) return chatMessages;
return chatMessages.slice(-visibleMessageCount); return chatMessages.slice(-visibleMessageCount);
@@ -625,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,
@@ -683,6 +713,8 @@ export function useChatSessionState({
setCanAbortSession, setCanAbortSession,
isUserScrolledUp, isUserScrolledUp,
setIsUserScrolledUp, setIsUserScrolledUp,
tokenBudget,
setTokenBudget,
visibleMessageCount, visibleMessageCount,
visibleMessages, visibleMessages,
loadEarlierMessages, loadEarlierMessages,

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

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
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';
@@ -96,6 +96,8 @@ function ChatInterface({
setCanAbortSession, setCanAbortSession,
isUserScrolledUp, isUserScrolledUp,
setIsUserScrolledUp, setIsUserScrolledUp,
tokenBudget,
setTokenBudget,
visibleMessageCount, visibleMessageCount,
visibleMessages, visibleMessages,
loadEarlierMessages, loadEarlierMessages,
@@ -163,7 +165,6 @@ function ChatInterface({
syncInputOverlayScroll, syncInputOverlayScroll,
handleClearInput, handleClearInput,
handleAbortSession, handleAbortSession,
handleTranscript,
handlePermissionDecision, handlePermissionDecision,
handleGrantToolPermission, handleGrantToolPermission,
handleInputFocusChange, handleInputFocusChange,
@@ -181,6 +182,7 @@ function ChatInterface({
geminiModel, geminiModel,
isLoading, isLoading,
canAbortSession, canAbortSession,
tokenBudget,
sendMessage, sendMessage,
sendByCtrlEnter, sendByCtrlEnter,
onSessionActive, onSessionActive,
@@ -204,9 +206,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 || '',
}); });
@@ -224,6 +226,7 @@ function ChatInterface({
setIsLoading, setIsLoading,
setCanAbortSession, setCanAbortSession,
setClaudeStatus, setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests, setPendingPermissionRequests,
pendingViewSessionRef, pendingViewSessionRef,
streamBufferRef, streamBufferRef,
@@ -348,6 +351,7 @@ function ChatInterface({
onModeSwitch={cyclePermissionMode} onModeSwitch={cyclePermissionMode}
thinkingMode={thinkingMode} thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode} setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount} slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={handleToggleCommandMenu} onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())} hasInput={Boolean(input.trim())}
@@ -402,7 +406,6 @@ function ChatInterface({
})} })}
isTextareaExpanded={isTextareaExpanded} isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter} sendByCtrlEnter={sendByCtrlEnter}
onTranscript={handleTranscript}
/> />
</div> </div>

View File

@@ -11,7 +11,6 @@ import type {
SetStateAction, SetStateAction,
TouchEvent, TouchEvent,
} from 'react'; } from 'react';
import MicButton from '../../../mic-button/view/MicButton';
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';
@@ -41,7 +40,7 @@ interface ChatComposerProps {
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown }, decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void; ) => void;
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean }; handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
claudeStatus: { text: string; can_interrupt: boolean } | null; claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
isLoading: boolean; isLoading: boolean;
onAbortSession: () => void; onAbortSession: () => void;
provider: Provider | string; provider: Provider | string;
@@ -49,6 +48,7 @@ interface ChatComposerProps {
onModeSwitch: () => void; onModeSwitch: () => void;
thinkingMode: string; thinkingMode: string;
setThinkingMode: Dispatch<SetStateAction<string>>; setThinkingMode: Dispatch<SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number; slashCommandsCount: number;
onToggleCommandMenu: () => void; onToggleCommandMenu: () => void;
hasInput: boolean; hasInput: boolean;
@@ -90,7 +90,6 @@ interface ChatComposerProps {
placeholder: string; placeholder: string;
isTextareaExpanded: boolean; isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean; sendByCtrlEnter?: boolean;
onTranscript: (text: string) => void;
} }
export default function ChatComposer({ export default function ChatComposer({
@@ -105,6 +104,7 @@ export default function ChatComposer({
onModeSwitch, onModeSwitch,
thinkingMode, thinkingMode,
setThinkingMode, setThinkingMode,
tokenBudget,
slashCommandsCount, slashCommandsCount,
onToggleCommandMenu, onToggleCommandMenu,
hasInput, hasInput,
@@ -146,7 +146,6 @@ export default function ChatComposer({
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();
@@ -161,6 +160,9 @@ export default function ChatComposer({
(r) => r.toolName === 'AskUserQuestion' (r) => r.toolName === 'AskUserQuestion'
); );
// Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0;
// On mobile, when input is focused, float the input box at the bottom // On mobile, when input is focused, float the input box at the bottom
const mobileFloatingClass = isInputFocused const mobileFloatingClass = isInputFocused
? '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)]' ? '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)]'
@@ -168,7 +170,7 @@ export default function ChatComposer({
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 ${mobileFloatingClass}`}>
{!hasQuestionPanel && ( {!hasPendingPermissions && (
<div className="flex-1"> <div className="flex-1">
<ClaudeStatus <ClaudeStatus
status={claudeStatus} status={claudeStatus}
@@ -192,6 +194,7 @@ export default function ChatComposer({
provider={provider} provider={provider}
thinkingMode={thinkingMode} thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode} setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount} slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={onToggleCommandMenu} onToggleCommandMenu={onToggleCommandMenu}
hasInput={hasInput} hasInput={hasInput}
@@ -318,10 +321,6 @@ export default function ChatComposer({
</svg> </svg>
</button> </button>
<div className="absolute right-16 top-1/2 -translate-y-1/2 transform sm:right-16" style={{ display: 'none' }}>
<MicButton onTranscript={onTranscript} className="h-10 w-10 sm:h-10 sm:w-10" />
</div>
<button <button
type="submit" type="submit"
disabled={!input.trim() || isLoading} disabled={!input.trim() || isLoading}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { PermissionMode, Provider } from '../../types/types'; import type { PermissionMode, Provider } from '../../types/types';
import ThinkingModeSelector from './ThinkingModeSelector'; import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie';
interface ChatInputControlsProps { interface ChatInputControlsProps {
permissionMode: PermissionMode | string; permissionMode: PermissionMode | string;
@@ -9,6 +10,7 @@ interface ChatInputControlsProps {
provider: Provider | string; provider: Provider | string;
thinkingMode: string; thinkingMode: string;
setThinkingMode: React.Dispatch<React.SetStateAction<string>>; setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number; slashCommandsCount: number;
onToggleCommandMenu: () => void; onToggleCommandMenu: () => void;
hasInput: boolean; hasInput: boolean;
@@ -24,6 +26,7 @@ export default function ChatInputControls({
provider, provider,
thinkingMode, thinkingMode,
setThinkingMode, setThinkingMode,
tokenBudget,
slashCommandsCount, slashCommandsCount,
onToggleCommandMenu, onToggleCommandMenu,
hasInput, hasInput,
@@ -75,6 +78,8 @@ export default function ChatInputControls({
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" /> <ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)} )}
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
<button <button
type="button" type="button"
onClick={onToggleCommandMenu} onClick={onToggleCommandMenu}

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

@@ -6,6 +6,7 @@ import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'
type ClaudeStatusProps = { type ClaudeStatusProps = {
status: { status: {
text?: string; text?: string;
tokens?: number;
can_interrupt?: boolean; can_interrupt?: boolean;
} | null; } | null;
onAbort?: () => void; onAbort?: () => void;
@@ -125,4 +126,4 @@ export default function ClaudeStatus({
</div> </div>
</div> </div>
); );
} }

View File

@@ -8,14 +8,14 @@ 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";
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;
@@ -32,7 +32,7 @@ type ProviderSelectionEmptyStateProps = {
}; };
type ProviderDef = { type ProviderDef = {
id: SessionProvider; id: LLMProvider;
name: string; name: string;
infoKey: string; infoKey: string;
accent: string; accent: string;
@@ -75,7 +75,7 @@ const PROVIDERS: ProviderDef[] = [
}, },
]; ];
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;
@@ -83,7 +83,7 @@ function getModelConfig(p: SessionProvider) {
} }
function getModelValue( function getModelValue(
p: SessionProvider, p: LLMProvider,
c: string, c: string,
cu: string, cu: string,
co: string, co: string,
@@ -119,7 +119,7 @@ export default function ProviderSelectionEmptyState({
defaultValue: "Start the next task", defaultValue: "Start the next task",
}); });
const selectProvider = (next: SessionProvider) => { const selectProvider = (next: LLMProvider) => {
setProvider(next); setProvider(next);
localStorage.setItem("selected-provider", next); localStorage.setItem("selected-provider", next);
setTimeout(() => textareaRef.current?.focus(), 100); setTimeout(() => textareaRef.current?.focus(), 100);

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

@@ -0,0 +1,54 @@
type TokenUsagePieProps = {
used: number;
total: number;
};
export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
// Token usage visualization component
// Only bail out on missing values or nonpositive totals; allow used===0 to render 0%
if (used == null || total == null || total <= 0) return null;
const percentage = Math.min(100, (used / total) * 100);
const radius = 10;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percentage / 100) * circumference;
// Color based on usage level
const getColor = () => {
if (percentage < 50) return '#3b82f6'; // blue
if (percentage < 75) return '#f59e0b'; // orange
return '#ef4444'; // red
};
return (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<svg width="24" height="24" viewBox="0 0 24 24" className="-rotate-90 transform">
{/* Background circle */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-gray-300 dark:text-gray-600"
/>
{/* Progress circle */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke={getColor()}
strokeWidth="2"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
/>
</svg>
<span title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
{percentage.toFixed(1)}%
</span>
</div>
);
}

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,
}; };
} }

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