mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-07 05:45:39 +08:00
Merge branch 'main' into fix/replace-all-spawn-with-cross-spawn
This commit is contained in:
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -20,14 +20,14 @@ jobs:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: git config
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -3,6 +3,47 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [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)
|
||||
|
||||
### New Features
|
||||
|
||||
11
README.de.md
11
README.de.md
@@ -76,6 +76,8 @@ Der schnellste Einstieg – keine lokale Einrichtung erforderlich. Erhalte eine
|
||||
|
||||
### Self-Hosted (Open Source)
|
||||
|
||||
#### npm
|
||||
|
||||
CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+):
|
||||
|
||||
```bash
|
||||
@@ -93,6 +95,15 @@ cloudcli
|
||||
|
||||
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/).
|
||||
|
||||
---
|
||||
|
||||
|
||||
11
README.ja.md
11
README.ja.md
@@ -72,6 +72,8 @@
|
||||
|
||||
### セルフホスト(オープンソース)
|
||||
|
||||
#### npm
|
||||
|
||||
**npx** で今すぐ CloudCLI UI を試せます(**Node.js** v22+ が必要):
|
||||
|
||||
```bash
|
||||
@@ -89,6 +91,15 @@ cloudcli
|
||||
|
||||
より詳細な設定オプション、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/)をご覧ください。
|
||||
|
||||
---
|
||||
|
||||
|
||||
14
README.ko.md
14
README.ko.md
@@ -72,6 +72,8 @@
|
||||
|
||||
### 셀프 호스트 (오픈 소스)
|
||||
|
||||
#### npm
|
||||
|
||||
**npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요):
|
||||
|
||||
```bash
|
||||
@@ -87,7 +89,17 @@ cloudcli
|
||||
|
||||
`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/)를 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
|
||||
48
README.md
48
README.md
@@ -76,6 +76,8 @@ The fastest way to get started — no local setup required. Get a fully managed,
|
||||
|
||||
### Self-Hosted (Open source)
|
||||
|
||||
#### npm
|
||||
|
||||
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.
|
||||
|
||||
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?
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| **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 |
|
||||
| **How you access it** | Browser via `[yourip]:port` | Browser, any IDE, REST API, n8n |
|
||||
| **Setup** | `npx @cloudcli-ai/cloudcli` | No setup required |
|
||||
| **Machine needs to stay on** | Yes | No |
|
||||
| **Mobile access** | Any browser on your network | Any device, native app coming |
|
||||
| **Sessions available** | All sessions auto-discovered from `~/.claude` | All sessions within your cloud environment |
|
||||
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
||||
| **File explorer and Git** | Yes, built into the UI | Yes, built into the UI |
|
||||
| **MCP configuration** | Managed via UI, synced with your local `~/.claude` config | Managed via UI |
|
||||
| **IDE access** | Your local IDE | Any IDE connected to your cloud environment |
|
||||
| **REST API** | Yes | Yes |
|
||||
| **n8n node** | No | Yes |
|
||||
| **Team sharing** | No | Yes |
|
||||
| **Platform cost** | Free, open source | Starts at $7/month |
|
||||
| | Self-Hosted (npm) | Self-Hosted (Docker Sandbox) *(Experimental)* | CloudCLI Cloud |
|
||||
|---|---|---|---|
|
||||
| **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 via `localhost:port` | Browser, any IDE, REST API, n8n |
|
||||
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
|
||||
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
|
||||
| **Machine needs to stay on** | Yes | Yes | No |
|
||||
| **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, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
||||
| **File explorer and Git** | Yes | Yes | Yes |
|
||||
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
|
||||
| **REST API** | Yes | Yes | Yes |
|
||||
| **Team sharing** | No | No | Yes |
|
||||
| **Platform cost** | Free, open source | 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
13
README.ru.md
13
README.ru.md
@@ -76,6 +76,8 @@
|
||||
|
||||
### Self-Hosted (Open source)
|
||||
|
||||
#### npm
|
||||
|
||||
Попробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+):
|
||||
|
||||
```bash
|
||||
@@ -91,8 +93,17 @@ cloudcli
|
||||
|
||||
Откройте `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/).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
|
||||
### 自托管(开源)
|
||||
|
||||
#### npm
|
||||
|
||||
启动 CloudCLI UI,只需一行 `npx`(需要 Node.js v22+):
|
||||
|
||||
```bash
|
||||
@@ -87,7 +89,17 @@ cloudcli
|
||||
|
||||
打开 `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/)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
187
docker/README.md
187
docker/README.md
@@ -1,89 +1,160 @@
|
||||
# Claude Code UI — 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 |
|
||||
|----------|-----------|-------|
|
||||
| `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 |
|
||||
### 1. Install the sbx 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
|
||||
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
|
||||
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.
|
||||
|
||||
```
|
||||
http://localhost:3001
|
||||
### Using a different agent
|
||||
|
||||
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
|
||||
- **Files** — Visual file tree with syntax-highlighted editor
|
||||
- **Git** — Diff viewer, staging, branch switching, and commit — all visual
|
||||
These are used with `--template` when running `sbx` directly (see [Advanced usage](#advanced-usage)).
|
||||
|
||||
## 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
|
||||
- **MCP** — Configure Model Context Protocol servers through the UI
|
||||
- **MCP** — Configure Model Context Protocol servers visually
|
||||
- **Mobile** — Works on tablet and phone browsers
|
||||
|
||||
## Building Locally
|
||||
|
||||
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 Claude Code UI
|
||||
2. **Claude Code UI** — 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. Claude Code UI connects to it and provides the web interface on top.
|
||||
Your project directory is mounted bidirectionally — edits propagate in real time, both ways.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `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:
|
||||
Set variables at creation time with `--env`:
|
||||
|
||||
```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
|
||||
|
||||
These templates are free and open-source under the same license as Claude Code UI (AGPL-3.0-or-later).
|
||||
AGPL-3.0-or-later
|
||||
|
||||
@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
|
||||
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
||||
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
|
||||
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
||||
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
|
||||
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
||||
|
||||
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
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Install Node.js 22 LTS
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
|
||||
# Install Node.js + build tools needed for native modules (node-pty, better-sqlite3, bcrypt)
|
||||
# Node.js + build tools for native modules + common dev tools
|
||||
apt-get install -y --no-install-recommends \
|
||||
nodejs build-essential python3 python3-setuptools \
|
||||
# Install build tools needed for native modules (node-pty, better-sqlite3, bcrypt)
|
||||
# Node.js is already provided by the sandbox base image
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential python3 python3-setuptools \
|
||||
jq ripgrep sqlite3 zip unzip tree vim-tiny
|
||||
|
||||
# Clean up apt cache to reduce image size
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Auto-start Claude Code UI server in background if not already running.
|
||||
# Auto-start CloudCLI server in background if not already running.
|
||||
# This script is sourced from ~/.bashrc on sandbox shell open.
|
||||
|
||||
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 &
|
||||
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 " Claude Code UI is starting on port 3001..."
|
||||
echo " CloudCLI is starting on port 3001..."
|
||||
echo ""
|
||||
echo " To access the web UI, forward the port:"
|
||||
echo " sbx ports \$(hostname) --publish 3001:3001"
|
||||
echo " Forward the port from another terminal:"
|
||||
echo " sbx ports <sandbox-name> --publish 3001:3001"
|
||||
echo ""
|
||||
echo " Then open: http://localhost:3001"
|
||||
echo ""
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.28.0",
|
||||
"version": "1.29.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.28.0",
|
||||
"version": "1.29.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.28.0",
|
||||
"version": "1.29.2",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
@@ -35,7 +35,8 @@
|
||||
"release": "./release.sh",
|
||||
"prepublishOnly": "npm run build",
|
||||
"postinstall": "node scripts/fix-node-pty.js",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"update:platform": "./update-platform.sh"
|
||||
},
|
||||
"keywords": [
|
||||
"claude code",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude Code UI - API Documentation</title>
|
||||
<title>CloudCLI - API Documentation</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
|
||||
@@ -418,7 +418,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<h1>Claude Code UI</h1>
|
||||
<h1>CloudCLI</h1>
|
||||
<div class="subtitle">API Documentation</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Service Worker for Claude Code UI PWA
|
||||
// Service Worker for CloudCLI PWA
|
||||
// Cache only manifest (needed for PWA install). HTML and JS are never pre-cached
|
||||
// so a rebuild + refresh always picks up the latest assets.
|
||||
const CACHE_NAME = 'claude-ui-v2';
|
||||
@@ -79,7 +79,7 @@ self.addEventListener('push', event => {
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
payload = { title: 'Claude Code UI', body: event.data.text() };
|
||||
payload = { title: 'CloudCLI', body: event.data.text() };
|
||||
}
|
||||
|
||||
const options = {
|
||||
@@ -92,7 +92,7 @@ self.addEventListener('push', event => {
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title || 'Claude Code UI', options)
|
||||
self.registration.showNotification(payload.title || 'CloudCLI', options)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
371
server/cli.js
371
server/cli.js
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Claude Code UI CLI
|
||||
* CloudCLI CLI
|
||||
*
|
||||
* Provides command-line utilities for managing Claude Code UI
|
||||
* Provides command-line utilities for managing CloudCLI
|
||||
*
|
||||
* Commands:
|
||||
* (no args) - Start the server (default)
|
||||
* start - Start the server
|
||||
* sandbox - Manage Docker sandbox environments
|
||||
* status - Show configuration and data locations
|
||||
* help - Show help information
|
||||
* version - Show version information
|
||||
@@ -84,7 +85,7 @@ function getInstallDir() {
|
||||
|
||||
// Show status command
|
||||
function showStatus() {
|
||||
console.log(`\n${c.bright('Claude Code UI - Status')}\n`);
|
||||
console.log(`\n${c.bright('CloudCLI UI - Status')}\n`);
|
||||
console.log(c.dim('═'.repeat(60)));
|
||||
|
||||
// Version info
|
||||
@@ -141,7 +142,7 @@ function showStatus() {
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ Claude Code UI - Command Line Tool ║
|
||||
║ CloudCLI - Command Line Tool ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Usage:
|
||||
@@ -149,7 +150,8 @@ Usage:
|
||||
cloudcli [command] [options]
|
||||
|
||||
Commands:
|
||||
start Start the Claude Code UI server (default)
|
||||
start Start the CloudCLI server (default)
|
||||
sandbox Manage Docker sandbox environments
|
||||
status Show configuration and data locations
|
||||
update Update to the latest version
|
||||
help Show this help information
|
||||
@@ -164,8 +166,7 @@ Options:
|
||||
Examples:
|
||||
$ cloudcli # Start with defaults
|
||||
$ cloudcli --port 8080 # Start on port 8080
|
||||
$ cloudcli -p 3000 # Short form for port
|
||||
$ cloudcli start --port 4000 # Explicit start command
|
||||
$ cloudcli sandbox ~/my-project # Run in a Docker sandbox
|
||||
$ cloudcli status # Show configuration
|
||||
|
||||
Environment Variables:
|
||||
@@ -244,6 +245,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
|
||||
async function startServer() {
|
||||
// Check for updates silently on startup
|
||||
@@ -274,6 +622,10 @@ function parseArgs(args) {
|
||||
parsed.command = 'version';
|
||||
} else if (!arg.startsWith('-')) {
|
||||
parsed.command = arg;
|
||||
if (arg === 'sandbox') {
|
||||
parsed.remainingArgs = args.slice(i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +635,7 @@ function parseArgs(args) {
|
||||
// Main CLI handler
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const { command, options } = parseArgs(args);
|
||||
const { command, options, remainingArgs } = parseArgs(args);
|
||||
|
||||
// Apply CLI options to environment variables
|
||||
if (options.serverPort) {
|
||||
@@ -299,6 +651,9 @@ async function main() {
|
||||
case 'start':
|
||||
await startServer();
|
||||
break;
|
||||
case 'sandbox':
|
||||
await sandboxCommand(remainingArgs || []);
|
||||
break;
|
||||
case 'status':
|
||||
case 'info':
|
||||
showStatus();
|
||||
|
||||
168
server/index.js
168
server/index.js
@@ -435,13 +435,20 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
|
||||
|
||||
console.log('Starting system update from directory:', projectRoot);
|
||||
|
||||
// Run the update command based on install mode
|
||||
const updateCommand = installMode === 'git'
|
||||
? 'git checkout main && git pull && npm install'
|
||||
: 'npm install -g @cloudcli-ai/cloudcli@latest';
|
||||
// Platform deployments use their own update workflow from the project root.
|
||||
const updateCommand = IS_PLATFORM
|
||||
// In platform, husky and dev dependencies are not needed
|
||||
? '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], {
|
||||
cwd: installMode === 'git' ? projectRoot : os.homedir(),
|
||||
cwd: updateCwd,
|
||||
env: process.env
|
||||
});
|
||||
|
||||
@@ -1984,155 +1991,6 @@ function handleShellConnection(ws) {
|
||||
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
|
||||
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
@@ -2548,7 +2406,7 @@ async function startServer() {
|
||||
|
||||
console.log('');
|
||||
console.log(c.dim('═'.repeat(63)));
|
||||
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
|
||||
console.log(` ${c.bright('CloudCLI Server - Ready')}`);
|
||||
console.log(c.dim('═'.repeat(63)));
|
||||
console.log('');
|
||||
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`);
|
||||
|
||||
@@ -1125,7 +1125,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
} else {
|
||||
prBody += `Agent task: ${message}`;
|
||||
}
|
||||
prBody += '\n\n---\n*This pull request was automatically created by Claude Code UI Agent.*';
|
||||
prBody += '\n\n---\n*This pull request was automatically created by CloudCLI.ai Agent.*';
|
||||
|
||||
console.log(`📝 PR Title: ${prTitle}`);
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ function buildPushBody(event) {
|
||||
const message = CODE_MAP[event.code] || 'You have a new notification';
|
||||
|
||||
return {
|
||||
title: sessionName || 'Claude Code UI',
|
||||
title: sessionName || 'CloudCLI',
|
||||
body: `${providerLabel}: ${message}`,
|
||||
data: {
|
||||
sessionId: event.sessionId || null,
|
||||
|
||||
@@ -18,9 +18,10 @@ export const CLAUDE_MODELS = {
|
||||
{ value: "haiku", label: "Haiku" },
|
||||
{ value: "opusplan", label: "Opus Plan" },
|
||||
{ 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 = {
|
||||
OPTIONS: [
|
||||
{ value: "gpt-5.4", label: "GPT-5.4" },
|
||||
{ value: "gpt-5.4-mini", label: "GPT-5.4 mini" },
|
||||
{ value: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
|
||||
{ value: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
|
||||
{ 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",
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function AuthLoadingScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="mb-2 text-2xl font-bold text-foreground">Claude Code UI</h1>
|
||||
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
{loadingDotAnimationDelays.map((delay) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { IS_PLATFORM } from '../../../constants/config';
|
||||
|
||||
type AuthScreenLayoutProps = {
|
||||
title: string;
|
||||
@@ -37,6 +38,22 @@ export default function AuthScreenLayout({
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">{footerText}</p>
|
||||
</div>
|
||||
|
||||
{!IS_PLATFORM && (
|
||||
<div className="flex items-center justify-center gap-1.5 pt-2">
|
||||
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||
</svg>
|
||||
<a
|
||||
href="https://github.com/siteboon/claudecodeui"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground/50 transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
CloudCLI is open source
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function LoginForm() {
|
||||
<AuthScreenLayout
|
||||
title={t('login.title')}
|
||||
description={t('login.description')}
|
||||
footerText="Enter your credentials to access Claude Code UI"
|
||||
footerText="Enter your credentials to access CloudCLI"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<AuthInputField
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function SetupForm() {
|
||||
|
||||
return (
|
||||
<AuthScreenLayout
|
||||
title="Welcome to Claude Code UI"
|
||||
title="Welcome to CloudCLI"
|
||||
description="Set up your account to get started"
|
||||
footerText="This is a single-user system. Only one account can be created."
|
||||
logo={<img src="/logo.svg" alt="CloudCLI" className="h-16 w-16" />}
|
||||
|
||||
@@ -878,30 +878,6 @@ export function useChatComposerState({
|
||||
});
|
||||
}, [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(
|
||||
(suggestion: { entry: string; toolName: string }) => {
|
||||
if (!suggestion || provider !== 'claude') {
|
||||
@@ -994,7 +970,6 @@ export function useChatComposerState({
|
||||
syncInputOverlayScroll,
|
||||
handleClearInput,
|
||||
handleAbortSession,
|
||||
handleTranscript,
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
|
||||
@@ -165,7 +165,6 @@ function ChatInterface({
|
||||
syncInputOverlayScroll,
|
||||
handleClearInput,
|
||||
handleAbortSession,
|
||||
handleTranscript,
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
@@ -407,7 +406,6 @@ function ChatInterface({
|
||||
})}
|
||||
isTextareaExpanded={isTextareaExpanded}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
onTranscript={handleTranscript}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
SetStateAction,
|
||||
TouchEvent,
|
||||
} from 'react';
|
||||
import MicButton from '../../../mic-button/view/MicButton';
|
||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
||||
import CommandMenu from './CommandMenu';
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
@@ -91,7 +90,6 @@ interface ChatComposerProps {
|
||||
placeholder: string;
|
||||
isTextareaExpanded: boolean;
|
||||
sendByCtrlEnter?: boolean;
|
||||
onTranscript: (text: string) => void;
|
||||
}
|
||||
|
||||
export default function ChatComposer({
|
||||
@@ -148,7 +146,6 @@ export default function ChatComposer({
|
||||
placeholder,
|
||||
isTextareaExpanded,
|
||||
sendByCtrlEnter,
|
||||
onTranscript,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
||||
@@ -321,10 +318,6 @@ export default function ChatComposer({
|
||||
</svg>
|
||||
</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
|
||||
type="submit"
|
||||
disabled={!input.trim() || isLoading}
|
||||
|
||||
@@ -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 { useTranslation } from 'react-i18next';
|
||||
import { thinkingModes } from '../../constants/thinkingModes';
|
||||
@@ -12,6 +13,11 @@ type ThinkingModeSelectorProps = {
|
||||
|
||||
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
|
||||
|
||||
// Mapping from mode ID to translation key
|
||||
const modeKeyMap: Record<string, string> = {
|
||||
@@ -29,50 +35,143 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
};
|
||||
});
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const closeDropdown = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
if (!trigger || !dropdown || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const viewportPadding = window.innerWidth < 640 ? 12 : 16;
|
||||
const spacing = 8;
|
||||
const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256);
|
||||
let left = triggerRect.left + triggerRect.width / 2 - width / 2;
|
||||
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding));
|
||||
|
||||
const measuredHeight = dropdown.offsetHeight || 0;
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding;
|
||||
const spaceAbove = triggerRect.top - spacing - viewportPadding;
|
||||
const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove;
|
||||
const availableHeight = Math.min(
|
||||
window.innerHeight - viewportPadding * 2,
|
||||
Math.max(180, openBelow ? spaceBelow : spaceAbove),
|
||||
);
|
||||
const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight);
|
||||
const top = openBelow
|
||||
? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight)
|
||||
: Math.max(viewportPadding, triggerRect.top - spacing - panelHeight);
|
||||
|
||||
setDropdownStyle({
|
||||
position: 'fixed',
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
maxHeight: availableHeight,
|
||||
zIndex: 80,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
if (!isOpen) {
|
||||
setDropdownStyle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = window.requestAnimationFrame(updateDropdownPosition);
|
||||
const handleViewportChange = () => updateDropdownPosition();
|
||||
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
window.addEventListener('scroll', handleViewportChange, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener('scroll', handleViewportChange, true);
|
||||
};
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [onClose]);
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, closeDropdown]);
|
||||
|
||||
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
|
||||
const IconComponent = currentMode.icon || Brain;
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
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'
|
||||
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
|
||||
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
|
||||
}`}
|
||||
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<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">
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={dropdownStyle || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' }}
|
||||
className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
>
|
||||
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('thinkingMode.selector.title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
type="button"
|
||||
onClick={closeDropdown}
|
||||
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
@@ -83,7 +182,7 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<div className="min-h-0 overflow-y-auto py-1">
|
||||
{translatedModes.map((mode) => {
|
||||
const ModeIcon = mode.icon;
|
||||
const isSelected = mode.id === selectedMode;
|
||||
@@ -91,10 +190,10 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onModeChange(mode.id);
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
closeDropdown();
|
||||
}}
|
||||
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
@@ -135,10 +234,11 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThinkingModeSelector;
|
||||
export default ThinkingModeSelector;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import MicButton from '../../../mic-button/view/MicButton';
|
||||
import type { ConfirmationRequest } from '../../types/types';
|
||||
|
||||
// Persists commit messages across unmount/remount, keyed by project path
|
||||
@@ -147,13 +146,6 @@ export default function CommitComposer({
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<div style={{ display: 'none' }}>
|
||||
<MicButton
|
||||
onTranscript={(transcript) => setCommitMessage(transcript)}
|
||||
mode="default"
|
||||
className="p-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export type MicButtonState = 'idle' | 'recording' | 'transcribing' | 'processing';
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,21 +2,12 @@ import {
|
||||
ArrowDown,
|
||||
Brain,
|
||||
Eye,
|
||||
FileText,
|
||||
Languages,
|
||||
Maximize2,
|
||||
Mic,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
PreferenceToggleItem,
|
||||
WhisperMode,
|
||||
WhisperOption,
|
||||
} from './types';
|
||||
import type { PreferenceToggleItem } from './types';
|
||||
|
||||
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 HANDLE_POSITION_MIN = 10;
|
||||
@@ -64,30 +55,3 @@ export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
|
||||
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',
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -16,20 +16,4 @@ export type PreferenceToggleItem = {
|
||||
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;
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
} from '../types';
|
||||
import QuickSettingsSection from './QuickSettingsSection';
|
||||
import QuickSettingsToggleRow from './QuickSettingsToggleRow';
|
||||
import QuickSettingsWhisperSection from './QuickSettingsWhisperSection';
|
||||
|
||||
type QuickSettingsContentProps = {
|
||||
isDarkMode: boolean;
|
||||
@@ -73,8 +72,6 @@ export default function QuickSettingsContent({
|
||||
{t('quickSettings.sendByCtrlEnterDescription')}
|
||||
</p>
|
||||
</QuickSettingsSection>
|
||||
|
||||
<QuickSettingsWhisperSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins';
|
||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins' | 'about';
|
||||
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
|
||||
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
||||
export type ProjectSortOrder = 'name' | 'date';
|
||||
|
||||
46
src/components/settings/view/PremiumFeatureCard.tsx
Normal file
46
src/components/settings/view/PremiumFeatureCard.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ExternalLink, Lock } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const CLOUDCLI_URL = 'https://cloudcli.ai';
|
||||
|
||||
type PremiumFeatureCardProps = {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
ctaText?: string;
|
||||
};
|
||||
|
||||
export default function PremiumFeatureCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
ctaText = 'Available with CloudCLI Pro',
|
||||
}: PremiumFeatureCardProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-foreground">{title}</h4>
|
||||
<Lock className="h-3 w-3 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
<a
|
||||
href={CLOUDCLI_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
|
||||
>
|
||||
{ctaText}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
||||
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
||||
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
||||
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
|
||||
import AboutTab from '../view/tabs/AboutTab';
|
||||
import { useSettingsController } from '../hooks/useSettingsController';
|
||||
import { useWebPush } from '../../../hooks/useWebPush';
|
||||
import type { SettingsProps } from '../types/types';
|
||||
@@ -206,6 +207,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
{activeTab === 'api' && <CredentialsSettingsTab />}
|
||||
|
||||
{activeTab === 'plugins' && <PluginSettingsTab />}
|
||||
|
||||
{activeTab === 'about' && <AboutTab />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GitBranch, Key, Puzzle } from 'lucide-react';
|
||||
import { GitBranch, Info, Key, Puzzle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SettingsMainTab } from '../types/types';
|
||||
|
||||
@@ -22,6 +22,7 @@ const TAB_CONFIG: MainTabConfig[] = [
|
||||
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
||||
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
|
||||
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
||||
{ id: 'about', labelKey: 'mainTabs.about', icon: Info },
|
||||
];
|
||||
|
||||
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bell, Bot, GitBranch, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
|
||||
import { Bell, Bot, GitBranch, Info, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { PillBar, Pill } from '../../../shared/view/ui';
|
||||
@@ -23,6 +23,7 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
|
||||
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
||||
{ id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell },
|
||||
{ id: 'about', labelKey: 'mainTabs.about', icon: Info },
|
||||
];
|
||||
|
||||
export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) {
|
||||
|
||||
166
src/components/settings/view/tabs/AboutTab.tsx
Normal file
166
src/components/settings/view/tabs/AboutTab.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { ExternalLink, MessageSquare, Star } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IS_PLATFORM } from '../../../../constants/config';
|
||||
import { useVersionCheck } from '../../../../hooks/useVersionCheck';
|
||||
import PremiumFeatureCard from '../PremiumFeatureCard';
|
||||
import { Cloud, Users } from 'lucide-react';
|
||||
|
||||
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
|
||||
const DISCORD_URL = 'https://discord.gg/buxwujPNRE';
|
||||
const DOCS_URL = 'https://cloudcli.ai/docs/plugin-overview';
|
||||
const CLOUDCLI_URL = 'https://cloudcli.ai';
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscordIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AboutTab() {
|
||||
const { t } = useTranslation('settings');
|
||||
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
const releasesUrl = releaseInfo?.htmlUrl || `${GITHUB_REPO_URL}/releases`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Logo + name + version */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-primary/90 shadow-sm">
|
||||
<MessageSquare className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-semibold text-foreground">CloudCLI</span>
|
||||
<a
|
||||
href={releasesUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
v{currentVersion}
|
||||
</a>
|
||||
{updateAvailable && latestVersion && (
|
||||
<a
|
||||
href={releasesUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium text-green-600 transition-colors hover:bg-green-500/20 dark:text-green-400"
|
||||
>
|
||||
{t('apiKeys.version.updateAvailable', { version: latestVersion })}
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Open-source AI coding assistant interface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Star on GitHub button */}
|
||||
<a
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border/60 bg-background px-3.5 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
<span>Star on GitHub</span>
|
||||
</a>
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<a
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href={DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
Discord
|
||||
</a>
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Docs
|
||||
</a>
|
||||
<a
|
||||
href={CLOUDCLI_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
cloudcli.ai
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Hosted CTA (OSS mode only) */}
|
||||
{!IS_PLATFORM && (
|
||||
<div className="rounded-xl border border-primary/10 bg-primary/5 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">Try CloudCLI Hosted</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Team collaboration, shared MCP configs, settings sync across environments, and managed infrastructure.
|
||||
</p>
|
||||
<a
|
||||
href={CLOUDCLI_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Premium feature placeholders (OSS mode only) */}
|
||||
{!IS_PLATFORM && (
|
||||
<div className="space-y-4 border-t border-border/50 pt-6">
|
||||
<h3 className="text-sm font-medium text-foreground">CloudCLI Pro Features</h3>
|
||||
<PremiumFeatureCard
|
||||
icon={<Cloud className="h-5 w-5" />}
|
||||
title="Sync Settings"
|
||||
description="Keep your preferences, MCP configs, and theme in sync across all your environments."
|
||||
/>
|
||||
<PremiumFeatureCard
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
title="Team Management"
|
||||
description="Multiple users, role-based access, and shared projects for your team."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License */}
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Licensed under AGPL-3.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Edit3, Globe, Plus, Server, Terminal, Trash2, Zap } from 'lucide-react';
|
||||
import { Edit3, Globe, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Button } from '../../../../../../../shared/view/ui';
|
||||
import { IS_PLATFORM } from '../../../../../../../constants/config';
|
||||
import PremiumFeatureCard from '../../../../PremiumFeatureCard';
|
||||
import type { McpServer, McpToolsResult, McpTestResult } from '../../../../../types/types';
|
||||
|
||||
const getTransportIcon = (type: string | undefined) => {
|
||||
@@ -179,6 +181,14 @@ function ClaudeMcpServers({
|
||||
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!IS_PLATFORM && (
|
||||
<PremiumFeatureCard
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
title="Team MCP Configs"
|
||||
description="Share MCP server configurations across your team. Everyone stays in sync automatically."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVersionCheck } from '../../../../../hooks/useVersionCheck';
|
||||
import { useCredentialsSettings } from '../../../hooks/useCredentialsSettings';
|
||||
import ApiKeysSection from './sections/ApiKeysSection';
|
||||
import GithubCredentialsSection from './sections/GithubCredentialsSection';
|
||||
import NewApiKeyAlert from './sections/NewApiKeyAlert';
|
||||
import VersionInfoSection from './sections/VersionInfoSection';
|
||||
|
||||
export default function CredentialsSettingsTab() {
|
||||
const { t } = useTranslation('settings');
|
||||
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
const {
|
||||
apiKeys,
|
||||
githubCredentials,
|
||||
@@ -89,12 +86,6 @@ export default function CredentialsSettingsTab() {
|
||||
onDeleteGithubCredential={deleteGithubCredential}
|
||||
/>
|
||||
|
||||
<VersionInfoSection
|
||||
currentVersion={currentVersion}
|
||||
updateAvailable={updateAvailable}
|
||||
latestVersion={latestVersion}
|
||||
releaseInfo={releaseInfo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { ExternalLink, Star, MessageSquare } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IS_PLATFORM } from '../../../../../../constants/config';
|
||||
import type { ReleaseInfo } from '../../../../../../types/sharedTypes';
|
||||
|
||||
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
|
||||
const DISCORD_URL = 'https://discord.gg/buxwujPNRE';
|
||||
const DOCS_URL = 'https://cloudcli.ai/docs/plugin-overview';
|
||||
const CLOUDCLI_URL = 'https://cloudcli.ai';
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscordIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type VersionInfoSectionProps = {
|
||||
currentVersion: string;
|
||||
updateAvailable: boolean;
|
||||
@@ -16,29 +38,115 @@ export default function VersionInfoSection({
|
||||
releaseInfo,
|
||||
}: VersionInfoSectionProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const releasesUrl = releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases';
|
||||
const releasesUrl = releaseInfo?.htmlUrl || `${GITHUB_REPO_URL}/releases`;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<div className="flex items-center justify-between text-xs italic text-muted-foreground/60">
|
||||
{/* About CloudCLI */}
|
||||
<div className="space-y-4">
|
||||
{/* Logo + name + version */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-primary/90 shadow-sm">
|
||||
<MessageSquare className="h-4.5 w-4.5 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">CloudCLI</span>
|
||||
<a
|
||||
href={releasesUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
v{currentVersion}
|
||||
</a>
|
||||
{updateAvailable && latestVersion && (
|
||||
<a
|
||||
href={releasesUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium text-green-600 transition-colors hover:bg-green-500/20 dark:text-green-400"
|
||||
>
|
||||
{t('apiKeys.version.updateAvailable', { version: latestVersion })}
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Open-source AI coding assistant interface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Star on GitHub button */}
|
||||
<a
|
||||
href={releasesUrl}
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition-colors hover:text-muted-foreground"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
v{currentVersion}
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
<span>Star on GitHub</span>
|
||||
</a>
|
||||
{updateAvailable && latestVersion && (
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<a
|
||||
href={releasesUrl}
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-2 py-0.5 font-medium not-italic text-green-600 transition-colors hover:bg-green-500/20 dark:text-green-400"
|
||||
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<span className="text-[10px]">{t('apiKeys.version.updateAvailable', { version: latestVersion })}</span>
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
<GitHubIcon className="h-3.5 w-3.5" />
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href={DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<DiscordIcon className="h-3.5 w-3.5" />
|
||||
Discord
|
||||
</a>
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Docs
|
||||
</a>
|
||||
<a
|
||||
href={CLOUDCLI_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
cloudcli.ai
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Hosted CTA (OSS mode only) */}
|
||||
{!IS_PLATFORM && (
|
||||
<div className="rounded-xl border border-primary/10 bg-primary/5 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">Try CloudCLI Hosted</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Team collaboration, shared MCP configs, settings sync across environments, and managed infrastructure.
|
||||
</p>
|
||||
<a
|
||||
href={CLOUDCLI_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -266,6 +266,7 @@ function Sidebar({
|
||||
updateAvailable={updateAvailable}
|
||||
releaseInfo={releaseInfo}
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
onShowSettings={onShowSettings}
|
||||
projectListProps={projectListProps}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Star, X } from 'lucide-react';
|
||||
import { useGitHubStars } from '../../../../hooks/useGitHubStars';
|
||||
import { IS_PLATFORM } from '../../../../constants/config';
|
||||
|
||||
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GitHubStarBadge() {
|
||||
const { formattedCount, isDismissed, dismiss } = useGitHubStars('siteboon', 'claudecodeui');
|
||||
|
||||
if (IS_PLATFORM || isDismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="group/star relative hidden md:block">
|
||||
<a
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
<GitHubIcon className="h-3.5 w-3.5" />
|
||||
<Star className="h-3 w-3" />
|
||||
<span className="font-medium">Star</span>
|
||||
{formattedCount && (
|
||||
<span className="border-l border-border/50 pl-1.5 tabular-nums">{formattedCount}</span>
|
||||
)}
|
||||
</a>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dismiss();
|
||||
}}
|
||||
className="absolute -right-1.5 -top-1.5 hidden h-4 w-4 items-center justify-center rounded-full border border-border/50 bg-muted text-muted-foreground transition-colors hover:text-foreground group-hover/star:flex"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Settings, Sparkles, PanelLeftOpen } from 'lucide-react';
|
||||
import { Settings, Sparkles, PanelLeftOpen, Bug } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
|
||||
const GITHUB_ISSUES_URL = 'https://github.com/siteboon/claudecodeui/issues/new';
|
||||
|
||||
function DiscordIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
@@ -50,6 +51,18 @@ export default function SidebarCollapsed({
|
||||
<Settings className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Report Issue */}
|
||||
<a
|
||||
href={GITHUB_ISSUES_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-accent/80"
|
||||
aria-label={t('actions.reportIssue')}
|
||||
title={t('actions.reportIssue')}
|
||||
>
|
||||
<Bug className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</a>
|
||||
|
||||
{/* Discord */}
|
||||
<a
|
||||
href={DISCORD_INVITE_URL}
|
||||
|
||||
@@ -56,6 +56,7 @@ type SidebarContentProps = {
|
||||
updateAvailable: boolean;
|
||||
releaseInfo: ReleaseInfo | null;
|
||||
latestVersion: string | null;
|
||||
currentVersion: string;
|
||||
onShowVersionModal: () => void;
|
||||
onShowSettings: () => void;
|
||||
projectListProps: SidebarProjectListProps;
|
||||
@@ -83,6 +84,7 @@ export default function SidebarContent({
|
||||
updateAvailable,
|
||||
releaseInfo,
|
||||
latestVersion,
|
||||
currentVersion,
|
||||
onShowVersionModal,
|
||||
onShowSettings,
|
||||
projectListProps,
|
||||
@@ -217,6 +219,7 @@ export default function SidebarContent({
|
||||
updateAvailable={updateAvailable}
|
||||
releaseInfo={releaseInfo}
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
onShowVersionModal={onShowVersionModal}
|
||||
onShowSettings={onShowSettings}
|
||||
t={t}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Settings, ArrowUpCircle } from 'lucide-react';
|
||||
import { Settings, ArrowUpCircle, Bug } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { IS_PLATFORM } from '../../../../constants/config';
|
||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||
|
||||
const GITHUB_ISSUES_URL = 'https://github.com/siteboon/claudecodeui/issues/new';
|
||||
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
|
||||
|
||||
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
|
||||
|
||||
function DiscordIcon({ className }: { className?: string }) {
|
||||
@@ -16,6 +20,7 @@ type SidebarFooterProps = {
|
||||
updateAvailable: boolean;
|
||||
releaseInfo: ReleaseInfo | null;
|
||||
latestVersion: string | null;
|
||||
currentVersion: string;
|
||||
onShowVersionModal: () => void;
|
||||
onShowSettings: () => void;
|
||||
t: TFunction;
|
||||
@@ -25,6 +30,7 @@ export default function SidebarFooter({
|
||||
updateAvailable,
|
||||
releaseInfo,
|
||||
latestVersion,
|
||||
currentVersion,
|
||||
onShowVersionModal,
|
||||
onShowSettings,
|
||||
t,
|
||||
@@ -79,11 +85,24 @@ export default function SidebarFooter({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Discord + Settings */}
|
||||
{/* Community + Settings */}
|
||||
<div className="nav-divider" />
|
||||
|
||||
{/* Desktop Discord */}
|
||||
{/* Desktop Report Issue */}
|
||||
<div className="hidden px-2 pt-1.5 md:block">
|
||||
<a
|
||||
href={GITHUB_ISSUES_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
<Bug className="h-3.5 w-3.5" />
|
||||
<span className="text-sm">{t('actions.reportIssue')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Desktop Discord */}
|
||||
<div className="hidden px-2 md:block">
|
||||
<a
|
||||
href={DISCORD_INVITE_URL}
|
||||
target="_blank"
|
||||
@@ -106,8 +125,37 @@ export default function SidebarFooter({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Discord */}
|
||||
{/* Desktop version brand line (OSS mode only) */}
|
||||
{!IS_PLATFORM && (
|
||||
<div className="hidden px-3 py-2 text-center md:block">
|
||||
<a
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-muted-foreground/40 transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
CloudCLI v{currentVersion} – {t('branding.openSource')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Report Issue */}
|
||||
<div className="px-3 pt-3 md:hidden">
|
||||
<a
|
||||
href={GITHUB_ISSUES_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80">
|
||||
<Bug className="w-4.5 h-4.5 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-base font-medium text-foreground">{t('actions.reportIssue')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Mobile Discord */}
|
||||
<div className="px-3 pt-2 md:hidden">
|
||||
<a
|
||||
href={DISCORD_INVITE_URL}
|
||||
target="_blank"
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TFunction } from 'i18next';
|
||||
import { Button, Input } from '../../../../shared/view/ui';
|
||||
import { IS_PLATFORM } from '../../../../constants/config';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import GitHubStarBadge from './GitHubStarBadge';
|
||||
|
||||
type SearchMode = 'projects' | 'conversations';
|
||||
|
||||
@@ -106,6 +107,8 @@ export default function SidebarHeader({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GitHubStarBadge />
|
||||
|
||||
{/* Search bar */}
|
||||
{projectsCount > 0 && !isLoading && (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { authenticatedFetch } from "../../../utils/api";
|
||||
import { ReleaseInfo } from "../../../types/sharedTypes";
|
||||
import { copyTextToClipboard } from "../../../utils/clipboard";
|
||||
import type { InstallMode } from "../../../hooks/useVersionCheck";
|
||||
import { IS_PLATFORM } from "../../../constants/config";
|
||||
|
||||
interface VersionUpgradeModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,6 +15,8 @@ interface VersionUpgradeModalProps {
|
||||
installMode: InstallMode;
|
||||
}
|
||||
|
||||
const RELOAD_COUNTDOWN_START = 30;
|
||||
|
||||
export function VersionUpgradeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -25,14 +28,36 @@ export function VersionUpgradeModal({
|
||||
const { t } = useTranslation('common');
|
||||
const upgradeCommand = installMode === 'npm'
|
||||
? t('versionUpdate.npmUpgradeCommand')
|
||||
: 'git checkout main && git pull && npm install';
|
||||
: IS_PLATFORM
|
||||
? 'npm run update:platform'
|
||||
: 'git checkout main && git pull && npm install';
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateOutput, setUpdateOutput] = useState('');
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
const [reloadCountdown, setReloadCountdown] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_PLATFORM || reloadCountdown === null || reloadCountdown <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setReloadCountdown((previousCountdown) => {
|
||||
if (previousCountdown === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(previousCountdown - 1, 0);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [reloadCountdown]);
|
||||
|
||||
const handleUpdateNow = useCallback(async () => {
|
||||
setIsUpdating(true);
|
||||
setUpdateOutput('Starting update...\n');
|
||||
setReloadCountdown(IS_PLATFORM ? RELOAD_COUNTDOWN_START : null);
|
||||
setUpdateError('');
|
||||
|
||||
try {
|
||||
@@ -46,7 +71,7 @@ export function VersionUpgradeModal({
|
||||
if (response.ok) {
|
||||
setUpdateOutput(prev => prev + data.output + '\n');
|
||||
setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n');
|
||||
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n');
|
||||
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.' + '\n');
|
||||
} else {
|
||||
setUpdateError(data.error || 'Update failed');
|
||||
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n');
|
||||
@@ -143,6 +168,13 @@ export function VersionUpgradeModal({
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-gray-700 bg-gray-900 p-4 dark:bg-gray-950">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs text-green-400">{updateOutput}</pre>
|
||||
</div>
|
||||
{IS_PLATFORM && reloadCountdown !== null && (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900/40 dark:bg-blue-900/20 dark:text-blue-200">
|
||||
{reloadCountdown === 0
|
||||
? 'Refresh the page now. If that doesn\'t work, RESTART the environment.'
|
||||
: `Refresh the page in ${reloadCountdown} ${reloadCountdown === 1 ? 'second' : 'seconds'}. If that doesn\'t work, RESTART the environment.`}
|
||||
</div>
|
||||
)}
|
||||
{updateError && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-200">
|
||||
{updateError}
|
||||
|
||||
77
src/hooks/useGitHubStars.ts
Normal file
77
src/hooks/useGitHubStars.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const CACHE_KEY = 'CLOUDCLI_GITHUB_STARS';
|
||||
const DISMISS_KEY = 'CLOUDCLI_HIDE_GITHUB_STAR';
|
||||
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
type CachedStars = {
|
||||
count: number;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export const useGitHubStars = (owner: string, repo: string) => {
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
const [isDismissed, setIsDismissed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isDismissed) return;
|
||||
|
||||
// Check cache first
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (cached) {
|
||||
const parsed: CachedStars = JSON.parse(cached);
|
||||
if (Date.now() - parsed.timestamp < CACHE_TTL) {
|
||||
setStarCount(parsed.count);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const fetchStars = async () => {
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const count = data.stargazers_count;
|
||||
if (typeof count === 'number') {
|
||||
setStarCount(count);
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({ count, timestamp: Date.now() }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// silent fail
|
||||
}
|
||||
};
|
||||
|
||||
void fetchStars();
|
||||
}, [owner, repo, isDismissed]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setIsDismissed(true);
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, 'true');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const formattedCount = starCount !== null
|
||||
? starCount >= 1000
|
||||
? `${(starCount / 1000).toFixed(1)}k`
|
||||
: `${starCount}`
|
||||
: null;
|
||||
|
||||
return { starCount, formattedCount, isDismissed, dismiss };
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Willkommen zurück",
|
||||
"description": "Meld dich bei deinem Claude Code UI-Konto an",
|
||||
"description": "Meld dich bei deinem CloudCLI-Konto an",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"submit": "Anmelden",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"openInEditor": "Im Editor öffnen"
|
||||
},
|
||||
"mainContent": {
|
||||
"loading": "Claude Code UI wird geladen",
|
||||
"loading": "CloudCLI wird geladen",
|
||||
"settingUpWorkspace": "Arbeitsbereich wird eingerichtet...",
|
||||
"chooseProject": "Projekt auswählen",
|
||||
"selectProjectDescription": "Wähl ein Projekt aus der Seitenleiste, um mit Claude zu programmieren. Jedes Projekt enthält deine Chat-Sitzungen und den Dateiverlauf.",
|
||||
|
||||
@@ -55,8 +55,7 @@
|
||||
"appearance": "Darstellung",
|
||||
"toolDisplay": "Werkzeuganzeige",
|
||||
"viewOptions": "Anzeigeoptionen",
|
||||
"inputSettings": "Eingabeeinstellungen",
|
||||
"whisperDictation": "Whisper-Diktat"
|
||||
"inputSettings": "Eingabeeinstellungen"
|
||||
},
|
||||
"darkMode": "Darkmode",
|
||||
"autoExpandTools": "Werkzeuge automatisch erweitern",
|
||||
@@ -71,16 +70,6 @@
|
||||
"openPanel": "Einstellungspanel öffnen",
|
||||
"draggingStatus": "Wird gezogen...",
|
||||
"toggleAndMove": "Klicken zum Umschalten, ziehen zum Verschieben"
|
||||
},
|
||||
"whisper": {
|
||||
"modes": {
|
||||
"default": "Standardmodus",
|
||||
"defaultDescription": "Direkte Transkription deiner Sprache",
|
||||
"prompt": "Prompt-Verbesserung",
|
||||
"promptDescription": "Rohe Ideen in klare, detaillierte KI-Prompts umwandeln",
|
||||
"vibe": "Vibe-Modus",
|
||||
"vibeDescription": "Ideen als klare Agentenanweisungen mit Details formatieren"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminalShortcuts": {
|
||||
@@ -105,7 +94,8 @@
|
||||
"git": "Git",
|
||||
"apiTokens": "API & Token",
|
||||
"tasks": "Aufgaben",
|
||||
"plugins": "Plugins"
|
||||
"plugins": "Plugins",
|
||||
"about": "Info"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"runClaudeCli": "Führ Claude CLI in einem Projektverzeichnis aus, um zu beginnen"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code UI",
|
||||
"title": "CloudCLI",
|
||||
"subtitle": "KI-Programmierassistent-Oberfläche"
|
||||
},
|
||||
"sessions": {
|
||||
@@ -65,7 +65,12 @@
|
||||
"save": "Speichern",
|
||||
"delete": "Löschen",
|
||||
"rename": "Umbenennen",
|
||||
"joinCommunity": "Community beitreten"
|
||||
"joinCommunity": "Community beitreten",
|
||||
"reportIssue": "Problem melden",
|
||||
"starOnGithub": "Stern auf GitHub"
|
||||
},
|
||||
"branding": {
|
||||
"openSource": "Open Source"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Welcome Back",
|
||||
"description": "Sign in to your Claude Code UI account",
|
||||
"description": "Sign in to your CloudCLI self-hosted account",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"submit": "Sign In",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"openInEditor": "Open in Editor"
|
||||
},
|
||||
"mainContent": {
|
||||
"loading": "Loading Claude Code UI",
|
||||
"loading": "Loading CloudCLI",
|
||||
"settingUpWorkspace": "Setting up your workspace...",
|
||||
"chooseProject": "Choose Your Project",
|
||||
"selectProjectDescription": "Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.",
|
||||
|
||||
@@ -55,8 +55,7 @@
|
||||
"appearance": "Appearance",
|
||||
"toolDisplay": "Tool Display",
|
||||
"viewOptions": "View Options",
|
||||
"inputSettings": "Input Settings",
|
||||
"whisperDictation": "Whisper Dictation"
|
||||
"inputSettings": "Input Settings"
|
||||
},
|
||||
"darkMode": "Dark Mode",
|
||||
"autoExpandTools": "Auto-expand tools",
|
||||
@@ -71,16 +70,6 @@
|
||||
"openPanel": "Open settings panel",
|
||||
"draggingStatus": "Dragging...",
|
||||
"toggleAndMove": "Click to toggle, drag to move"
|
||||
},
|
||||
"whisper": {
|
||||
"modes": {
|
||||
"default": "Default Mode",
|
||||
"defaultDescription": "Direct transcription of your speech",
|
||||
"prompt": "Prompt Enhancement",
|
||||
"promptDescription": "Transform rough ideas into clear, detailed AI prompts",
|
||||
"vibe": "Vibe Mode",
|
||||
"vibeDescription": "Format ideas as clear agent instructions with details"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminalShortcuts": {
|
||||
@@ -106,8 +95,8 @@
|
||||
"apiTokens": "API & Tokens",
|
||||
"tasks": "Tasks",
|
||||
"notifications": "Notifications",
|
||||
"plugins": "Plugins"
|
||||
|
||||
"plugins": "Plugins",
|
||||
"about": "About"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
@@ -498,4 +487,4 @@
|
||||
"tab": "tab",
|
||||
"runningStatus": "running"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"runClaudeCli": "Run Claude CLI in a project directory to get started"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code UI",
|
||||
"title": "CloudCLI",
|
||||
"subtitle": "AI coding assistant interface"
|
||||
},
|
||||
"sessions": {
|
||||
@@ -65,7 +65,12 @@
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"rename": "Rename",
|
||||
"joinCommunity": "Join Community"
|
||||
"joinCommunity": "Join Community",
|
||||
"reportIssue": "Report Issue",
|
||||
"starOnGithub": "Star on GitHub"
|
||||
},
|
||||
"branding": {
|
||||
"openSource": "Open Source"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "おかえりなさい",
|
||||
"description": "Claude Code UIアカウントにサインイン",
|
||||
"description": "CloudCLIアカウントにサインイン",
|
||||
"username": "ユーザー名",
|
||||
"password": "パスワード",
|
||||
"submit": "サインイン",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"openInEditor": "エディタで開く"
|
||||
},
|
||||
"mainContent": {
|
||||
"loading": "Claude Code UI を読み込んでいます",
|
||||
"loading": "CloudCLI を読み込んでいます",
|
||||
"settingUpWorkspace": "ワークスペースを準備しています...",
|
||||
"chooseProject": "プロジェクトを選択",
|
||||
"selectProjectDescription": "サイドバーからプロジェクトを選択して、Claudeとコーディングを始めましょう。各プロジェクトにはチャットセッションとファイル履歴が含まれています。",
|
||||
|
||||
@@ -55,8 +55,7 @@
|
||||
"appearance": "外観",
|
||||
"toolDisplay": "ツール表示",
|
||||
"viewOptions": "表示オプション",
|
||||
"inputSettings": "入力設定",
|
||||
"whisperDictation": "Whisper音声入力"
|
||||
"inputSettings": "入力設定"
|
||||
},
|
||||
"darkMode": "ダークモード",
|
||||
"autoExpandTools": "ツールを自動展開",
|
||||
@@ -71,16 +70,6 @@
|
||||
"openPanel": "設定パネルを開く",
|
||||
"draggingStatus": "ドラッグ中...",
|
||||
"toggleAndMove": "クリックで切替、ドラッグで移動"
|
||||
},
|
||||
"whisper": {
|
||||
"modes": {
|
||||
"default": "標準モード",
|
||||
"defaultDescription": "音声をそのまま文字起こしします",
|
||||
"prompt": "プロンプト強化",
|
||||
"promptDescription": "ラフなアイデアを明確で詳細なAIプロンプトに変換します",
|
||||
"vibe": "バイブモード",
|
||||
"vibeDescription": "アイデアを明確なエージェント指示に整形します"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminalShortcuts": {
|
||||
@@ -106,8 +95,8 @@
|
||||
"apiTokens": "API & トークン",
|
||||
"tasks": "タスク",
|
||||
"notifications": "通知",
|
||||
"plugins": "プラグイン"
|
||||
|
||||
"plugins": "プラグイン",
|
||||
"about": "概要"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
@@ -492,4 +481,4 @@
|
||||
"tab": "タブ",
|
||||
"runningStatus": "実行中"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"runClaudeCli": "プロジェクトディレクトリでClaude CLIを実行して始めましょう"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code UI",
|
||||
"title": "CloudCLI",
|
||||
"subtitle": "AIコーディングアシスタント"
|
||||
},
|
||||
"sessions": {
|
||||
@@ -64,7 +64,12 @@
|
||||
"save": "保存",
|
||||
"delete": "削除",
|
||||
"rename": "名前の変更",
|
||||
"joinCommunity": "コミュニティに参加"
|
||||
"joinCommunity": "コミュニティに参加",
|
||||
"reportIssue": "問題を報告",
|
||||
"starOnGithub": "GitHubでスター"
|
||||
},
|
||||
"branding": {
|
||||
"openSource": "オープンソース"
|
||||
},
|
||||
"status": {
|
||||
"active": "アクティブ",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "다시 오신 것을 환영합니다",
|
||||
"description": "Claude Code UI 계정에 로그인하세요",
|
||||
"description": "CloudCLI 계정에 로그인하세요",
|
||||
"username": "사용자명",
|
||||
"password": "비밀번호",
|
||||
"submit": "로그인",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"openInEditor": "에디터에서 열기"
|
||||
},
|
||||
"mainContent": {
|
||||
"loading": "Claude Code UI 로딩 중",
|
||||
"loading": "CloudCLI 로딩 중",
|
||||
"settingUpWorkspace": "워크스페이스 설정 중...",
|
||||
"chooseProject": "프로젝트 선택",
|
||||
"selectProjectDescription": "사이드바에서 프로젝트를 선택하여 Claude와 코딩을 시작하세요. 각 프로젝트에는 채팅 세션과 파일 히스토리가 포함됩니다.",
|
||||
|
||||
@@ -55,8 +55,7 @@
|
||||
"appearance": "외관",
|
||||
"toolDisplay": "도구 표시",
|
||||
"viewOptions": "보기 옵션",
|
||||
"inputSettings": "입력 설정",
|
||||
"whisperDictation": "Whisper 음성 인식"
|
||||
"inputSettings": "입력 설정"
|
||||
},
|
||||
"darkMode": "다크 모드",
|
||||
"autoExpandTools": "도구 자동 펼치기",
|
||||
@@ -71,16 +70,6 @@
|
||||
"openPanel": "설정 패널 열기",
|
||||
"draggingStatus": "드래그 중...",
|
||||
"toggleAndMove": "클릭하여 토글, 드래그하여 이동"
|
||||
},
|
||||
"whisper": {
|
||||
"modes": {
|
||||
"default": "기본 모드",
|
||||
"defaultDescription": "음성을 그대로 텍스트로 변환",
|
||||
"prompt": "프롬프트 향상",
|
||||
"promptDescription": "거친 아이디어를 명확하고 상세한 AI 프롬프트로 변환",
|
||||
"vibe": "Vibe 모드",
|
||||
"vibeDescription": "아이디어를 상세한 에이전트 지침 형식으로 변환"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminalShortcuts": {
|
||||
@@ -106,8 +95,8 @@
|
||||
"apiTokens": "API & 토큰",
|
||||
"tasks": "작업",
|
||||
"notifications": "알림",
|
||||
"plugins": "플러그인"
|
||||
|
||||
"plugins": "플러그인",
|
||||
"about": "정보"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "알림",
|
||||
@@ -492,4 +481,4 @@
|
||||
"tab": "탭",
|
||||
"runningStatus": "실행 중"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"runClaudeCli": "프로젝트 디렉토리에서 Claude CLI를 실행하여 시작하세요"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code UI",
|
||||
"title": "CloudCLI",
|
||||
"subtitle": "AI 코딩 어시스턴트 UI"
|
||||
},
|
||||
"sessions": {
|
||||
@@ -64,7 +64,12 @@
|
||||
"save": "저장",
|
||||
"delete": "삭제",
|
||||
"rename": "이름 변경",
|
||||
"joinCommunity": "커뮤니티 참여"
|
||||
"joinCommunity": "커뮤니티 참여",
|
||||
"reportIssue": "문제 신고",
|
||||
"starOnGithub": "GitHub에서 스타"
|
||||
},
|
||||
"branding": {
|
||||
"openSource": "오픈 소스"
|
||||
},
|
||||
"status": {
|
||||
"active": "활성",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Добро пожаловать",
|
||||
"description": "Войдите в свой аккаунт Claude Code UI",
|
||||
"description": "Войдите в свой аккаунт CloudCLI",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"submit": "Войти",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"openInEditor": "Открыть в редакторе"
|
||||
},
|
||||
"mainContent": {
|
||||
"loading": "Загрузка Claude Code UI",
|
||||
"loading": "Загрузка CloudCLI",
|
||||
"settingUpWorkspace": "Настройка рабочего пространства...",
|
||||
"chooseProject": "Выберите проект",
|
||||
"selectProjectDescription": "Выберите проект на боковой панели, чтобы начать работу с Claude. Каждый проект содержит ваши сеансы чата и историю файлов.",
|
||||
|
||||
@@ -55,8 +55,7 @@
|
||||
"appearance": "Внешний вид",
|
||||
"toolDisplay": "Отображение инструментов",
|
||||
"viewOptions": "Параметры просмотра",
|
||||
"inputSettings": "Настройки ввода",
|
||||
"whisperDictation": "Диктовка Whisper"
|
||||
"inputSettings": "Настройки ввода"
|
||||
},
|
||||
"darkMode": "Темная тема",
|
||||
"autoExpandTools": "Автоматически разворачивать инструменты",
|
||||
@@ -71,16 +70,6 @@
|
||||
"openPanel": "Открыть панель настроек",
|
||||
"draggingStatus": "Перетаскивание...",
|
||||
"toggleAndMove": "Нажмите для переключения, перетащите для перемещения"
|
||||
},
|
||||
"whisper": {
|
||||
"modes": {
|
||||
"default": "Режим по умолчанию",
|
||||
"defaultDescription": "Прямая транскрипция вашей речи",
|
||||
"prompt": "Улучшение запроса",
|
||||
"promptDescription": "Преобразование грубых идей в четкие, детальные AI-запросы",
|
||||
"vibe": "Режим Vibe",
|
||||
"vibeDescription": "Форматирование идей как четких инструкций агента с деталями"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminalShortcuts": {
|
||||
@@ -105,7 +94,8 @@
|
||||
"git": "Git",
|
||||
"apiTokens": "API и токены",
|
||||
"tasks": "Задачи",
|
||||
"plugins": "Плагины"
|
||||
"plugins": "Плагины",
|
||||
"about": "О программе"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
@@ -471,4 +461,4 @@
|
||||
"tab": "вкладка",
|
||||
"runningStatus": "запущен"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"runClaudeCli": "Запустите Claude CLI в каталоге проекта для начала работы"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code UI",
|
||||
"title": "CloudCLI",
|
||||
"subtitle": "Интерфейс AI помощника для программирования"
|
||||
},
|
||||
"sessions": {
|
||||
@@ -65,7 +65,12 @@
|
||||
"save": "Сохранить",
|
||||
"delete": "Удалить",
|
||||
"rename": "Переименовать",
|
||||
"joinCommunity": "Присоединиться к сообществу"
|
||||
"joinCommunity": "Присоединиться к сообществу",
|
||||
"reportIssue": "Сообщить о проблеме",
|
||||
"starOnGithub": "Звезда на GitHub"
|
||||
},
|
||||
"branding": {
|
||||
"openSource": "Открытый исходный код"
|
||||
},
|
||||
"status": {
|
||||
"active": "Активен",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "欢迎回来",
|
||||
"description": "登录您的 Claude Code UI 账户",
|
||||
"description": "登录您的 CloudCLI 账户",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"submit": "登录",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"openInEditor": "在编辑器中打开"
|
||||
},
|
||||
"mainContent": {
|
||||
"loading": "正在加载 Claude Code UI",
|
||||
"loading": "正在加载 CloudCLI",
|
||||
"settingUpWorkspace": "正在设置您的工作空间...",
|
||||
"chooseProject": "选择您的项目",
|
||||
"selectProjectDescription": "从侧边栏选择一个项目以开始使用 Claude 进行编程。每个项目包含您的聊天会话和文件历史。",
|
||||
|
||||
@@ -55,8 +55,7 @@
|
||||
"appearance": "外观",
|
||||
"toolDisplay": "工具显示",
|
||||
"viewOptions": "视图选项",
|
||||
"inputSettings": "输入设置",
|
||||
"whisperDictation": "Whisper 听写"
|
||||
"inputSettings": "输入设置"
|
||||
},
|
||||
"darkMode": "深色模式",
|
||||
"autoExpandTools": "自动展开工具",
|
||||
@@ -71,16 +70,6 @@
|
||||
"openPanel": "打开设置面板",
|
||||
"draggingStatus": "正在拖拽...",
|
||||
"toggleAndMove": "点击切换,拖拽移动"
|
||||
},
|
||||
"whisper": {
|
||||
"modes": {
|
||||
"default": "默认模式",
|
||||
"defaultDescription": "直接转录您的语音",
|
||||
"prompt": "提示词增强",
|
||||
"promptDescription": "将粗略的想法转化为清晰、详细的 AI 提示词",
|
||||
"vibe": "Vibe 模式",
|
||||
"vibeDescription": "将想法格式化为带有详细说明的清晰智能体指令"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminalShortcuts": {
|
||||
@@ -106,8 +95,8 @@
|
||||
"apiTokens": "API 和令牌",
|
||||
"tasks": "任务",
|
||||
"notifications": "通知",
|
||||
"plugins": "插件"
|
||||
|
||||
"plugins": "插件",
|
||||
"about": "关于"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
@@ -492,4 +481,4 @@
|
||||
"tab": "标签",
|
||||
"runningStatus": "运行中"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"runClaudeCli": "在项目目录中运行 Claude CLI 以开始使用"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code UI",
|
||||
"title": "CloudCLI",
|
||||
"subtitle": "AI 编程助手"
|
||||
},
|
||||
"sessions": {
|
||||
@@ -65,7 +65,12 @@
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"rename": "重命名",
|
||||
"joinCommunity": "加入社区"
|
||||
"joinCommunity": "加入社区",
|
||||
"reportIssue": "报告问题",
|
||||
"starOnGithub": "在GitHub上加星"
|
||||
},
|
||||
"branding": {
|
||||
"openSource": "开源"
|
||||
},
|
||||
"status": {
|
||||
"active": "活动",
|
||||
|
||||
@@ -147,13 +147,6 @@ export const api = {
|
||||
headers: {}, // Let browser set Content-Type for FormData
|
||||
}),
|
||||
|
||||
transcribe: (formData) =>
|
||||
authenticatedFetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {}, // Let browser set Content-Type for FormData
|
||||
}),
|
||||
|
||||
// TaskMaster endpoints
|
||||
taskmaster: {
|
||||
// Initialize TaskMaster in a project
|
||||
|
||||
Reference in New Issue
Block a user