Compare commits

..

2 Commits

Author SHA1 Message Date
Haileyesus
9c79b8fdfb fix: prefer user cli bins in shell terminals 2026-06-15 14:52:01 +03:00
Haileyesus
85f5d0a174 fix: use consistent codex runtime 2026-06-15 14:28:04 +03:00
72 changed files with 730 additions and 7075 deletions

1
.gitignore vendored
View File

@@ -134,7 +134,6 @@ tasks/
# Translations # Translations
!src/i18n/locales/en/tasks.json !src/i18n/locales/en/tasks.json
!src/i18n/locales/fr/tasks.json
!src/i18n/locales/ja/tasks.json !src/i18n/locales/ja/tasks.json
!src/i18n/locales/ru/tasks.json !src/i18n/locales/ru/tasks.json
!src/i18n/locales/de/tasks.json !src/i18n/locales/de/tasks.json

View File

@@ -164,14 +164,6 @@ CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit e
| Plugin | Beschreibung | | Plugin | Beschreibung |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Vollwertiges xterm.js-Terminal mit Multi-Tab-Unterstützung |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Überwacht lange laufende Claude-Code-Sitzungen auf Hänger und stellt Prozesssteuerungen bereit |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Erstellt arbeitsbereichsbezogene geplante Prompts und führt sie über eine lokale CLI wie Codex, Claude Code oder Gemini CLI aus |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Sitzungsintelligenz für Claude Code in CloudCLI, inklusive Sichtbarkeit des Token-Verbrauchs |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktive Claude-Code-Sitzungen anzeigen, verwalten und beenden |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | API-Kosten anhand von Modellpreisen und Token-Nutzung berechnen, mit Unterstützung für Preisvorlagen |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task-Queue-Dashboard zum Anzeigen, Filtern und Starten von Agent-Aufgaben |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-Board für GitHub Issues mit bidirektionaler TaskMaster-Synchronisierung und automatischer Installation des /github-task CLI-Skills |
### Eigenes Plugin erstellen ### Eigenes Plugin erstellen

View File

@@ -158,14 +158,6 @@ CloudCLI にはプラグインシステムがあり、独自のフロントエ
| プラグイン | 説明 | | プラグイン | 説明 |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 複数タブに対応した本格的な xterm.js ターミナル |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 長時間実行中の Claude Code セッションのハングを監視し、プロセス操作を提供 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | ワークスペース単位のスケジュール済みプロンプトを作成し、Codex、Claude Code、Gemini CLI などのローカル CLI で実行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 内で Claude Code のセッション分析を行い、トークン消費の可視化も提供 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | アクティブな Claude Code セッションを表示、管理、終了 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | モデル価格とトークン使用量から API コストを計算し、モデル価格プリセットにも対応 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | エージェントタスクを表示、フィルタリング、起動するためのタスクキューダッシュボード |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues 用の Kanban ボード。TaskMaster との双方向同期と /github-task CLI スキルの自動インストールに対応 |
### 自作する ### 自作する

View File

@@ -158,14 +158,6 @@ CloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그
| 플러그인 | 설명 | | 플러그인 | 설명 |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 다중 탭을 지원하는 전체 xterm.js 터미널 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 장시간 실행 중인 Claude Code 세션의 중단 상태를 감시하고 프로세스 제어를 제공 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 워크스페이스 범위 예약 프롬프트를 만들고 Codex, Claude Code, Gemini CLI 같은 로컬 CLI로 실행 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 안에서 Claude Code 세션 인텔리전스와 토큰 소모 가시성을 제공 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 활성 Claude Code 세션을 보고, 관리하고, 종료 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 모델 가격과 토큰 사용량으로 API 비용을 계산하고 모델 가격 프리셋을 지원 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 에이전트 작업을 보고, 필터링하고, 실행하는 작업 큐 대시보드 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues용 Kanban 보드. TaskMaster 양방향 동기화와 /github-task CLI 스킬 자동 설치 지원 |
### 직접 만들기 ### 직접 만들기

View File

@@ -163,15 +163,8 @@ CloudCLI has a plugin system that lets you add custom tabs with their own fronte
| Plugin | Description | | Plugin | Description |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support|
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Watches long-running Claude Code sessions for hangs and exposes process controls | | **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Session intelligence for Claude Code inside CloudCLI, including token burn visibility |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | View, manage, and kill active Claude Code sessions |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Calculate API costs from model prices and token usage, with preset model pricing support |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task queue dashboard to view, filter, and launch agent tasks |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban board for GitHub Issues with bidirectional TaskMaster sync and /github-task CLI skill auto-install |
### Build Your Own ### Build Your Own
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server. **[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.

View File

@@ -164,14 +164,6 @@ CloudCLI UI — это open source UI-слой, на котором постро
| Плагин | Описание | | Плагин | Описание |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Полноценный терминал xterm.js с поддержкой нескольких вкладок |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Отслеживает зависания долгих сессий Claude Code и предоставляет управление процессами |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Создаёт запланированные промпты для рабочей области и запускает их через локальную CLI, например Codex, Claude Code или Gemini CLI |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Аналитика сессий Claude Code внутри CloudCLI, включая видимость расхода токенов |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Просмотр, управление и завершение активных сессий Claude Code |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Расчёт стоимости API по ценам моделей и использованию токенов, с поддержкой пресетов цен |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Дашборд очереди задач для просмотра, фильтрации и запуска агентских задач |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-доска для GitHub Issues с двусторонней синхронизацией TaskMaster и автоустановкой CLI-навыка /github-task |
### Создать свой ### Создать свой

View File

@@ -164,13 +164,6 @@ CloudCLI, kendi frontend UI'sı ve isteğe bağlı Node.js arka ucu olan özel s
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Uzun süren Claude Code oturumlarını takılmalara karşı izler ve süreç kontrolleri sunar |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Çalışma alanı kapsamlı zamanlanmış prompt'lar oluşturur ve bunları Codex, Claude Code veya Gemini CLI gibi yerel CLI'larla çalıştırır |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI içinde Claude Code oturum zekası ve token tüketimi görünürlüğü sağlar |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktif Claude Code oturumlarını görüntülemeni, yönetmeni ve sonlandırmanı sağlar |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Model fiyatları ve token kullanımından API maliyetlerini hesaplar; model fiyatı hazır ayarlarını destekler |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Ajan görevlerini görüntülemek, filtrelemek ve başlatmak için görev kuyruğu paneli |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues için Kanban panosu; çift yönlü TaskMaster senkronizasyonu ve /github-task CLI becerisi otomatik kurulumu içerir |
### Kendi Eklentini Yaz ### Kendi Eklentini Yaz

View File

@@ -158,14 +158,6 @@ CloudCLI 配备插件系统,允许你添加带自定义前端 UI 和可选 Nod
| 插件 | 描述 | | 插件 | 描述 |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支持多标签页的完整 xterm.js 终端 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 监控长时间运行的 Claude Code 会话是否卡住,并提供进程控制 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 创建工作区范围的定时提示词,并通过 Codex、Claude Code 或 Gemini CLI 等本地 CLI 执行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 会话智能分析,包括 token 消耗可视化 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 查看、管理并终止活动的 Claude Code 会话 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根据模型价格和 token 用量计算 API 成本,并支持模型价格预设 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用于查看、筛选和启动代理任务的任务队列仪表板 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用于 GitHub Issues 的看板,支持 TaskMaster 双向同步和 /github-task CLI 技能自动安装 |
### 自行构建 ### 自行构建

View File

@@ -158,14 +158,6 @@ CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Nod
| 外掛 | 描述 | | 外掛 | 描述 |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支援多分頁的完整 xterm.js 終端機 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 監控長時間執行的 Claude Code 工作階段是否卡住,並提供程序控制 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 建立工作區範圍的排程提示詞,並透過 Codex、Claude Code 或 Gemini CLI 等本機 CLI 執行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 工作階段智慧分析,包括 token 消耗可視化 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 檢視、管理並終止作用中的 Claude Code 工作階段 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根據模型價格與 token 用量計算 API 成本,並支援模型價格預設 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用於檢視、篩選和啟動代理任務的任務佇列儀表板 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用於 GitHub Issues 的看板,支援 TaskMaster 雙向同步和 /github-task CLI 技能自動安裝 |
### 自行建構 ### 自行建構

View File

@@ -161,6 +161,8 @@ export default tseslint.config(
"server/shared/utils.{js,ts}", "server/shared/utils.{js,ts}",
"server/shared/frontmatter.ts", "server/shared/frontmatter.ts",
"server/shared/claude-cli-path.ts", "server/shared/claude-cli-path.ts",
"server/shared/cli-runtime-env.ts",
"server/shared/codex-cli-runtime.ts",
], // classify shared utility files so modules can depend on them explicitly ], // classify shared utility files so modules can depend on them explicitly
mode: "file", mode: "file",
}, },

2992
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.34.0", "version": "1.34.0",
"productName": "CloudCLI",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "dist-server/server/index.js", "main": "dist-server/server/index.js",
@@ -9,7 +8,6 @@
"cloudcli": "dist-server/server/cli.js" "cloudcli": "dist-server/server/cli.js"
}, },
"files": [ "files": [
"electron/",
"server/", "server/",
"shared/", "shared/",
"public/api-docs.html", "public/api-docs.html",
@@ -32,10 +30,6 @@
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js", "server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js", "server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
"client": "vite", "client": "vite",
"desktop": "electron electron/main.js",
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
"desktop:pack": "npm run build && electron-builder --dir",
"desktop:dist:mac": "npm run build && electron-builder --mac dmg zip",
"build": "npm run build:client && npm run build:server", "build": "npm run build:client && npm run build:server",
"build:client": "vite build", "build:client": "vite build",
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"", "prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
@@ -51,53 +45,6 @@
"prepare": "husky", "prepare": "husky",
"update:platform": "./update-platform.sh" "update:platform": "./update-platform.sh"
}, },
"build": {
"appId": "ai.cloudcli.desktop",
"productName": "CloudCLI",
"artifactName": "CloudCLI-${version}-${arch}.${ext}",
"directories": {
"output": "release"
},
"extraMetadata": {
"main": "electron/main.js"
},
"files": [
"electron/",
"public/",
"dist/",
"dist-server/",
"shared/",
"server/",
"package.json"
],
"protocols": [
{
"name": "CloudCLI",
"schemes": [
"cloudcli"
]
}
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
"dmg",
"zip"
],
"extendInfo": {
"CFBundleName": "CloudCLI",
"CFBundleDisplayName": "CloudCLI",
"CFBundleURLTypes": [
{
"CFBundleURLName": "CloudCLI",
"CFBundleURLSchemes": [
"cloudcli"
]
}
]
}
}
},
"keywords": [ "keywords": [
"claude code", "claude code",
"claude-code", "claude-code",
@@ -194,9 +141,6 @@
"auto-changelog": "^2.5.0", "auto-changelog": "^2.5.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"cross-env": "^10.1.0",
"electron": "^38.0.0",
"electron-builder": "^26.15.3",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-import-resolver-typescript": "^4.4.4", "eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2", "eslint-plugin-boundaries": "^6.0.2",

View File

@@ -1,384 +0,0 @@
#!/usr/bin/env node
import './load-env.js';
type JsonRpcRequest = {
jsonrpc: '2.0';
id?: string | number | null;
method: string;
params?: Record<string, unknown>;
};
type ToolDefinition = {
name: string;
description: string;
inputSchema: Record<string, unknown>;
};
const textResponse = (text: string) => ({
content: [{ type: 'text', text }],
});
const jsonResponse = (value: unknown) => textResponse(JSON.stringify(value, null, 2));
const readString = (value: unknown, name: string): string => {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${name} is required.`);
}
return value.trim();
};
const readOptionalString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value.trim() : undefined;
const readNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const apiUrl = (process.env.CLOUDCLI_BROWSER_USE_API_URL || 'http://127.0.0.1:3001/api/browser-use-mcp').replace(/\/$/, '');
const apiToken = process.env.CLOUDCLI_BROWSER_USE_MCP_TOKEN || '';
const API_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_API_TIMEOUT_MS || '60000', 10);
async function callBrowserUseApi(toolName: string, input: Record<string, unknown>) {
if (!apiToken) {
throw new Error('CLOUDCLI_BROWSER_USE_MCP_TOKEN is not configured.');
}
const response = await fetch(`${apiUrl}/tools/${encodeURIComponent(toolName)}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
signal: AbortSignal.timeout(API_TIMEOUT_MS),
});
const data = await response.json() as { success?: boolean; data?: unknown; error?: string };
if (!response.ok || data.success === false) {
throw new Error(data.error || `Browser API request failed (${response.status})`);
}
return data.data;
}
const sessionIdSchema = {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Browser session id.' },
},
required: ['sessionId'],
};
const tools: ToolDefinition[] = [
{
name: 'browser_create_session',
description: 'Create a temporary Browser session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.',
inputSchema: {
type: 'object',
properties: {
profileName: { type: 'string', description: 'Optional background profile name for persistent browser storage.' },
},
},
},
{
name: 'browser_list_sessions',
description: 'List Browser sessions currently available to agents.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'browser_snapshot',
description: 'Capture current page metadata, screenshot data URL, and visible body text for a Browser session.',
inputSchema: sessionIdSchema,
},
{
name: 'browser_take_screenshot',
description: 'Capture the latest screenshot for a Browser session.',
inputSchema: sessionIdSchema,
},
{
name: 'browser_navigate',
description: 'Navigate a Browser session to an HTTP or HTTPS URL.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
url: { type: 'string' },
},
required: ['sessionId', 'url'],
},
},
{
name: 'browser_click',
description: 'Click an element by CSS selector, visible text, or x/y coordinates.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
text: { type: 'string' },
x: { type: 'number' },
y: { type: 'number' },
},
required: ['sessionId'],
},
},
{
name: 'browser_type',
description: 'Type text into the focused page or fill a CSS selector. Set submit to press Enter after typing.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
text: { type: 'string' },
submit: { type: 'boolean' },
},
required: ['sessionId', 'text'],
},
},
{
name: 'browser_fill_form',
description: 'Fill multiple form fields using CSS selectors.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
fields: {
type: 'array',
items: {
type: 'object',
properties: {
selector: { type: 'string' },
value: { type: 'string' },
},
required: ['selector', 'value'],
},
},
},
required: ['sessionId', 'fields'],
},
},
{
name: 'browser_press_key',
description: 'Press a keyboard key, for example Enter, Escape, Tab, or Control+A.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
key: { type: 'string' },
},
required: ['sessionId', 'key'],
},
},
{
name: 'browser_select_option',
description: 'Select option values in a select element found by CSS selector.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
values: { type: 'array', items: { type: 'string' } },
},
required: ['sessionId', 'selector', 'values'],
},
},
{
name: 'browser_wait_for',
description: 'Wait for visible text, a URL pattern, or a short timeout.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
text: { type: 'string' },
url: { type: 'string' },
timeoutMs: { type: 'number' },
},
required: ['sessionId'],
},
},
{
name: 'browser_tabs',
description: 'List, open, select, or close tabs in a Browser session.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
action: { type: 'string', enum: ['list', 'new', 'select', 'close'] },
index: { type: 'number' },
url: { type: 'string' },
},
required: ['sessionId'],
},
},
{
name: 'browser_close_session',
description: 'Stop a Browser session controlled by agents.',
inputSchema: sessionIdSchema,
},
];
async function callTool(name: string, args: Record<string, unknown>) {
switch (name) {
case 'browser_create_session':
return jsonResponse(await callBrowserUseApi(name, {
profileName: readOptionalString(args.profileName),
}));
case 'browser_list_sessions':
return jsonResponse(await callBrowserUseApi(name, {}));
case 'browser_snapshot':
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
case 'browser_take_screenshot': {
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
}
case 'browser_navigate':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
url: readString(args.url, 'url'),
}));
case 'browser_click':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readOptionalString(args.selector),
text: readOptionalString(args.text),
x: readNumber(args.x),
y: readNumber(args.y),
}));
case 'browser_type':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readOptionalString(args.selector),
text: readString(args.text, 'text'),
submit: args.submit === true,
}));
case 'browser_fill_form': {
const fields = Array.isArray(args.fields)
? args.fields.map((field) => {
const record = field as Record<string, unknown>;
return {
selector: readString(record.selector, 'field.selector'),
value: readString(record.value, 'field.value'),
};
})
: [];
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
fields,
}));
}
case 'browser_press_key':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
key: readString(args.key, 'key'),
}));
case 'browser_select_option':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readString(args.selector, 'selector'),
values: Array.isArray(args.values) ? args.values.filter((value): value is string => typeof value === 'string') : [],
}));
case 'browser_wait_for':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
text: readOptionalString(args.text),
url: readOptionalString(args.url),
timeoutMs: readNumber(args.timeoutMs),
}));
case 'browser_tabs':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
action: args.action === 'new' || args.action === 'select' || args.action === 'close' || args.action === 'list'
? args.action
: undefined,
index: readNumber(args.index),
url: readOptionalString(args.url),
}));
case 'browser_close_session':
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
default:
throw new Error(`Unknown tool: ${name}`);
}
}
async function handleMessage(message: JsonRpcRequest) {
if (message.method === 'initialize') {
return {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'cloudcli-browser', version: '1.0.0' },
};
}
if (message.method === 'tools/list') {
return { tools };
}
if (message.method === 'tools/call') {
const params = message.params || {};
const name = readString(params.name, 'name');
const args = (params.arguments && typeof params.arguments === 'object'
? params.arguments
: {}) as Record<string, unknown>;
return callTool(name, args);
}
if (message.method.startsWith('notifications/')) {
return undefined;
}
throw new Error(`Unsupported method: ${message.method}`);
}
function writeMessage(message: Record<string, unknown>) {
// MCP stdio transport uses newline-delimited JSON (one JSON-RPC message per line,
// no embedded newlines). This is NOT the LSP Content-Length framing.
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function sendResult(id: string | number | null | undefined, result: unknown) {
if (id === undefined) {
return;
}
writeMessage({ jsonrpc: '2.0', id, result });
}
function sendError(id: string | number | null | undefined, error: unknown) {
if (id === undefined) {
return;
}
writeMessage({
jsonrpc: '2.0',
id,
error: {
code: -32000,
message: error instanceof Error ? error.message : String(error),
},
});
}
let buffer = '';
process.stdin.on('data', (chunk) => {
buffer += chunk.toString('utf8');
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const rawMessage = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!rawMessage) {
continue;
}
void (async () => {
let request: JsonRpcRequest;
try {
request = JSON.parse(rawMessage) as JsonRpcRequest;
} catch (error) {
sendError(null, error);
return;
}
try {
const result = await handleMessage(request);
sendResult(request.id, result);
} catch (error) {
sendError(request.id, error);
}
})();
}
});

View File

@@ -8,7 +8,6 @@
* (no args) - Start the server (default) * (no args) - Start the server (default)
* start - Start the server * start - Start the server
* sandbox - Manage Docker sandbox environments * sandbox - Manage Docker sandbox environments
* browser-use-mcp - Run Browser MCP stdio server
* status - Show configuration and data locations * status - Show configuration and data locations
* help - Show help information * help - Show help information
* version - Show version information * version - Show version information
@@ -155,13 +154,12 @@ Usage:
cloudcli [command] [options] cloudcli [command] [options]
Commands: Commands:
start Start the CloudCLI server (default) start Start the CloudCLI server (default)
sandbox Manage Docker sandbox environments sandbox Manage Docker sandbox environments
browser-use-mcp Run the Browser MCP stdio server status Show configuration and data locations
status Show configuration and data locations update Update to the latest version
update Update to the latest version help Show this help information
help Show this help information version Show version information
version Show version information
Options: Options:
-p, --port <port> Set server port (default: 3001) -p, --port <port> Set server port (default: 3001)
@@ -607,10 +605,6 @@ async function startServer() {
await import('./index.js'); await import('./index.js');
} }
async function startBrowserUseMcp() {
await import('./browser-use-mcp.js');
}
// Parse CLI arguments // Parse CLI arguments
function parseArgs(args) { function parseArgs(args) {
const parsed = { command: 'start', options: {} }; const parsed = { command: 'start', options: {} };
@@ -664,9 +658,6 @@ async function main() {
case 'sandbox': case 'sandbox':
await sandboxCommand(remainingArgs || []); await sandboxCommand(remainingArgs || []);
break; break;
case 'browser-use-mcp':
await startBrowserUseMcp();
break;
case 'status': case 'status':
case 'info': case 'info':
showStatus(); showStatus();

View File

@@ -61,9 +61,6 @@ import userRoutes from './routes/user.js';
import geminiRoutes from './routes/gemini.js'; import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js'; import pluginsRoutes from './routes/plugins.js';
import providerRoutes from './modules/providers/provider.routes.js'; import providerRoutes from './modules/providers/provider.routes.js';
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
import { browserUseService } from './modules/browser-use/browser-use.service.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js'; import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
import { configureWebPush } from './services/vapid-keys.js'; import { configureWebPush } from './services/vapid-keys.js';
@@ -196,12 +193,6 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected) // Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes); app.use('/api/plugins', authenticateToken, pluginsRoutes);
// Browser MCP bridge API (local token protected)
app.use('/api/browser-use-mcp', browserUseMcpRoutes);
// Browser API Routes (protected)
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
// Unified provider MCP routes (protected) // Unified provider MCP routes (protected)
app.use('/api/providers', authenticateToken, providerRoutes); app.use('/api/providers', authenticateToken, providerRoutes);
@@ -1713,21 +1704,12 @@ async function startServer() {
await closeSessionsWatcher(); await closeSessionsWatcher();
// Clean up plugin processes on shutdown // Clean up plugin processes on shutdown
const shutdownRuntimeServices = async () => { const shutdownPlugins = async () => {
try { await stopAllPlugins();
await browserUseService.stopAllSessions();
} catch (err) {
console.error('[Browser] Error stopping sessions during shutdown:', err?.message || err);
}
try {
await stopAllPlugins();
} catch (err) {
console.error('[Plugins] Error stopping plugins during shutdown:', err?.message || err);
}
process.exit(0); process.exit(0);
}; };
process.on('SIGTERM', () => void shutdownRuntimeServices()); process.on('SIGTERM', () => void shutdownPlugins());
process.on('SIGINT', () => void shutdownRuntimeServices()); process.on('SIGINT', () => void shutdownPlugins());
} catch (error) { } catch (error) {
console.error('[ERROR] Failed to start server:', error); console.error('[ERROR] Failed to start server:', error);
process.exit(1); process.exit(1);

View File

@@ -1,120 +0,0 @@
import express from 'express';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
const router = express.Router();
function readBearerToken(header: unknown): string | null {
if (typeof header !== 'string') {
return null;
}
const match = /^Bearer\s+(\S.*)$/i.exec(header.trim());
return match?.[1]?.trim() || null;
}
router.use((req, res, next) => {
const expected = browserUseService.getMcpToken();
const token = readBearerToken(req.headers.authorization) || String(req.headers['x-browser-use-mcp-token'] || '');
if (!token || token !== expected) {
res.status(401).json({ success: false, error: 'Invalid Browser MCP token.' });
return;
}
next();
});
router.post('/tools/:toolName', async (req, res) => {
try {
const input = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>;
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : '';
const toolName = req.params.toolName;
let result: unknown;
switch (toolName) {
case 'browser_create_session':
result = await browserUseService.createAgentSession({
profileName: typeof input.profileName === 'string' ? input.profileName : null,
});
break;
case 'browser_list_sessions':
result = await browserUseService.listAgentSessions();
break;
case 'browser_snapshot':
case 'browser_take_screenshot':
result = await browserUseService.agentSnapshot(sessionId);
break;
case 'browser_navigate':
result = await browserUseService.agentNavigate(sessionId, String(input.url || ''));
break;
case 'browser_click':
result = await browserUseService.agentClick(sessionId, {
selector: typeof input.selector === 'string' ? input.selector : undefined,
text: typeof input.text === 'string' ? input.text : undefined,
x: typeof input.x === 'number' ? input.x : undefined,
y: typeof input.y === 'number' ? input.y : undefined,
});
break;
case 'browser_type':
result = await browserUseService.agentType(sessionId, {
selector: typeof input.selector === 'string' ? input.selector : undefined,
text: String(input.text || ''),
submit: input.submit === true,
});
break;
case 'browser_fill_form':
result = await browserUseService.agentFillForm(
sessionId,
Array.isArray(input.fields)
? input.fields.map((field) => {
const record = field as Record<string, unknown>;
return {
selector: String(record.selector || ''),
value: String(record.value || ''),
};
})
: [],
);
break;
case 'browser_press_key':
result = await browserUseService.agentPressKey(sessionId, String(input.key || ''));
break;
case 'browser_select_option':
result = await browserUseService.agentSelectOption(
sessionId,
String(input.selector || ''),
Array.isArray(input.values) ? input.values.filter((value): value is string => typeof value === 'string') : [],
);
break;
case 'browser_wait_for':
result = await browserUseService.agentWaitFor(sessionId, {
text: typeof input.text === 'string' ? input.text : undefined,
url: typeof input.url === 'string' ? input.url : undefined,
timeoutMs: typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined,
});
break;
case 'browser_tabs':
result = await browserUseService.agentTabs(sessionId, {
action: input.action === 'new' || input.action === 'select' || input.action === 'close' || input.action === 'list'
? input.action
: undefined,
index: typeof input.index === 'number' ? input.index : undefined,
url: typeof input.url === 'string' ? input.url : undefined,
});
break;
case 'browser_close_session':
result = await browserUseService.agentStopSession(sessionId);
break;
default:
res.status(404).json({ success: false, error: `Unknown Browser MCP tool "${toolName}".` });
return;
}
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Browser MCP tool failed.',
});
}
});
export default router;

View File

@@ -1,96 +0,0 @@
import express from 'express';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
const router = express.Router();
function readParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || '' : value || '';
}
router.get('/status', async (_req, res) => {
try {
res.json({ success: true, data: await browserUseService.getStatus() });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser status.',
});
}
});
router.get('/settings', async (_req, res) => {
try {
res.json({ success: true, data: { settings: await browserUseService.getSettings() } });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser settings.',
});
}
});
router.put('/settings', async (req, res) => {
try {
const settings = await browserUseService.updateSettings(req.body || {});
res.json({ success: true, data: { settings } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to save Browser settings.',
});
}
});
router.post('/runtime/install', async (_req, res) => {
try {
const result = await browserUseService.installRuntime();
res.status(result.success ? 200 : 500).json({
success: result.success,
data: result,
error: result.success ? undefined : result.message,
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to install Browser runtime.',
});
}
});
router.get('/sessions', async (_req, res) => {
try {
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
} catch (error) {
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
});
}
});
router.post('/sessions/:sessionId/stop', async (req, res) => {
try {
const result = await browserUseService.stopSession(readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to stop browser session.',
});
}
});
router.delete('/sessions/:sessionId', async (req, res) => {
try {
const result = await browserUseService.deleteSession(readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to delete browser session.',
});
}
});
export default router;

View File

@@ -1,836 +0,0 @@
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { appConfigDb } from '@/modules/database/index.js';
import { providerMcpService } from '@/modules/providers/index.js';
import { getModuleDir } from '@/utils/runtime-paths.js';
const require = createRequire(import.meta.url);
const __dirname = getModuleDir(import.meta.url);
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
type BrowserUseRuntime = 'cloud' | 'local';
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
type BrowserUseSession = {
id: string;
ownerId: string;
createdBy: 'agent';
runtime: BrowserUseRuntime;
status: BrowserUseSessionStatus;
url: string | null;
title: string | null;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
profileName: string | null;
viewport: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent';
} | null;
};
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
type RuntimeHandle = {
browser?: any;
context?: any;
page?: any;
};
type BrowserUseSettings = {
enabled: boolean;
};
type RuntimeReadiness = {
playwright: any | null;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
chromiumExecutablePath: string | null;
installInProgress: boolean;
installMessage: string | null;
};
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
const sessions = new Map<string, BrowserUseSession>();
const handles = new Map<string, RuntimeHandle>();
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
let lastInstallMessage: string | null = null;
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
const DEFAULT_SETTINGS: BrowserUseSettings = {
enabled: false,
};
const AGENT_OWNER_ID = 'agent';
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
const MCP_SERVER_NAME = 'cloudcli-browser';
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
function getRuntime(): BrowserUseRuntime {
return IS_PLATFORM ? 'cloud' : 'local';
}
function readSettings(): BrowserUseSettings {
try {
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
if (!raw) {
return DEFAULT_SETTINGS;
}
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
return {
enabled: parsed.enabled === true,
};
} catch (error: any) {
console.warn('[Browser] Failed to read settings:', error?.message || error);
return DEFAULT_SETTINGS;
}
}
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
const normalized = {
enabled: settings.enabled === true,
};
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
return normalized;
}
function getOrCreateMcpToken(): string {
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
if (existing) {
return existing;
}
const token = randomBytes(32).toString('hex');
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
return token;
}
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
if (!settings.enabled) {
return 'Browser is disabled in settings.';
}
if (!readiness.playwrightInstalled) {
return 'Install Playwright and Chromium to use browser sessions.';
}
if (!readiness.chromiumInstalled) {
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
}
return readiness.installMessage || 'Browser runtime is not ready.';
}
function getPlaywright(): any | null {
try {
return require('playwright');
} catch {
return null;
}
}
function getMcpCommand(): { command: string; args: string[] } {
const serverDir = path.resolve(__dirname, '..', '..');
const mcpScriptPath = path.join(serverDir, 'browser-use-mcp.js');
if (fs.existsSync(mcpScriptPath)) {
return {
command: process.execPath,
args: [mcpScriptPath],
};
}
return {
command: 'cloudcli',
args: ['browser-use-mcp'],
};
}
function getMcpApiUrl(): string {
const port = process.env.SERVER_PORT || process.env.PORT || '3001';
return `http://127.0.0.1:${port}/api/browser-use-mcp`;
}
async function removeMcpServerFromAllProviders(name: string) {
const results = await providerMcpService.removeMcpServerFromAllProviders({
name,
scope: 'user',
});
return results.map((result) => ({ ...result, name }));
}
function normalizeProfileName(profileName?: string | null): string | null {
const normalized = String(profileName || '').trim();
if (!normalized) {
return null;
}
return normalized.slice(0, 80);
}
function getProfilePath(profileName: string): string {
const safeName = profileName
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'default';
return path.join(PROFILE_ROOT, safeName);
}
function probeRuntime(): RuntimeProbe {
const playwright = getPlaywright();
const readiness: RuntimeProbe = {
playwright,
playwrightInstalled: Boolean(playwright),
chromiumInstalled: false,
chromiumExecutablePath: null,
};
if (!playwright) {
return readiness;
}
try {
const executablePath = playwright.chromium.executablePath();
readiness.chromiumExecutablePath = executablePath;
readiness.chromiumInstalled = Boolean(executablePath && fs.existsSync(executablePath));
} catch {
readiness.chromiumInstalled = false;
}
return readiness;
}
function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadiness {
const now = Date.now();
const cachedProbe = runtimeProbeCache;
const canUseCache = !options.force
&& !installPromise
&& cachedProbe
&& now - cachedProbe.updatedAt < RUNTIME_READINESS_CACHE_TTL_MS;
const probe = canUseCache ? cachedProbe.value : probeRuntime();
if (!canUseCache && !installPromise) {
runtimeProbeCache = { value: probe, updatedAt: now };
}
return {
...probe,
installInProgress: Boolean(installPromise),
installMessage: lastInstallMessage,
};
}
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
10,
);
function runCommand(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
const output: string[] = [];
let settled = false;
const finish = (fn: () => void) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => {
child.kill('SIGKILL');
finish(() => reject(new Error(
`${command} ${args.join(' ')} timed out after ${INSTALL_COMMAND_TIMEOUT_MS}ms.`,
)));
}, INSTALL_COMMAND_TIMEOUT_MS);
timer.unref?.();
child.stdout.on('data', (chunk) => output.push(String(chunk)));
child.stderr.on('data', (chunk) => output.push(String(chunk)));
child.on('error', (error) => finish(() => reject(error)));
child.on('close', (code) => finish(() => {
if (code === 0) {
resolve();
return;
}
reject(new Error(output.join('').trim() || `${command} ${args.join(' ')} exited with code ${code}`));
}));
});
}
function formatInstallError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('sudo') && message.includes('password')) {
return 'Installing Chromium system dependencies requires administrator privileges. Run `npx playwright install-deps chromium` on the machine where CloudCLI runs, then try again.';
}
return message || 'Failed to install Browser runtime.';
}
async function installRuntime(): Promise<{ success: boolean; message: string }> {
if (installPromise) {
return installPromise;
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
runtimeProbeCache = null;
installPromise = (async () => {
try {
lastInstallMessage = 'Installing Playwright package...';
await runCommand(npmCommand, ['install', '--no-save', '--no-package-lock', 'playwright']);
if (process.platform === 'linux') {
lastInstallMessage = 'Installing Chromium system dependencies...';
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install-deps', 'chromium']);
}
lastInstallMessage = 'Installing Chromium runtime...';
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']);
lastInstallMessage = 'Browser runtime installed.';
return { success: true, message: lastInstallMessage };
} catch (error) {
lastInstallMessage = formatInstallError(error);
return { success: false, message: lastInstallMessage };
}
})();
try {
return await installPromise;
} finally {
installPromise = null;
runtimeProbeCache = null;
}
}
function normalizeUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (!trimmed) {
throw new Error('URL is required.');
}
const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)
? trimmed
: `https://${trimmed}`;
const parsed = new URL(withProtocol);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only http and https URLs are supported.');
}
return parsed.toString();
}
function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
const { ownerId: _ownerId, ...publicFields } = session;
return publicFields;
}
function ownerSessions(ownerId: string): BrowserUseSession[] {
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
}
async function closeHandle(sessionId: string): Promise<void> {
const handle = handles.get(sessionId);
handles.delete(sessionId);
await handle?.context?.close?.().catch(() => undefined);
await handle?.browser?.close().catch(() => undefined);
}
async function expireStaleSessions(now = Date.now()): Promise<void> {
await Promise.all([...sessions.values()].map(async (session) => {
if (session.status !== 'ready') {
return;
}
const updatedAt = Date.parse(session.updatedAt);
if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) {
return;
}
await closeHandle(session.id);
session.status = 'stopped';
session.updatedAt = new Date(now).toISOString();
session.lastAction = 'expire';
session.message = 'Browser session expired after inactivity.';
}));
}
async function captureSession(session: BrowserUseSession, page: any): Promise<void> {
const screenshot = await page.screenshot({ type: 'jpeg', quality: 72, fullPage: false });
session.screenshotDataUrl = `data:image/jpeg;base64,${Buffer.from(screenshot).toString('base64')}`;
session.title = await page.title().catch(() => null);
session.url = page.url() || session.url;
session.viewport = page.viewportSize?.() || session.viewport;
session.updatedAt = new Date().toISOString();
}
async function getActionPoint(page: any, input: { selector?: string; text?: string; x?: number; y?: number }) {
if (typeof input.x === 'number' && typeof input.y === 'number') {
return { x: input.x, y: input.y };
}
const locator = input.selector
? page.locator(input.selector).first()
: input.text
? page.getByText(input.text, { exact: false }).first()
: null;
if (!locator) {
return null;
}
const box = await locator.boundingBox().catch(() => null);
if (!box) {
return null;
}
return {
x: Math.round(box.x + box.width / 2),
y: Math.round(box.y + box.height / 2),
};
}
export const browserUseService = {
async getSettings() {
return readSettings();
},
async updateSettings(settings: Partial<BrowserUseSettings>) {
const current = readSettings();
const nextSettings = {
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
};
const next = writeSettings(nextSettings);
if (next.enabled) {
await this.registerAgentMcp();
} else if (current.enabled) {
await this.unregisterAgentMcp();
await this.stopAllSessions();
}
return next;
},
async getStatus() {
const settings = readSettings();
const readiness = getRuntimeReadiness();
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled;
return {
enabled: settings.enabled,
runtime: getRuntime(),
available,
playwrightInstalled: readiness.playwrightInstalled,
chromiumInstalled: readiness.chromiumInstalled,
installInProgress: readiness.installInProgress,
sessionCount: sessions.size,
message: available
? 'Browser runtime is available.'
: getSetupMessage(settings, readiness),
};
},
async registerAgentMcp() {
const { command, args } = getMcpCommand();
await Promise.all(LEGACY_MCP_SERVER_NAMES.map((name) => removeMcpServerFromAllProviders(name)));
const results = await providerMcpService.addMcpServerToAllProviders({
name: MCP_SERVER_NAME,
scope: 'user',
transport: 'stdio',
command,
args,
env: {
CLOUDCLI_BROWSER_USE_MCP_TOKEN: getOrCreateMcpToken(),
CLOUDCLI_BROWSER_USE_API_URL: getMcpApiUrl(),
},
});
return { name: MCP_SERVER_NAME, command, args, results };
},
getMcpToken() {
return getOrCreateMcpToken();
},
async unregisterAgentMcp() {
const results = (await Promise.all(
[MCP_SERVER_NAME, ...LEGACY_MCP_SERVER_NAMES].map((name) => removeMcpServerFromAllProviders(name)),
)).flat();
return { name: MCP_SERVER_NAME, results };
},
async installRuntime() {
const result = await installRuntime();
return {
...result,
status: await this.getStatus(),
};
},
async listSessions() {
await expireStaleSessions();
return [...sessions.values()]
.filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession);
},
async createAgentSession(options?: { profileName?: string | null }) {
const settings = readSettings();
if (!settings.enabled) {
throw new Error('Browser agent tools are disabled.');
}
await expireStaleSessions();
const profileName = normalizeProfileName(options?.profileName);
const now = new Date().toISOString();
const session: BrowserUseSession = {
id: randomUUID(),
ownerId: AGENT_OWNER_ID,
createdBy: 'agent',
runtime: getRuntime(),
status: 'unavailable',
url: null,
title: null,
screenshotDataUrl: null,
createdAt: now,
updatedAt: now,
lastAction: 'create',
message: null,
profileName,
viewport: { width: 1440, height: 900 },
cursor: null,
};
const activeOwnerSessions = ownerSessions(AGENT_OWNER_ID).filter((item) => item.status === 'ready');
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
throw new Error(`Browser is limited to ${MAX_SESSIONS_PER_OWNER} active agent sessions.`);
}
const readiness = getRuntimeReadiness();
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
session.message = getSetupMessage(settings, readiness);
sessions.set(session.id, session);
return publicSession(session);
}
let browser: any | undefined;
let context: any | undefined;
let page: any;
const launchOptions = {
headless: true,
args: ['--disable-dev-shm-usage'],
};
const contextOptions = {
viewport: { width: 1440, height: 900 },
serviceWorkers: 'block',
};
if (profileName) {
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
context = await readiness.playwright.chromium.launchPersistentContext(getProfilePath(profileName), {
...launchOptions,
...contextOptions,
});
page = context.pages()[0] || await context.newPage();
} else {
browser = await readiness.playwright.chromium.launch(launchOptions);
context = await browser.newContext(contextOptions);
page = await context.newPage();
}
session.status = 'ready';
session.message = 'Browser session is ready.';
sessions.set(session.id, session);
handles.set(session.id, { browser, context, page });
await captureSession(session, page);
return publicSession(session);
},
async listAgentSessions() {
const settings = readSettings();
if (!settings.enabled) {
return [];
}
await expireStaleSessions();
return [...sessions.values()]
.filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession);
},
async getAgentSession(sessionId: string) {
const settings = readSettings();
if (!settings.enabled) {
throw new Error('Browser agent tools are disabled.');
}
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session not found.');
}
return session;
},
async agentNavigate(sessionId: string, rawUrl: string) {
await this.getAgentSession(sessionId);
await expireStaleSessions();
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session not found.');
}
if (session.status !== 'ready') {
throw new Error(session.message || 'Browser session is not available.');
}
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const url = normalizeUrl(rawUrl);
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
session.lastAction = `navigate:${url}`;
session.cursor = null;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentSnapshot(sessionId: string) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await captureSession(session, handle.page);
const text = await handle.page.locator('body').innerText({ timeout: 5_000 }).catch(() => '');
return {
session: publicSession(session),
text: text.slice(0, 30_000),
};
},
async agentClick(sessionId: string, input: { selector?: string; text?: string; x?: number; y?: number }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const point = await getActionPoint(handle.page, input);
if (input.selector) {
await handle.page.locator(input.selector).first().click({ timeout: 10_000 });
} else if (input.text) {
await handle.page.getByText(input.text, { exact: false }).first().click({ timeout: 10_000 });
} else if (typeof input.x === 'number' && typeof input.y === 'number') {
await handle.page.mouse.click(input.x, input.y);
} else {
throw new Error('Provide selector, text, or x/y coordinates.');
}
session.lastAction = 'click';
session.cursor = point ? { ...point, actor: 'agent' } : null;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentType(sessionId: string, input: { selector?: string; text: string; submit?: boolean }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
if (input.selector) {
await handle.page.locator(input.selector).first().fill(input.text, { timeout: 10_000 });
session.cursor = await getActionPoint(handle.page, input).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
} else {
await handle.page.keyboard.type(input.text);
}
if (input.submit) {
await handle.page.keyboard.press('Enter');
}
session.lastAction = 'type';
await captureSession(session, handle.page);
return publicSession(session);
},
async agentFillForm(sessionId: string, fields: Array<{ selector: string; value: string }>) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
for (const field of fields) {
await handle.page.locator(field.selector).first().fill(field.value, { timeout: 10_000 });
}
session.lastAction = 'fill_form';
if (fields[0]) {
session.cursor = await getActionPoint(handle.page, { selector: fields[0].selector }).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
}
await captureSession(session, handle.page);
return publicSession(session);
},
async agentPressKey(sessionId: string, key: string) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await handle.page.keyboard.press(key);
session.lastAction = `press_key:${key}`;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentSelectOption(sessionId: string, selector: string, values: string[]) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await handle.page.locator(selector).first().selectOption(values, { timeout: 10_000 });
session.lastAction = 'select_option';
session.cursor = await getActionPoint(handle.page, { selector }).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
await captureSession(session, handle.page);
return publicSession(session);
},
async agentWaitFor(sessionId: string, input: { text?: string; url?: string; timeoutMs?: number }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const timeout = Math.max(250, Math.min(input.timeoutMs || 5_000, 30_000));
if (input.text) {
await handle.page.getByText(input.text, { exact: false }).first().waitFor({ timeout });
} else if (input.url) {
await handle.page.waitForURL(input.url, { timeout });
} else {
await handle.page.waitForTimeout(timeout);
}
session.lastAction = 'wait_for';
await captureSession(session, handle.page);
return publicSession(session);
},
async agentTabs(sessionId: string, input: { action?: 'list' | 'new' | 'select' | 'close'; index?: number; url?: string }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.context || !handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const action = input.action || 'list';
if (action === 'new') {
const page = await handle.context.newPage();
handles.set(sessionId, { ...handle, page });
if (input.url) {
await this.agentNavigate(sessionId, input.url);
}
} else if (action === 'select') {
const page = handle.context.pages()[input.index || 0];
if (!page) {
throw new Error('Tab not found.');
}
handles.set(sessionId, { ...handle, page });
} else if (action === 'close') {
const pages = handle.context.pages();
const page = pages[input.index ?? pages.indexOf(handle.page)];
if (!page) {
throw new Error('Tab not found.');
}
await page.close();
handles.set(sessionId, { ...handle, page: handle.context.pages()[0] || await handle.context.newPage() });
}
const updatedHandle = handles.get(sessionId);
await captureSession(session, updatedHandle?.page || handle.page);
return {
session: publicSession(session),
tabs: handle.context.pages().map((page: any, index: number) => ({
index,
url: page.url(),
active: page === (updatedHandle?.page || handle.page),
})),
};
},
async stopSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { stopped: false };
}
await closeHandle(sessionId);
session.status = 'stopped';
session.updatedAt = new Date().toISOString();
session.lastAction = 'stop';
session.message = 'Browser session stopped. Create a new session to continue browsing.';
return { stopped: true, session: publicSession(session) };
},
async deleteSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { deleted: false };
}
await closeHandle(sessionId);
sessions.delete(sessionId);
return { deleted: true, sessionId };
},
async agentStopSession(sessionId: string) {
await this.getAgentSession(sessionId);
return this.stopSession(sessionId);
},
async stopAllSessions() {
await Promise.all([...sessions.keys()].map(async (sessionId) => {
await closeHandle(sessionId);
const session = sessions.get(sessionId);
if (session) {
session.status = 'stopped';
session.updatedAt = new Date().toISOString();
session.lastAction = 'shutdown';
session.message = 'Browser session stopped during server shutdown.';
}
}));
},
};
process.once('beforeExit', () => {
void browserUseService.stopAllSessions();
});

View File

@@ -1,10 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
test('browser monitor list starts empty without agent sessions', async () => {
const sessions = await browserUseService.listSessions();
assert.deepEqual(sessions, []);
});

View File

@@ -1,6 +1,5 @@
export { sessionSynchronizerService } from './services/session-synchronizer.service.js'; export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
export { providerSkillsService } from './services/skills.service.js'; export { providerSkillsService } from './services/skills.service.js';
export { providerMcpService } from './services/mcp.service.js';
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js'; export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
export { closeSessionsWatcher } from './services/sessions-watcher.service.js'; export { closeSessionsWatcher } from './services/sessions-watcher.service.js';

View File

@@ -6,6 +6,7 @@ import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js'; import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js'; import type { ProviderAuthStatus } from '@/shared/types.js';
import { createCodexRuntimeEnv, resolveCodexExecutablePath } from '@/shared/codex-cli-runtime.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js'; import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type CodexCredentialsStatus = { type CodexCredentialsStatus = {
@@ -21,8 +22,12 @@ export class CodexProviderAuth implements IProviderAuth {
*/ */
private checkInstalled(): boolean { private checkInstalled(): boolean {
try { try {
spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 }); const result = spawn.sync(resolveCodexExecutablePath(), ['--version'], {
return true; env: createCodexRuntimeEnv(),
stdio: 'ignore',
timeout: 5000,
});
return !result.error && result.status === 0;
} catch { } catch {
return false; return false;
} }

View File

@@ -80,30 +80,4 @@ export const providerMcpService = {
return results; return results;
}, },
/**
* Removes one MCP server from every provider. Mirrors `addMcpServerToAllProviders`
* by iterating the live provider registry, so callers stay in sync with which
* providers exist instead of maintaining their own provider list.
*/
async removeMcpServerFromAllProviders(
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<Array<{ provider: LLMProvider; removed: boolean; error?: string }>> {
const results: Array<{ provider: LLMProvider; removed: boolean; error?: string }> = [];
const providers = providerRegistry.listProviders();
for (const provider of providers) {
try {
const result = await provider.mcp.removeServer(input);
results.push({ provider: provider.id, removed: result.removed });
} catch (error) {
results.push({
provider: provider.id,
removed: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return results;
},
}; };

View File

@@ -5,6 +5,8 @@ import path from 'node:path';
import pty, { type IPty } from 'node-pty'; import pty, { type IPty } from 'node-pty';
import { WebSocket, type RawData } from 'ws'; import { WebSocket, type RawData } from 'ws';
import { createUserShellRuntimeEnv } from '@/shared/cli-runtime-env.js';
import { getCodexShellCommand } from '@/shared/codex-cli-runtime.js';
import { parseIncomingJsonObject } from '@/shared/utils.js'; import { parseIncomingJsonObject } from '@/shared/utils.js';
type ShellIncomingMessage = { type ShellIncomingMessage = {
@@ -137,13 +139,14 @@ function buildShellCommand(
} }
if (provider === 'codex') { if (provider === 'codex') {
const codexCommand = getCodexShellCommand();
if (resumeSessionId) { if (resumeSessionId) {
if (os.platform() === 'win32') { if (os.platform() === 'win32') {
return `codex resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { codex }`; return `${codexCommand} resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { ${codexCommand} }`;
} }
return `codex resume "${resumeSessionId}" || codex`; return `${codexCommand} resume "${resumeSessionId}" || ${codexCommand}`;
} }
return 'codex'; return codexCommand;
} }
if (provider === 'gemini') { if (provider === 'gemini') {
@@ -284,6 +287,10 @@ export function handleShellConnection(
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand]; os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
const termCols = readNumber(data.cols, 80); const termCols = readNumber(data.cols, 80);
const termRows = readNumber(data.rows, 24); const termRows = readNumber(data.rows, 24);
// Plain terminals inherit the server process PATH, which npm can prefix with
// /opt/claudecodeui/node_modules/.bin. Put user CLI bins first so shell
// commands resolve like the user's login shell instead of the app install.
const ptyEnv = createUserShellRuntimeEnv();
shellProcess = pty.spawn(shell, shellArgs, { shellProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color', name: 'xterm-256color',
@@ -291,7 +298,7 @@ export function handleShellConnection(
rows: termRows, rows: termRows,
cwd: resolvedProjectPath, cwd: resolvedProjectPath,
env: { env: {
...process.env, ...ptyEnv,
TERM: 'xterm-256color', TERM: 'xterm-256color',
COLORTERM: 'truecolor', COLORTERM: 'truecolor',
FORCE_COLOR: '3', FORCE_COLOR: '3',

View File

@@ -14,10 +14,12 @@
*/ */
import { Codex } from '@openai/codex-sdk'; import { Codex } from '@openai/codex-sdk';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCodexRuntimeEnv, resolveCodexExecutablePath } from './shared/codex-cli-runtime.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
// Track active sessions // Track active sessions
@@ -248,8 +250,11 @@ export async function queryCodex(command, options = {}, ws) {
const abortController = new AbortController(); const abortController = new AbortController();
try { try {
// Initialize Codex SDK // Initialize Codex SDK against the same user/global Codex runtime used by shell terminals.
codex = new Codex(); codex = new Codex({
codexPathOverride: resolveCodexExecutablePath(),
env: createCodexRuntimeEnv(),
});
// Thread options with sandbox and approval settings // Thread options with sandbox and approval settings
const threadOptions = { const threadOptions = {

View File

@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createUserShellRuntimeEnv } from '@/shared/cli-runtime-env.js';
const POSIX_PATH_DELIMITER = ':';
test('createUserShellRuntimeEnv prepends user CLI bins before app-local npm bins', () => {
const runtimeEnv = createUserShellRuntimeEnv(
{
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
},
{
homedir: () => '/home/devuser',
platform: 'linux',
}
);
assert.equal(
runtimeEnv.PATH,
[
'/home/devuser/.npm-global/bin',
'/home/devuser/.local/bin',
'/opt/claudecodeui/node_modules/.bin',
'/usr/bin',
].join(POSIX_PATH_DELIMITER)
);
});
test('createUserShellRuntimeEnv does not duplicate existing user CLI path entries', () => {
const runtimeEnv = createUserShellRuntimeEnv(
{
PATH: [
'/home/devuser/.npm-global/bin',
'/opt/claudecodeui/node_modules/.bin',
'/usr/bin',
].join(POSIX_PATH_DELIMITER),
},
{
homedir: () => '/home/devuser',
platform: 'linux',
}
);
assert.equal(
runtimeEnv.PATH,
[
'/home/devuser/.local/bin',
'/home/devuser/.npm-global/bin',
'/opt/claudecodeui/node_modules/.bin',
'/usr/bin',
].join(POSIX_PATH_DELIMITER)
);
});

View File

@@ -0,0 +1,109 @@
import os from 'node:os';
import path from 'node:path';
export type EnvRecord = Record<string, string | undefined>;
export type CliRuntimeEnvDependencies = {
env?: EnvRecord;
homedir?: typeof os.homedir;
platform?: NodeJS.Platform;
};
/**
* Returns the path implementation that matches the target runtime platform.
*/
function getPathApi(platform: NodeJS.Platform) {
return platform === 'win32' ? path.win32 : path.posix;
}
/**
* Returns the PATH delimiter used by the target runtime platform.
*/
function getPathDelimiter(platform: NodeJS.Platform): string {
return platform === 'win32' ? ';' : ':';
}
/**
* Finds the environment key that represents PATH, preserving Windows case variants.
*/
function getPathEnvKey(env: EnvRecord, platform: NodeJS.Platform): string {
if (platform !== 'win32') {
return 'PATH';
}
return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'Path';
}
/**
* Deduplicates non-empty string values while preserving their original order.
*/
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)));
}
/**
* Converts a process-style environment object into a string-only environment for child processes.
*/
export function toStringEnv(env: EnvRecord): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)
);
}
/**
* Builds user/global CLI bin directories that should rank ahead of app-local npm bins.
*/
export function getPreferredUserCliBinDirectories(
dependencies: Required<CliRuntimeEnvDependencies>
): string[] {
const pathApi = getPathApi(dependencies.platform);
const homeDir = dependencies.homedir();
const candidates: string[] = [];
const npmPrefix = dependencies.env.NPM_CONFIG_PREFIX?.trim();
if (npmPrefix) {
candidates.push(pathApi.join(npmPrefix, dependencies.platform === 'win32' ? '' : 'bin'));
}
if (dependencies.platform === 'win32') {
const appData = dependencies.env.APPDATA?.trim();
if (appData) {
candidates.push(appData, pathApi.join(appData, 'npm'));
}
candidates.push(pathApi.join(homeDir, 'AppData', 'Roaming', 'npm'));
} else {
candidates.push(
pathApi.join(homeDir, '.npm-global', 'bin'),
pathApi.join(homeDir, '.local', 'bin'),
);
}
return unique(candidates);
}
/**
* Creates a provider-neutral shell environment that prefers user/global CLI bins over app-local bins.
*/
export function createUserShellRuntimeEnv(
env: EnvRecord = process.env,
dependencies: CliRuntimeEnvDependencies = {}
): Record<string, string> {
const deps: Required<CliRuntimeEnvDependencies> = {
env,
homedir: dependencies.homedir ?? os.homedir,
platform: dependencies.platform ?? process.platform,
};
const pathKey = getPathEnvKey(env, deps.platform);
const delimiter = getPathDelimiter(deps.platform);
const currentPathEntries = (env[pathKey] ?? '').split(delimiter).filter(Boolean);
const preferredEntries = getPreferredUserCliBinDirectories(deps).filter(
(entry) => !currentPathEntries.includes(entry)
);
const nextEnv: EnvRecord = { ...env };
if (preferredEntries.length > 0) {
nextEnv[pathKey] = [...preferredEntries, ...currentPathEntries].join(delimiter);
}
return toStringEnv(nextEnv);
}

View File

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createCodexRuntimeEnv,
getCodexShellCommand,
resolveCodexExecutablePath,
type ResolveCodexExecutablePathDependencies,
} from '@/shared/codex-cli-runtime.js';
const POSIX_PATH_DELIMITER = ':';
function createExistsSync(paths: string[]): ResolveCodexExecutablePathDependencies['existsSync'] {
const existing = new Set(paths);
return ((candidate: string) => existing.has(candidate)) as ResolveCodexExecutablePathDependencies['existsSync'];
}
test('resolveCodexExecutablePath prefers the user npm-global install over app-local PATH entries', () => {
const globalCodexPath = '/home/devuser/.npm-global/bin/codex';
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
const resolved = resolveCodexExecutablePath(undefined, {
env: {
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
},
existsSync: createExistsSync([globalCodexPath, localCodexPath]),
homedir: () => '/home/devuser',
platform: 'linux',
});
assert.equal(resolved, globalCodexPath);
});
test('resolveCodexExecutablePath skips node_modules bin when a non-local PATH codex exists', () => {
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
const pathCodexPath = '/usr/local/bin/codex';
const resolved = resolveCodexExecutablePath(undefined, {
env: {
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/local/bin`,
},
existsSync: createExistsSync([localCodexPath, pathCodexPath]),
homedir: () => '/home/devuser',
platform: 'linux',
});
assert.equal(resolved, pathCodexPath);
});
test('resolveCodexExecutablePath falls back to app-local codex when it is the only install', () => {
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
const resolved = resolveCodexExecutablePath(undefined, {
env: {
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
},
existsSync: createExistsSync([localCodexPath]),
homedir: () => '/home/devuser',
platform: 'linux',
});
assert.equal(resolved, localCodexPath);
});
test('createCodexRuntimeEnv prepends the selected Codex directory to PATH', () => {
const runtimeEnv = createCodexRuntimeEnv(
{
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
},
{
existsSync: createExistsSync(['/home/devuser/.npm-global/bin/codex']),
homedir: () => '/home/devuser',
platform: 'linux',
}
);
assert.equal(
runtimeEnv.PATH,
`/home/devuser/.npm-global/bin${POSIX_PATH_DELIMITER}/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`
);
});
test('getCodexShellCommand quotes explicit executable paths for shell launches', () => {
const command = getCodexShellCommand({
env: {
CODEX_CLI_PATH: "/home/devuser/bin/codex with space",
},
existsSync: createExistsSync([]),
homedir: () => '/home/devuser',
platform: 'linux',
});
assert.equal(command, "'/home/devuser/bin/codex with space'");
});

View File

@@ -0,0 +1,288 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const DEFAULT_CODEX_COMMAND = 'codex';
const CODEX_CLI_PATH_ENV_KEYS = ['CODEX_CLI_PATH', 'CLOUDCLI_CODEX_CLI_PATH'] as const;
/**
* Codex runtime precedence:
* 1. Explicit CODEX_CLI_PATH or CLOUDCLI_CODEX_CLI_PATH.
* 2. User/global installs such as NPM_CONFIG_PREFIX/bin, ~/.npm-global/bin, or ~/.local/bin.
* 3. Non-local PATH entries.
* 4. App-local node_modules/.bin as the final fallback.
*/
type EnvRecord = Record<string, string | undefined>;
export type ResolveCodexExecutablePathDependencies = {
env?: EnvRecord;
existsSync?: typeof fs.existsSync;
homedir?: typeof os.homedir;
platform?: NodeJS.Platform;
};
/**
* Returns the path implementation that matches the target runtime platform.
*/
function getPathApi(platform: NodeJS.Platform) {
return platform === 'win32' ? path.win32 : path.posix;
}
/**
* Returns the PATH delimiter used by the target runtime platform.
*/
function getPathDelimiter(platform: NodeJS.Platform): string {
return platform === 'win32' ? ';' : ':';
}
/**
* Removes one matching pair of surrounding quotes from a configured path value.
*/
function stripWrappingQuotes(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
/**
* Checks whether a command value looks like a filesystem path instead of a bare command name.
*/
function isPathLike(value: string, platform: NodeJS.Platform): boolean {
return value.includes('/') || value.includes('\\') || getPathApi(platform).isAbsolute(value);
}
/**
* Finds the environment key that represents PATH, preserving Windows case variants.
*/
function getPathEnvKey(env: EnvRecord, platform: NodeJS.Platform): string {
if (platform !== 'win32') {
return 'PATH';
}
return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'Path';
}
/**
* Returns the Codex executable filenames to probe for the target platform.
*/
function getExecutableNames(platform: NodeJS.Platform): string[] {
if (platform !== 'win32') {
return [DEFAULT_CODEX_COMMAND];
}
return ['codex.exe', 'codex.cmd', 'codex.bat', 'codex.ps1', DEFAULT_CODEX_COMMAND];
}
/**
* Deduplicates non-empty string values while preserving their original order.
*/
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)));
}
/**
* Detects app-local npm bin directories so they can be treated as a fallback.
*/
function isNodeModulesBinPath(directoryPath: string, platform: NodeJS.Platform): boolean {
const pathApi = getPathApi(platform);
const normalized = directoryPath.replace(/[\\/]+$/, '');
return (
pathApi.basename(normalized).toLowerCase() === '.bin' &&
pathApi.basename(pathApi.dirname(normalized)).toLowerCase() === 'node_modules'
);
}
/**
* Resolves the first Codex executable that exists inside one directory.
*/
function resolveExecutableInDirectory(
directoryPath: string,
deps: Required<ResolveCodexExecutablePathDependencies>
): string | null {
const pathApi = getPathApi(deps.platform);
for (const executableName of getExecutableNames(deps.platform)) {
const candidate = pathApi.join(directoryPath, executableName);
if (deps.existsSync(candidate)) {
return candidate;
}
}
return null;
}
/**
* Reads an explicit Codex executable override from supported environment variables.
*/
function getConfiguredCodexPath(env: EnvRecord): string | null {
for (const key of CODEX_CLI_PATH_ENV_KEYS) {
const value = env[key]?.trim();
if (value) {
return stripWrappingQuotes(value);
}
}
return null;
}
/**
* Builds user/global Codex install candidates that rank ahead of PATH and app-local installs:
* NPM_CONFIG_PREFIX/bin, ~/.npm-global/bin, ~/.local/bin, or the Windows npm user folders.
*/
function getPreferredUserInstallCandidates(
deps: Required<ResolveCodexExecutablePathDependencies>
): string[] {
const pathApi = getPathApi(deps.platform);
const homeDir = deps.homedir();
const candidates: string[] = [];
const npmPrefix = deps.env.NPM_CONFIG_PREFIX?.trim();
if (npmPrefix) {
candidates.push(pathApi.join(npmPrefix, deps.platform === 'win32' ? '' : 'bin'));
}
if (deps.platform === 'win32') {
const appData = deps.env.APPDATA?.trim();
if (appData) {
candidates.push(appData, pathApi.join(appData, 'npm'));
}
candidates.push(pathApi.join(homeDir, 'AppData', 'Roaming', 'npm'));
} else {
candidates.push(
pathApi.join(homeDir, '.npm-global', 'bin'),
pathApi.join(homeDir, '.local', 'bin'),
);
}
return unique(candidates);
}
/**
* Searches PATH for Codex after user/global candidates, keeping node_modules/.bin as the last fallback.
*/
function resolveFromPath(
deps: Required<ResolveCodexExecutablePathDependencies>
): string | null {
const pathKey = getPathEnvKey(deps.env, deps.platform);
const pathValue = deps.env[pathKey] ?? '';
const directories = unique(pathValue.split(getPathDelimiter(deps.platform)).filter(Boolean));
let nodeModulesFallback: string | null = null;
for (const directory of directories) {
const candidate = resolveExecutableInDirectory(directory, deps);
if (!candidate) {
continue;
}
if (isNodeModulesBinPath(directory, deps.platform)) {
nodeModulesFallback ??= candidate;
continue;
}
return candidate;
}
return nodeModulesFallback;
}
/**
* Converts a process-style environment object into a string-only environment for child processes.
*/
function toStringEnv(env: EnvRecord): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)
);
}
/**
* Resolves the Codex executable path for all backend entry points in this order:
* explicit CODEX_CLI_PATH/CLOUDCLI_CODEX_CLI_PATH, user/global installs, non-local PATH, app-local PATH.
*/
export function resolveCodexExecutablePath(
configuredPath: string | undefined = undefined,
dependencies: ResolveCodexExecutablePathDependencies = {}
): string {
const deps: Required<ResolveCodexExecutablePathDependencies> = {
env: dependencies.env ?? process.env,
existsSync: dependencies.existsSync ?? fs.existsSync,
homedir: dependencies.homedir ?? os.homedir,
platform: dependencies.platform ?? process.platform,
};
const normalizedConfiguredPath = stripWrappingQuotes(
configuredPath?.trim() || getConfiguredCodexPath(deps.env) || ''
);
if (normalizedConfiguredPath) {
if (!isPathLike(normalizedConfiguredPath, deps.platform)) {
return resolveFromPath(deps) ?? normalizedConfiguredPath;
}
return normalizedConfiguredPath;
}
for (const candidateDirectory of getPreferredUserInstallCandidates(deps)) {
const candidate = resolveExecutableInDirectory(candidateDirectory, deps);
if (candidate) {
return candidate;
}
}
return resolveFromPath(deps) ?? DEFAULT_CODEX_COMMAND;
}
/**
* Creates a Codex child-process environment with the selected runtime directory first on PATH,
* preserving the same source precedence as resolveCodexExecutablePath.
*/
export function createCodexRuntimeEnv(
env: EnvRecord = process.env,
dependencies: ResolveCodexExecutablePathDependencies = {}
): Record<string, string> {
const platform = dependencies.platform ?? process.platform;
const pathApi = getPathApi(platform);
const resolvedCodexPath = resolveCodexExecutablePath(undefined, {
...dependencies,
env,
platform,
});
const pathKey = getPathEnvKey(env, platform);
const currentPath = env[pathKey] ?? '';
const resolvedDirectory = isPathLike(resolvedCodexPath, platform)
? pathApi.dirname(resolvedCodexPath)
: '';
const nextEnv: EnvRecord = { ...env };
if (resolvedDirectory) {
const delimiter = getPathDelimiter(platform);
const pathEntries = currentPath.split(delimiter).filter(Boolean);
if (!pathEntries.includes(resolvedDirectory)) {
nextEnv[pathKey] = currentPath
? `${resolvedDirectory}${delimiter}${currentPath}`
: resolvedDirectory;
}
}
return toStringEnv(nextEnv);
}
/**
* Returns the shell-safe Codex command used by interactive PTY launches.
*/
export function getCodexShellCommand(
dependencies: ResolveCodexExecutablePathDependencies = {}
): string {
const platform = dependencies.platform ?? process.platform;
const resolvedCodexPath = resolveCodexExecutablePath(undefined, dependencies);
if (!isPathLike(resolvedCodexPath, platform)) {
return resolvedCodexPath;
}
if (platform === 'win32') {
return `& '${resolvedCodexPath.replace(/'/g, "''")}'`;
}
return `'${resolvedCodexPath.replace(/'/g, "'\\''")}'`;
}

View File

@@ -71,6 +71,7 @@ function AppContentInner() {
setActiveTab, setActiveTab,
setSidebarOpen, setSidebarOpen,
setIsInputFocused, setIsInputFocused,
setShowSettings,
openSettings, openSettings,
refreshProjectsSilently, refreshProjectsSilently,
registerOptimisticSession, registerOptimisticSession,
@@ -120,12 +121,16 @@ function AppContentInner() {
}, [refreshRunningSessions]); }, [refreshRunningSessions]);
useEffect(() => { useEffect(() => {
if (processingSessions.size === 0) {
return;
}
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
void refreshRunningSessions(); void refreshRunningSessions();
}, 5000); }, 5000);
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [refreshRunningSessions]); }, [processingSessions.size, refreshRunningSessions]);
usePaletteOpsRegister({ usePaletteOpsRegister({
openSettings, openSettings,
@@ -246,7 +251,7 @@ function AppContentInner() {
onSessionEstablished={(targetSessionId, context) => onSessionEstablished={(targetSessionId, context) =>
registerOptimisticSession({ sessionId: targetSessionId, ...context }) registerOptimisticSession({ sessionId: targetSessionId, ...context })
} }
onShowSettings={openSettings} onShowSettings={() => setShowSettings(true)}
externalMessageUpdate={externalMessageUpdate} externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger} newSessionTrigger={newSessionTrigger}
/> />

View File

@@ -1 +0,0 @@
export { default as BrowserUsePanel } from './view/BrowserUsePanel';

View File

@@ -1,536 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Bot,
Clock3,
Download,
Expand,
ExternalLink,
Loader2,
MonitorPlay,
RefreshCw,
Settings,
Square,
Trash2,
X,
} from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Badge, Button } from '../../../shared/view/ui';
import { authenticatedFetch } from '../../../utils/api';
import type { SettingsMainTab } from '../../settings/types/types';
type BrowserUseStatus = {
enabled: boolean;
available: boolean;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
installInProgress: boolean;
sessionCount: number;
message: string;
};
type BrowserUseSession = {
id: string;
status: 'ready' | 'stopped' | 'unavailable';
url: string | null;
title: string | null;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
createdBy: 'agent';
profileName: string | null;
viewport: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent';
} | null;
};
type BrowserUsePanelProps = {
isVisible: boolean;
onShowSettings?: (tab?: SettingsMainTab) => void;
};
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || data.details || `Request failed (${response.status})`);
}
return data as T;
}
function formatRelativeTime(value: string | null): string {
if (!value) return 'Never';
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return 'Unknown';
const elapsedSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
if (elapsedSeconds < 10) return 'Just now';
if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`;
const elapsedMinutes = Math.round(elapsedSeconds / 60);
if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`;
const elapsedHours = Math.round(elapsedMinutes / 60);
if (elapsedHours < 24) return `${elapsedHours}h ago`;
return `${Math.round(elapsedHours / 24)}d ago`;
}
function getDomain(url: string | null): string {
if (!url) return 'No page loaded';
try {
return new URL(url).hostname;
} catch {
return url;
}
}
function formatAction(action: string | null): string {
if (!action) return 'Waiting';
return action.replace(/_/g, ' ').replace(/:/g, ': ');
}
function getStatusTone(status: BrowserUseSession['status']): string {
if (status === 'ready') {
return 'border-primary/30 bg-primary/5 text-foreground';
}
if (status === 'stopped') {
return 'border-border bg-muted text-muted-foreground';
}
return 'border-border bg-background text-muted-foreground';
}
function getRuntimeTone(status: BrowserUseStatus | null, installing: boolean): string {
if (!status?.enabled) return 'border-border bg-muted text-muted-foreground';
if (status.available) return 'border-primary/30 bg-primary/5 text-foreground';
if (status.installInProgress || installing) return 'border-primary/30 bg-primary/5 text-foreground';
return 'border-border bg-background text-muted-foreground';
}
function getStatusDot(status: BrowserUseSession['status']): string {
if (status === 'ready') return 'bg-primary';
if (status === 'stopped') return 'bg-muted-foreground/50';
return 'bg-border';
}
const PROMPTS = [
'Use Browser to inspect the checkout flow and report any broken UI states.',
'Open <url> with Browser, interact with the page, and summarize what changed after each step.',
];
export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUsePanelProps) {
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
const [sessions, setSessions] = useState<BrowserUseSession[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isBusy, setIsBusy] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
[selectedSessionId, sessions],
);
const activeSessions = sessions.filter((session) => session.status === 'ready');
const needsBrowserBinaries = Boolean(status?.enabled && (!status.playwrightInstalled || !status.chromiumInstalled));
const runtimeLabel = !status?.enabled
? 'Disabled'
: status.available
? 'Ready'
: status.installInProgress || isInstalling
? 'Installing'
: 'Setup required';
const cursorStyle = selectedSession?.cursor && selectedSession.viewport
? {
left: `${(selectedSession.cursor.x / selectedSession.viewport.width) * 100}%`,
top: `${(selectedSession.cursor.y / selectedSession.viewport.height) * 100}%`,
}
: null;
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const [statusResponse, sessionsResponse] = await Promise.all([
authenticatedFetch('/api/browser-use/status'),
authenticatedFetch('/api/browser-use/sessions'),
]);
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
const nextSessions = sessionsData.data.sessions;
setStatus(statusData.data);
setSessions(nextSessions);
setSelectedSessionId((current) => (
current && nextSessions.some((session) => session.id === current)
? current
: nextSessions[0]?.id || null
));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load Browser');
} finally {
setIsRefreshing(false);
}
}, []);
useEffect(() => {
if (!isVisible) return;
void refresh();
}, [isVisible, refresh]);
const runAction = useCallback(async (action: () => Promise<void>) => {
setIsBusy(true);
setError(null);
try {
await action();
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Browser action failed');
} finally {
setIsBusy(false);
}
}, [refresh]);
const stopSession = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/stop`, { method: 'POST' });
await readJson(response);
});
const deleteSession = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}`, { method: 'DELETE' });
await readJson(response);
setIsFullscreen(false);
});
const installBrowserBinaries = () => runAction(async () => {
setIsInstalling(true);
try {
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
await readJson(response);
} finally {
setIsInstalling(false);
}
});
const renderSessionItem = (session: BrowserUseSession) => {
const isSelected = selectedSession?.id === session.id;
return (
<button
key={session.id}
type="button"
onClick={() => setSelectedSessionId(session.id)}
className={cn(
'group w-full rounded-md border px-3 py-2.5 text-left transition-colors',
isSelected
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-border/60 bg-card/30 text-muted-foreground hover:bg-muted/50',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
<div className="truncate text-sm font-medium">{session.title || getDomain(session.url)}</div>
</div>
<div className="mt-1 truncate pl-3.5 text-xs text-muted-foreground">{getDomain(session.url)}</div>
</div>
<Badge variant="outline" className="shrink-0 border-border bg-background text-[10px] text-muted-foreground">
{session.status}
</Badge>
</div>
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Clock3 className="h-3 w-3" />
<span>{formatRelativeTime(session.updatedAt)}</span>
<span className="truncate">- {formatAction(session.lastAction)}</span>
</div>
</button>
);
};
const renderEmptyState = () => (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<div className="w-full max-w-2xl rounded-md border border-border bg-card/40 p-5 shadow-sm">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-border bg-background">
<MonitorPlay className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground">
{status?.enabled ? 'No browser sessions yet' : 'Browser is disabled'}
</div>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
{status?.enabled
? 'Agent browser sessions appear here while an AI task is using Browser.'
: 'Enable Browser in settings to let agents open monitored browser sessions.'}
</p>
</div>
</div>
{needsBrowserBinaries && (
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3">
<div className="text-sm font-medium text-foreground">Runtime setup required</div>
<p className="mt-1 text-sm text-muted-foreground">{status?.message}</p>
<Button
type="button"
size="sm"
className="mt-3"
onClick={installBrowserBinaries}
disabled={isBusy || isInstalling || status?.installInProgress}
>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling || status?.installInProgress ? 'Installing...' : 'Install Runtime'}
</Button>
</div>
)}
<div className="mt-5 grid gap-2 sm:grid-cols-2">
{PROMPTS.map((prompt) => (
<div key={prompt} className="rounded-md border border-border/70 bg-background/70 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
Prompt
</div>
<p className="text-sm leading-6 text-foreground">{prompt}</p>
</div>
))}
</div>
</div>
</div>
);
const renderBrowserSurface = (fullscreen = false) => (
<div className={cn('flex flex-1 items-center justify-center bg-neutral-950', fullscreen ? 'min-h-[80vh]' : 'min-h-[420px]')}>
{selectedSession?.screenshotDataUrl ? (
<div className="relative inline-block max-h-full">
<img
src={selectedSession.screenshotDataUrl}
alt="Browser session screenshot"
className={fullscreen ? 'block max-h-[80vh] w-auto max-w-full object-contain' : 'block max-h-[72vh] w-auto max-w-full object-contain'}
/>
{cursorStyle && (
<div
className="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/90 bg-primary/80 shadow-[0_0_0_6px_hsl(var(--primary)/0.18)]"
style={cursorStyle}
>
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
</div>
)}
</div>
) : (
<div className="px-6 text-center">
<MonitorPlay className="mx-auto h-9 w-9 text-neutral-500" />
<div className="mt-3 text-sm font-medium text-neutral-100">{selectedSession?.message || 'Waiting for screenshot'}</div>
<p className="mt-1 text-xs text-neutral-400">The next agent browser snapshot will render here.</p>
</div>
)}
</div>
);
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<MonitorPlay className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Browser</h3>
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
{runtimeLabel}
</Badge>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">Monitor browser sessions opened by AI agents.</p>
</div>
<div className="flex items-center gap-1.5">
{onShowSettings && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onShowSettings('browser')}
title="Open Browser settings"
aria-label="Open Browser settings"
>
<Settings className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => void refresh()}
disabled={isRefreshing || isBusy}
title="Refresh browser sessions"
aria-label="Refresh browser sessions"
>
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
</div>
{error && (
<div className="border-b border-destructive/20 bg-destructive/10 px-4 py-2 text-sm text-destructive">
{error}
</div>
)}
{sessions.length > 0 && (
<div className="border-b border-border/60 bg-muted/20 px-3 py-2 lg:hidden">
<div className="flex gap-2 overflow-x-auto">
{sessions.map((session) => (
<button
key={session.id}
type="button"
onClick={() => setSelectedSessionId(session.id)}
className={cn(
'flex min-w-[180px] items-center gap-2 rounded-md border px-2.5 py-2 text-left',
selectedSession?.id === session.id
? 'border-primary/40 bg-primary/5'
: 'border-border bg-background',
)}
>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{session.title || getDomain(session.url)}
</span>
</button>
))}
</div>
</div>
)}
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<main className="flex min-h-0 flex-col overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
<div className="min-w-0 truncate">
{activeSessions.length} active
<span className="px-1.5">/</span>
{sessions.length} total
</div>
<div className="min-w-0 truncate">
Updated {formatRelativeTime(selectedSession?.updatedAt || null)}
</div>
</div>
{sessions.length === 0 ? (
renderEmptyState()
) : (
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
<div className="mx-auto flex min-h-[500px] max-w-7xl flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm">
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
<Badge variant="outline" className={selectedSession ? cn('text-[10px]', getStatusTone(selectedSession.status)) : 'text-[10px]'}>
{selectedSession?.status || 'empty'}
</Badge>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{selectedSession?.title || getDomain(selectedSession?.url || null)}
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span>
</div>
</div>
<div className="hidden text-xs text-muted-foreground md:block">
{formatAction(selectedSession?.lastAction || null)}
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl} title="Full screen" aria-label="Full screen">
<Expand className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="Stop session" aria-label="Stop session">
<Square className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={deleteSession} disabled={isBusy || !selectedSession} title="Delete session" aria-label="Delete session">
<Trash2 className="h-4 w-4" />
</Button>
</div>
{renderBrowserSurface()}
</div>
</div>
)}
</main>
<aside className="hidden min-h-0 flex-col border-l border-border/60 bg-background lg:flex">
<div className="border-b border-border/60 px-4 py-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold text-foreground">Sessions</div>
<div className="mt-0.5 text-xs text-muted-foreground">{sessions.length} total</div>
</div>
<Badge variant="outline" className="text-[10px]">{activeSessions.length} active</Badge>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{sessions.length > 0 ? (
<div className="space-y-2">{sessions.map(renderSessionItem)}</div>
) : (
<div className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
No agent browser sessions.
</div>
)}
</div>
<div className="border-t border-border/60 p-3">
<div className="rounded-md border border-border/70 bg-muted/30 p-3">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
Selected
</div>
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-3">
<span>Status</span>
<span className="font-medium text-foreground">{selectedSession?.status || 'None'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Last action</span>
<span className="truncate font-medium text-foreground">{formatAction(selectedSession?.lastAction || null)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Profile</span>
<span className="truncate font-medium text-foreground">{selectedSession?.profileName || 'Temporary'}</span>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
<Square className="h-4 w-4" />
Stop
</Button>
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>
</aside>
</div>
{isFullscreen && selectedSession && (
<div className="fixed inset-0 z-50 bg-black/90 p-6">
<div className="flex h-full flex-col rounded-md border border-white/10 bg-black">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 text-sm text-white/80">
<div className="min-w-0 truncate">{selectedSession.title || selectedSession.url || 'Browser session'}</div>
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(false)}>
<X className="h-4 w-4" />
Close
</Button>
</div>
{renderBrowserSurface(true)}
</div>
</div>
)}
</div>
);
}

View File

@@ -202,9 +202,7 @@ export function useChatRealtimeHandlers({
// indicator derives from the processing map, so deleting the entry // indicator derives from the processing map, so deleting the entry
// hides it immediately and atomically. // hides it immediately and atomically.
onSessionIdle?.(sid); onSessionIdle?.(sid);
if (sid === activeViewSessionId) { setPendingPermissionRequests([]);
setPendingPermissionRequests([]);
}
if (msg.aborted) { if (msg.aborted) {
// Abort was requested — the complete event confirms it. No // Abort was requested — the complete event confirms it. No
@@ -234,19 +232,17 @@ export function useChatRealtimeHandlers({
case 'permission_request': { case 'permission_request': {
if (!msg.requestId) break; if (!msg.requestId) break;
if (sid === activeViewSessionId) { setPendingPermissionRequests((prev) => {
setPendingPermissionRequests((prev) => { if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev; return [...prev, {
return [...prev, { requestId: msg.requestId as string,
requestId: msg.requestId as string, toolName: (msg.toolName as string) || 'UnknownTool',
toolName: (msg.toolName as string) || 'UnknownTool', input: msg.input,
input: msg.input, context: msg.context,
context: msg.context, sessionId: sid || null,
sessionId: sid || null, receivedAt: new Date(),
receivedAt: new Date(), }];
}]; });
});
}
if (sid) { if (sid) {
onSessionProcessing?.(sid); onSessionProcessing?.(sid);
} }
@@ -254,7 +250,7 @@ export function useChatRealtimeHandlers({
} }
case 'permission_cancelled': { case 'permission_cancelled': {
if (msg.requestId && sid === activeViewSessionId) { if (msg.requestId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId)); setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
} }
break; break;

View File

@@ -452,31 +452,14 @@ export function useChatSessionState({
return; return;
} }
const selectedSessionId = selectedSession.id; const sessionKey = `${selectedSession.id}:${selectedProject.projectId}`;
const sessionKey = `${selectedSessionId}:${selectedProject.projectId}`;
const subscribeToSelectedSession = () => {
if (!ws) {
return;
}
statusCheckSentAtRef.current.set(selectedSessionId, Date.now());
sendMessage({
type: 'chat.subscribe',
sessions: [{
sessionId: selectedSessionId,
lastSeq: lastSeqRef.current.get(selectedSessionId) ?? 0,
}],
});
};
// Skip if already loaded and fresh // Skip if already loaded and fresh
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSessionId) && !sessionStore.isStale(selectedSessionId)) { if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) {
subscribeToSelectedSession();
return; return;
} }
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSessionId; const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
if (sessionChanged) { if (sessionChanged) {
resetStreamingState(); resetStreamingState();
} }
@@ -499,20 +482,29 @@ export function useChatSessionState({
setTokenBudget(null); setTokenBudget(null);
} }
setCurrentSessionId(selectedSessionId); setCurrentSessionId(selectedSession.id);
// Subscribe to the session's live run (if any): the ack reconciles the // Subscribe to the session's live run (if any): the ack reconciles the
// processing indicator, re-attaches a mid-flight stream to this socket, // processing indicator, re-attaches a mid-flight stream to this socket,
// and replays any live events missed since `lastSeq`. Recording the send // and replays any live events missed since `lastSeq`. Recording the send
// time lets the ack handler discard idle acks that a newer request has // time lets the ack handler discard idle acks that a newer request has
// since outdated. // since outdated.
subscribeToSelectedSession(); if (ws) {
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
sendMessage({
type: 'chat.subscribe',
sessions: [{
sessionId: selectedSession.id,
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
}],
});
}
lastLoadedSessionKeyRef.current = sessionKey; lastLoadedSessionKeyRef.current = sessionKey;
// Fetch from server → store updates → chatMessages re-derives automatically // Fetch from server → store updates → chatMessages re-derives automatically
setIsLoadingSessionMessages(true); setIsLoadingSessionMessages(true);
sessionStore.fetchFromServer(selectedSessionId, { sessionStore.fetchFromServer(selectedSession.id, {
limit: MESSAGES_PER_PAGE, limit: MESSAGES_PER_PAGE,
offset: 0, offset: 0,
}).then(slot => { }).then(slot => {

View File

@@ -7,7 +7,6 @@ import type {
SessionActivityMap, SessionActivityMap,
} from '../../../hooks/useSessionProtection'; } from '../../../hooks/useSessionProtection';
import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types'; import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types';
import type { SettingsMainTab } from '../../settings/types/types';
export type TaskMasterTask = { export type TaskMasterTask = {
id: string | number; id: string | number;
@@ -54,7 +53,7 @@ export type MainContentProps = {
processingSessions: SessionActivityMap; processingSessions: SessionActivityMap;
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void; onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void; onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void;
onShowSettings: (tab?: SettingsMainTab) => void; onShowSettings: () => void;
externalMessageUpdate: number; externalMessageUpdate: number;
newSessionTrigger: number; newSessionTrigger: number;
}; };
@@ -65,7 +64,6 @@ export type MainContentHeaderProps = {
selectedProject: Project; selectedProject: Project;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
shouldShowTasksTab: boolean; shouldShowTasksTab: boolean;
shouldShowBrowserTab: boolean;
isMobile: boolean; isMobile: boolean;
onMenuClick: () => void; onMenuClick: () => void;
}; };

View File

@@ -1,17 +1,15 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import ChatInterface from '../../chat/view/ChatInterface'; import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../file-tree/view/FileTree'; import FileTree from '../../file-tree/view/FileTree';
import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import GitPanel from '../../git-panel/view/GitPanel'; import GitPanel from '../../git-panel/view/GitPanel';
import PluginTabContent from '../../plugins/view/PluginTabContent'; import PluginTabContent from '../../plugins/view/PluginTabContent';
import { BrowserUsePanel } from '../../browser-use';
import type { MainContentProps } from '../types/types'; import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext'; import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext'; import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useUiPreferences } from '../../../hooks/useUiPreferences'; import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { authenticatedFetch } from '../../../utils/api';
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar'; import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
import EditorSidebar from '../../code-editor/view/EditorSidebar'; import EditorSidebar from '../../code-editor/view/EditorSidebar';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
@@ -57,10 +55,8 @@ function MainContent({
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue; const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue; const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
const [browserUseEnabled, setBrowserUseEnabled] = useState(false);
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const shouldShowBrowserTab = browserUseEnabled;
const { const {
editingFile, editingFile,
@@ -94,28 +90,6 @@ function MainContent({
} }
}, [shouldShowTasksTab, activeTab, setActiveTab]); }, [shouldShowTasksTab, activeTab, setActiveTab]);
const loadBrowserUseSettings = useCallback(async () => {
try {
const response = await authenticatedFetch('/api/browser-use/settings');
const data = await response.json();
setBrowserUseEnabled(Boolean(response.ok && data?.success !== false && data?.data?.settings?.enabled));
} catch {
setBrowserUseEnabled(false);
}
}, []);
useEffect(() => {
void loadBrowserUseSettings();
window.addEventListener('browserUseSettingsChanged', loadBrowserUseSettings);
return () => window.removeEventListener('browserUseSettingsChanged', loadBrowserUseSettings);
}, [loadBrowserUseSettings]);
useEffect(() => {
if (!shouldShowBrowserTab && activeTab === 'browser') {
setActiveTab('chat');
}
}, [shouldShowBrowserTab, activeTab, setActiveTab]);
usePaletteOpsRegister({ usePaletteOpsRegister({
openFile: (filePath: string) => { openFile: (filePath: string) => {
setActiveTab('files'); setActiveTab('files');
@@ -139,7 +113,6 @@ function MainContent({
selectedProject={selectedProject} selectedProject={selectedProject}
selectedSession={selectedSession} selectedSession={selectedSession}
shouldShowTasksTab={shouldShowTasksTab} shouldShowTasksTab={shouldShowTasksTab}
shouldShowBrowserTab={shouldShowBrowserTab}
isMobile={isMobile} isMobile={isMobile}
onMenuClick={onMenuClick} onMenuClick={onMenuClick}
/> />
@@ -198,11 +171,7 @@ function MainContent({
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />} {shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
{shouldShowBrowserTab && activeTab === 'browser' && ( <div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
<div className="h-full overflow-hidden">
<BrowserUsePanel isVisible={activeTab === 'browser'} onShowSettings={onShowSettings} />
</div>
)}
{activeTab.startsWith('plugin:') && ( {activeTab.startsWith('plugin:') && (
<div className="h-full overflow-hidden"> <div className="h-full overflow-hidden">

View File

@@ -10,7 +10,6 @@ export default function MainContentHeader({
selectedProject, selectedProject,
selectedSession, selectedSession,
shouldShowTasksTab, shouldShowTasksTab,
shouldShowBrowserTab,
isMobile, isMobile,
onMenuClick, onMenuClick,
}: MainContentHeaderProps) { }: MainContentHeaderProps) {
@@ -60,7 +59,6 @@ export default function MainContentHeader({
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
shouldShowTasksTab={shouldShowTasksTab} shouldShowTasksTab={shouldShowTasksTab}
shouldShowBrowserTab={shouldShowBrowserTab}
/> />
</div> </div>
{canScrollRight && ( {canScrollRight && (

View File

@@ -1,7 +1,6 @@
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, MonitorPlay, type LucideIcon } from 'lucide-react'; import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Tooltip, PillBar, Pill } from '../../../../shared/view/ui'; import { Tooltip, PillBar, Pill } from '../../../../shared/view/ui';
import type { AppTab } from '../../../../types/app'; import type { AppTab } from '../../../../types/app';
import { usePlugins } from '../../../../contexts/PluginsContext'; import { usePlugins } from '../../../../contexts/PluginsContext';
@@ -11,7 +10,6 @@ type MainContentTabSwitcherProps = {
activeTab: AppTab; activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>; setActiveTab: Dispatch<SetStateAction<AppTab>>;
shouldShowTasksTab: boolean; shouldShowTasksTab: boolean;
shouldShowBrowserTab: boolean;
}; };
type BuiltInTab = { type BuiltInTab = {
@@ -38,13 +36,6 @@ const BASE_TABS: BuiltInTab[] = [
{ kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch }, { kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch },
]; ];
const BROWSER_TAB: BuiltInTab = {
kind: 'builtin',
id: 'browser',
labelKey: 'tabs.browser',
icon: MonitorPlay,
};
const TASKS_TAB: BuiltInTab = { const TASKS_TAB: BuiltInTab = {
kind: 'builtin', kind: 'builtin',
id: 'tasks', id: 'tasks',
@@ -56,16 +47,11 @@ export default function MainContentTabSwitcher({
activeTab, activeTab,
setActiveTab, setActiveTab,
shouldShowTasksTab, shouldShowTasksTab,
shouldShowBrowserTab,
}: MainContentTabSwitcherProps) { }: MainContentTabSwitcherProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { plugins } = usePlugins(); const { plugins } = usePlugins();
const builtInTabs: BuiltInTab[] = [ const builtInTabs: BuiltInTab[] = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
...BASE_TABS,
...(shouldShowBrowserTab ? [BROWSER_TAB] : []),
...(shouldShowTasksTab ? [TASKS_TAB] : []),
];
const pluginTabs: PluginTab[] = plugins const pluginTabs: PluginTab[] = plugins
.filter((p) => p.enabled) .filter((p) => p.enabled)

View File

@@ -1,5 +1,4 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { AppTab, Project, ProjectSession } from '../../../../types/app'; import type { AppTab, Project, ProjectSession } from '../../../../types/app';
import { usePlugins } from '../../../../contexts/PluginsContext'; import { usePlugins } from '../../../../contexts/PluginsContext';
@@ -28,10 +27,6 @@ function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: st
return 'TaskMaster'; return 'TaskMaster';
} }
if (activeTab === 'browser') {
return 'Browser';
}
return 'Project'; return 'Project';
} }

View File

@@ -52,11 +52,6 @@ const getServerKey = (server: ProviderMcpServer): string => (
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}` `${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
); );
// Servers prefixed with `cloudcli-` are written and removed automatically by a
// CloudCLI feature toggle (e.g. the Browser tab), not added by the user. They are
// shown read-only so users don't edit/delete them out of sync with the feature.
const isManagedServer = (server: ProviderMcpServer): boolean => server.name.startsWith('cloudcli-');
function ConfigLine({ label, children }: { label: string; children: string }) { function ConfigLine({ label, children }: { label: string; children: string }) {
if (!children) { if (!children) {
return null; return null;
@@ -182,92 +177,65 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
<div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div> <div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div>
)} )}
{servers.map((server) => { {servers.map((server) => (
const managed = isManagedServer(server); <div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
return ( <div className="min-w-0 flex-1">
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4"> <div className="mb-2 flex flex-wrap items-center gap-2">
<div className="flex items-start justify-between"> {getTransportIcon(server.transport)}
<div className="min-w-0 flex-1"> <span className="font-medium text-foreground">{server.name}</span>
<div className="mb-2 flex flex-wrap items-center gap-2"> <Badge variant="outline" className="text-xs">
{!managed && getTransportIcon(server.transport)} {server.transport || 'stdio'}
<span className="font-medium text-foreground">{server.name}</span> </Badge>
{!managed && ( <Badge variant="outline" className="text-xs">
<> {getScopeLabel(server.scope)}
<Badge variant="outline" className="text-xs"> </Badge>
{server.transport || 'stdio'} {server.projectDisplayName && (
</Badge> <Badge variant="outline" className="max-w-full truncate text-xs">
<Badge variant="outline" className="text-xs"> {server.projectDisplayName}
{getScopeLabel(server.scope)} </Badge>
</Badge> )}
{server.projectDisplayName && (
<Badge variant="outline" className="max-w-full truncate text-xs">
{server.projectDisplayName}
</Badge>
)}
</>
)}
{managed && (
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
{t('mcpServers.managed.badge', { defaultValue: 'Managed' })}
</Badge>
)}
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{!managed && (
<>
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
{server.env && Object.keys(server.env).length > 0 && (
<ConfigLine label={t('mcpServers.config.environment')}>
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
</ConfigLine>
)}
{server.envVars && server.envVars.length > 0 && (
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
)}
</>
)}
{managed && (
<div className="text-xs text-muted-foreground">
{t('mcpServers.managed.hint', {
defaultValue: 'Managed by CloudCLI.',
})}
</div>
)}
</div>
</div> </div>
{!managed && ( <div className="space-y-1 text-sm text-muted-foreground">
<div className="ml-4 flex items-center gap-2"> <ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
<Button <ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
onClick={() => openForm(server)} <ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
variant="ghost" <ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
size="sm" {server.env && Object.keys(server.env).length > 0 && (
className="text-muted-foreground hover:text-foreground" <ConfigLine label={t('mcpServers.config.environment')}>
title={t('mcpServers.actions.edit')} {Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
> </ConfigLine>
<Edit3 className="h-4 w-4" /> )}
</Button> {server.envVars && server.envVars.length > 0 && (
<Button <ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
onClick={() => deleteServer(server)} )}
variant="ghost" </div>
size="sm" </div>
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')} <div className="ml-4 flex items-center gap-2">
> <Button
<Trash2 className="h-4 w-4" /> onClick={() => openForm(server)}
</Button> variant="ghost"
</div> size="sm"
)} className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => deleteServer(server)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
); </div>
})} ))}
{!isLoading && !isLoadingProjectScopes && servers.length === 0 && ( {!isLoading && !isLoadingProjectScopes && servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div> <div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>

View File

@@ -4,14 +4,11 @@ import {
Activity, Activity,
BarChart3, BarChart3,
BookOpen, BookOpen,
Calculator,
Clock, Clock,
Download, Download,
ExternalLink, ExternalLink,
Github,
GitBranch, GitBranch,
Loader2, Loader2,
ListTodo,
RefreshCw, RefreshCw,
ServerCrash, ServerCrash,
ShieldAlert, ShieldAlert,
@@ -30,10 +27,6 @@ const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-term
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron'; const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch'; const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism'; const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism';
const SESSION_MANAGER_PLUGIN_URL = 'https://github.com/strykereye2/cloudcli-plugin-session-manager';
const TOKEN_COST_CALCULATOR_PLUGIN_URL = 'https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator';
const TASK_QUEUE_PLUGIN_URL = 'https://github.com/TadMSTR/cloudcli-plugin-task-queue';
const GITHUB_ISSUES_BOARD_PLUGIN_URL = 'https://github.com/szmidtpiotr/claude-github-issue';
type PluginRecommendation = { type PluginRecommendation = {
id: string; id: string;
@@ -86,40 +79,8 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL, repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
installedNames: ['prism'], installedNames: ['prism'],
icon: Activity, icon: Activity,
source: 'unofficial', source: 'unofficial'
}, }
{
id: 'session-manager',
translationKey: 'sessionManagerPlugin',
repoUrl: SESSION_MANAGER_PLUGIN_URL,
installedNames: ['session-manager'],
icon: Activity,
source: 'unofficial',
},
{
id: 'token-cost-calculator',
translationKey: 'tokenCostCalculatorPlugin',
repoUrl: TOKEN_COST_CALCULATOR_PLUGIN_URL,
installedNames: ['token-cost-calculator'],
icon: Calculator,
source: 'unofficial',
},
{
id: 'task-queue',
translationKey: 'taskQueuePlugin',
repoUrl: TASK_QUEUE_PLUGIN_URL,
installedNames: ['task-queue'],
icon: ListTodo,
source: 'unofficial',
},
{
id: 'claude-github-issue',
translationKey: 'githubIssuesBoardPlugin',
repoUrl: GITHUB_ISSUES_BOARD_PLUGIN_URL,
installedNames: ['claude-github-issue'],
icon: Github,
source: 'unofficial',
},
]; ];
function repoSlug(repoUrl: string) { function repoSlug(repoUrl: string) {

View File

@@ -6,7 +6,6 @@ import {
Info, Info,
KeyRound, KeyRound,
ListChecks, ListChecks,
MonitorPlay,
Palette, Palette,
Plug, Plug,
} from 'lucide-react'; } from 'lucide-react';
@@ -33,7 +32,6 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
{ id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch }, { id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch },
{ id: 'api', label: 'API Tokens', keywords: 'api tokens auth keys', icon: KeyRound }, { id: 'api', label: 'API Tokens', keywords: 'api tokens auth keys', icon: KeyRound },
{ id: 'tasks', label: 'Tasks', keywords: 'tasks taskmaster', icon: ListChecks }, { id: 'tasks', label: 'Tasks', keywords: 'tasks taskmaster', icon: ListChecks },
{ id: 'browser', label: 'Browser', keywords: 'browser playwright chromium automation', icon: MonitorPlay },
{ id: 'notifications', label: 'Notifications', keywords: 'notifications alerts push', icon: Bell }, { id: 'notifications', label: 'Notifications', keywords: 'notifications alerts push', icon: Bell },
{ id: 'plugins', label: 'Plugins', keywords: 'plugins extensions integrations', icon: Plug }, { id: 'plugins', label: 'Plugins', keywords: 'plugins extensions integrations', icon: Plug },
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info }, { id: 'about', label: 'About', keywords: 'about version info', icon: Info },

View File

@@ -54,7 +54,7 @@ type NotificationPreferencesResponse = {
type ActiveLoginProvider = AgentProvider | ''; type ActiveLoginProvider = AgentProvider | '';
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'browser', 'notifications', 'plugins', 'about']; const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications', 'plugins'];
const normalizeMainTab = (tab: string): SettingsMainTab => { const normalizeMainTab = (tab: string): SettingsMainTab => {
// Keep backwards compatibility with older callers that still pass "tools". // Keep backwards compatibility with older callers that still pass "tools".

View File

@@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from 'react';
import type { LLMProvider } from '../../../types/app'; import type { LLMProvider } from '../../../types/app';
import type { ProviderAuthStatus } from '../../provider-auth/types'; import type { ProviderAuthStatus } from '../../provider-auth/types';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about'; export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins' | 'about';
export type AgentProvider = LLMProvider; export type AgentProvider = LLMProvider;
export type AgentCategory = 'account' | 'permissions' | 'mcp'; export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date'; export type ProjectSortOrder = 'name' | 'date';

View File

@@ -7,7 +7,6 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab'; import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab'; import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab'; import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import BrowserUseSettingsTab from '../view/tabs/browser-use-settings/BrowserUseSettingsTab';
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab'; import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab'; import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab'; import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
@@ -140,19 +139,17 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
{activeTab === 'tasks' && <TasksSettingsTab />} {activeTab === 'tasks' && <TasksSettingsTab />}
{activeTab === 'browser' && <BrowserUseSettingsTab />} {activeTab === 'notifications' && (
<NotificationsSettingsTab
{activeTab === 'notifications' && ( notificationPreferences={notificationPreferences}
<NotificationsSettingsTab onNotificationPreferencesChange={setNotificationPreferences}
notificationPreferences={notificationPreferences} pushPermission={pushPermission}
onNotificationPreferencesChange={setNotificationPreferences} isPushSubscribed={isPushSubscribed}
pushPermission={pushPermission} isPushLoading={isPushLoading}
isPushSubscribed={isPushSubscribed} onEnablePush={handleEnablePush}
isPushLoading={isPushLoading} onDisablePush={handleDisablePush}
onEnablePush={handleEnablePush} />
onDisablePush={handleDisablePush} )}
/>
)}
{activeTab === 'api' && <CredentialsSettingsTab />} {activeTab === 'api' && <CredentialsSettingsTab />}

View File

@@ -1,4 +1,4 @@
import { Bell, Bot, GitBranch, Info, Key, ListChecks, MonitorPlay, Palette, Puzzle } from 'lucide-react'; import { Bell, Bot, GitBranch, Info, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils'; import { cn } from '../../../lib/utils';
import { PillBar, Pill } from '../../../shared/view/ui'; import { PillBar, Pill } from '../../../shared/view/ui';
@@ -21,7 +21,6 @@ const NAV_ITEMS: NavItem[] = [
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch }, { id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key }, { id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks }, { id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
{ id: 'browser', labelKey: 'mainTabs.browser', icon: MonitorPlay },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle }, { id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
{ id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell }, { id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell },
{ id: 'about', labelKey: 'mainTabs.about', icon: Info }, { id: 'about', labelKey: 'mainTabs.about', icon: Info },

View File

@@ -1,185 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Download, Loader2 } from 'lucide-react';
import { Button } from '../../../../../shared/view/ui';
import { authenticatedFetch } from '../../../../../utils/api';
import SettingsCard from '../../SettingsCard';
import SettingsRow from '../../SettingsRow';
import SettingsSection from '../../SettingsSection';
import SettingsToggle from '../../SettingsToggle';
type BrowserUseSettings = {
enabled: boolean;
};
type BrowserUseStatus = {
enabled: boolean;
available: boolean;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
installInProgress: boolean;
message: string;
};
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || data.details || `Request failed (${response.status})`);
}
return data as T;
}
export default function BrowserUseSettingsTab() {
const [settings, setSettings] = useState<BrowserUseSettings | null>(null);
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
const [isSettingsLoading, setIsSettingsLoading] = useState(true);
const [isStatusLoading, setIsStatusLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadSettings = useCallback(async () => {
const settingsResponse = await authenticatedFetch('/api/browser-use/settings');
const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse);
setSettings(settingsData.data.settings);
}, []);
const loadStatus = useCallback(async () => {
const statusResponse = await authenticatedFetch('/api/browser-use/status');
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
setStatus(statusData.data);
}, []);
useEffect(() => {
setError(null);
setIsSettingsLoading(true);
setIsStatusLoading(true);
void loadSettings()
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser settings'))
.finally(() => setIsSettingsLoading(false));
void loadStatus()
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser status'))
.finally(() => setIsStatusLoading(false));
}, [loadSettings, loadStatus]);
const updateSettings = async (nextSettings: Partial<BrowserUseSettings>) => {
setIsSaving(true);
setError(null);
try {
const response = await authenticatedFetch('/api/browser-use/settings', {
method: 'PUT',
body: JSON.stringify(nextSettings),
});
const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response);
setSettings(data.data.settings);
window.dispatchEvent(new Event('browserUseSettingsChanged'));
setIsStatusLoading(true);
await loadStatus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save Browser settings');
} finally {
setIsStatusLoading(false);
setIsSaving(false);
}
};
const installBrowserBinaries = async () => {
setIsInstalling(true);
setError(null);
try {
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
await readJson(response);
setIsStatusLoading(true);
await loadStatus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to install browser runtime');
} finally {
setIsStatusLoading(false);
setIsInstalling(false);
}
};
const browserEnabled = settings?.enabled === true;
const needsBrowserBinaries = Boolean(browserEnabled && status && (!status.playwrightInstalled || !status.chromiumInstalled));
const runtimeLabel = (installed?: boolean) => {
if (isStatusLoading && !status) {
return 'checking...';
}
return installed ? 'installed' : 'missing';
};
return (
<div className="space-y-8">
<SettingsSection
title="Browser"
description="Allow agents to create guarded Playwright browser sessions that you can monitor from the Browser tab."
>
<SettingsCard divided>
<SettingsRow
label="Enable Browser"
description="Registers Browser for supported agents. Agents can create browser sessions; you can watch, stop, and delete them."
>
{isSettingsLoading && !settings ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<SettingsToggle
checked={browserEnabled}
onChange={(value) => void updateSettings({ enabled: value })}
ariaLabel="Enable Browser"
disabled={isSaving}
/>
)}
</SettingsRow>
<div className="space-y-4 px-4 py-4">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-md border border-border px-2 py-1">
Playwright: {runtimeLabel(status?.playwrightInstalled)}
</span>
<span className="rounded-md border border-border px-2 py-1">
Chromium: {runtimeLabel(status?.chromiumInstalled)}
</span>
<span className="rounded-md border border-border px-2 py-1">
Status: {isStatusLoading && !status ? 'checking...' : status?.available ? 'ready' : browserEnabled ? 'setup required' : 'disabled'}
</span>
</div>
{needsBrowserBinaries && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium text-foreground">Browser runtime required</div>
<p className="text-sm text-muted-foreground">
{status?.message || 'Install the browser runtime before agents can create Browser sessions.'}
</p>
</div>
<Button
type="button"
size="sm"
onClick={() => void installBrowserBinaries()}
disabled={isInstalling || status?.installInProgress}
className="flex-shrink-0"
>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling || status?.installInProgress ? 'Installing...' : 'Install Runtime'}
</Button>
</div>
)}
{error && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
{error}
</div>
)}
</div>
</SettingsCard>
</SettingsSection>
</div>
);
}

View File

@@ -69,7 +69,7 @@ export default function SidebarFooter({
onClick={onShowVersionModal} onClick={onShowVersionModal}
> >
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<ArrowUpCircle className="h-4 w-4 text-blue-500 dark:text-blue-400" /> <ArrowUpCircle className="w-4.5 h-4.5 text-blue-500 dark:text-blue-400" />
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" /> <span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
</div> </div>
<div className="min-w-0 flex-1 text-left"> <div className="min-w-0 flex-1 text-left">
@@ -145,12 +145,12 @@ export default function SidebarFooter({
href={GITHUB_ISSUES_URL} href={GITHUB_ISSUES_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex h-10 w-full items-center gap-3 rounded-xl bg-muted/40 px-3.5 transition-all hover:bg-muted/60 active:scale-[0.98]" 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-7 w-7 items-center justify-center rounded-lg bg-background/80"> <div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80">
<Bug className="h-4 w-4 text-muted-foreground" /> <Bug className="w-4.5 h-4.5 text-muted-foreground" />
</div> </div>
<span className="text-sm font-medium text-foreground">{t('actions.reportIssue')}</span> <span className="text-base font-medium text-foreground">{t('actions.reportIssue')}</span>
</a> </a>
</div> </div>
@@ -160,25 +160,25 @@ export default function SidebarFooter({
href={DISCORD_INVITE_URL} href={DISCORD_INVITE_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex h-10 w-full items-center gap-3 rounded-xl bg-muted/40 px-3.5 transition-all hover:bg-muted/60 active:scale-[0.98]" 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-7 w-7 items-center justify-center rounded-lg bg-background/80"> <div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80">
<DiscordIcon className="h-4 w-4 text-muted-foreground" /> <DiscordIcon className="w-4.5 h-4.5 text-muted-foreground" />
</div> </div>
<span className="text-sm font-medium text-foreground">{t('actions.joinCommunity')}</span> <span className="text-base font-medium text-foreground">{t('actions.joinCommunity')}</span>
</a> </a>
</div> </div>
{/* Mobile settings */} {/* Mobile settings */}
<div className="px-3 pb-3 pt-2 md:hidden"> <div className="px-3 pb-3 pt-2 md:hidden">
<button <button
className="flex h-10 w-full items-center gap-3 rounded-xl bg-muted/40 px-3.5 transition-all hover:bg-muted/60 active:scale-[0.98]" 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]"
onClick={onShowSettings} onClick={onShowSettings}
> >
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80"> <div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80">
<Settings className="h-4 w-4 text-muted-foreground" /> <Settings className="w-4.5 h-4.5 text-muted-foreground" />
</div> </div>
<span className="text-sm font-medium text-foreground">{t('actions.settings')}</span> <span className="text-base font-medium text-foreground">{t('actions.settings')}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -324,7 +324,7 @@ const removeSessionFromProject = (project: Project, sessionIdToDelete: string):
return updatedProject; return updatedProject;
}; };
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'browser']); const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
const isValidTab = (tab: string): tab is AppTab => { const isValidTab = (tab: string): tab is AppTab => {
return VALID_TABS.has(tab) || tab.startsWith('plugin:'); return VALID_TABS.has(tab) || tab.startsWith('plugin:');
@@ -776,7 +776,7 @@ export function useProjectsState({
(session: ProjectSession) => { (session: ProjectSession) => {
setSelectedSession(session); setSelectedSession(session);
if (activeTab === 'tasks' || activeTab === 'browser') { if (activeTab === 'tasks' || activeTab === 'preview') {
setActiveTab('chat'); setActiveTab('chat');
} }

View File

@@ -6,7 +6,7 @@ export interface SessionActivity {
canInterrupt: boolean; canInterrupt: boolean;
/** /**
* When this request was first marked as processing (client clock). Drives * When this request was first marked as processing (client clock). Drives
* the elapsed-time display and the stale `chat_subscribed` idle-ack guard. * the elapsed-time display and the stale `session-status` reply guard.
*/ */
startedAt: number; startedAt: number;
} }

View File

@@ -14,11 +14,6 @@ export const languages = [
label: 'English', label: 'English',
nativeName: 'English', nativeName: 'English',
}, },
{
value: 'fr',
label: 'French',
nativeName: 'Français',
},
{ {
value: 'ko', value: 'ko',
label: 'Korean', label: 'Korean',
@@ -53,8 +48,6 @@ export const languages = [
value: 'tr', value: 'tr',
label: 'Turkish', label: 'Turkish',
nativeName: 'Türkçe', nativeName: 'Türkçe',
},
{
value: 'it', value: 'it',
label: 'Italian', label: 'Italian',
nativeName: 'Italiano', nativeName: 'Italiano',

View File

@@ -22,8 +22,7 @@
"shell": "Terminal", "shell": "Terminal",
"files": "Dateien", "files": "Dateien",
"git": "Quellcodeverwaltung", "git": "Quellcodeverwaltung",
"tasks": "Aufgaben", "tasks": "Aufgaben"
"browser": "Browser"
}, },
"status": { "status": {
"loading": "Lädt...", "loading": "Lädt...",

View File

@@ -22,8 +22,7 @@
"shell": "Shell", "shell": "Shell",
"files": "Files", "files": "Files",
"git": "Source Control", "git": "Source Control",
"tasks": "Tasks", "tasks": "Tasks"
"browser": "Browser"
}, },
"status": { "status": {
"loading": "Loading...", "loading": "Loading...",

View File

@@ -94,7 +94,6 @@
"git": "Git", "git": "Git",
"apiTokens": "API & Tokens", "apiTokens": "API & Tokens",
"tasks": "Tasks", "tasks": "Tasks",
"browser": "Browser",
"notifications": "Notifications", "notifications": "Notifications",
"plugins": "Plugins", "plugins": "Plugins",
"about": "About" "about": "About"
@@ -451,10 +450,6 @@
"edit": "Edit server", "edit": "Edit server",
"delete": "Delete server" "delete": "Delete server"
}, },
"managed": {
"badge": "Managed",
"hint": "Managed by CloudCLI."
},
"help": { "help": {
"title": "About Codex MCP", "title": "About Codex MCP",
"description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources." "description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources."
@@ -519,30 +514,6 @@
"description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.", "description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.",
"install": "Install" "install": "Install"
}, },
"sessionManagerPlugin": {
"name": "Sessions",
"badge": "unofficial",
"description": "View, manage, and kill active Claude Code sessions.",
"install": "Install"
},
"tokenCostCalculatorPlugin": {
"name": "Token Cost Calculator",
"badge": "unofficial",
"description": "Calculate API costs from model prices and token usage, with preset model pricing support.",
"install": "Install"
},
"taskQueuePlugin": {
"name": "Task Queue",
"badge": "unofficial",
"description": "Task queue dashboard to view, filter, and launch agent tasks.",
"install": "Install"
},
"githubIssuesBoardPlugin": {
"name": "GitHub Issues Board",
"badge": "unofficial",
"description": "Kanban board for GitHub Issues with bidirectional TaskMaster sync and /github-task CLI skill auto-install",
"install": "Install"
},
"morePlugins": "More", "morePlugins": "More",
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",

View File

@@ -1,37 +0,0 @@
{
"login": {
"title": "Bon retour",
"description": "Connectez-vous à votre compte CloudCLI auto-hébergé",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"submit": "Se connecter",
"loading": "Connexion en cours...",
"errors": {
"invalidCredentials": "Nom d'utilisateur ou mot de passe incorrect",
"requiredFields": "Veuillez remplir tous les champs",
"networkError": "Erreur réseau. Veuillez réessayer."
},
"placeholders": {
"username": "Entrez votre nom d'utilisateur",
"password": "Entrez votre mot de passe"
}
},
"register": {
"title": "Créer un compte",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"submit": "Créer le compte",
"loading": "Création du compte...",
"errors": {
"passwordMismatch": "Les mots de passe ne correspondent pas",
"usernameTaken": "Ce nom d'utilisateur est déjà pris",
"weakPassword": "Le mot de passe est trop faible"
}
},
"logout": {
"title": "Se déconnecter",
"confirm": "Êtes-vous sûr de vouloir vous déconnecter ?",
"button": "Se déconnecter"
}
}

View File

@@ -1,241 +0,0 @@
{
"codeBlock": {
"copy": "Copier",
"copied": "Copié",
"copyCode": "Copier le code"
},
"copyMessage": {
"copy": "Copier le message",
"copied": "Message copié",
"selectFormat": "Sélectionner le format de copie",
"copyAsMarkdown": "Copier en markdown",
"copyAsText": "Copier en texte brut"
},
"messageTypes": {
"user": "U",
"error": "Erreur",
"tool": "Outil",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
},
"tools": {
"settings": "Paramètres de l'outil",
"error": "Erreur de l'outil",
"result": "Résultat de l'outil",
"viewParams": "Voir les paramètres d'entrée",
"viewRawParams": "Voir les paramètres bruts",
"viewDiff": "Voir les différences pour",
"creatingFile": "Création du fichier :",
"updatingTodo": "Mise à jour de la liste de tâches",
"read": "Lire",
"readFile": "Lire le fichier",
"updateTodo": "Mettre à jour la liste de tâches",
"readTodo": "Lire la liste de tâches",
"searchResults": "résultats"
},
"search": {
"found": "{{count}} {{type}} trouvé(s)",
"file": "fichier",
"files": "fichiers",
"pattern": "motif :",
"in": "dans :"
},
"fileOperations": {
"updated": "Fichier mis à jour avec succès",
"created": "Fichier créé avec succès",
"written": "Fichier écrit avec succès",
"diff": "Diff",
"newFile": "Nouveau fichier",
"viewContent": "Voir le contenu du fichier",
"viewFullOutput": "Voir la sortie complète ({{count}} caractères)",
"contentDisplayed": "Le contenu du fichier est affiché dans la vue diff ci-dessus"
},
"interactive": {
"title": "Invite interactive",
"waiting": "En attente de votre réponse dans le CLI",
"instruction": "Veuillez sélectionner une option dans votre terminal où Claude s'exécute.",
"selectedOption": "✓ Claude a sélectionné l'option {{number}}",
"instructionDetail": "Dans le CLI, vous sélectionneriez cette option de manière interactive avec les touches fléchées ou en tapant le numéro."
},
"thinking": {
"title": "Réflexion...",
"emoji": "💭 Réflexion..."
},
"json": {
"response": "Réponse JSON"
},
"permissions": {
"grant": "Autoriser {{tool}}",
"added": "Permission ajoutée",
"addTo": "Ajoute {{entry}} aux outils autorisés.",
"retry": "Permission enregistrée. Relancez la requête pour utiliser l'outil.",
"error": "Impossible de mettre à jour les permissions. Veuillez réessayer.",
"openSettings": "Ouvrir les paramètres"
},
"todo": {
"updated": "La liste de tâches a été mise à jour avec succès",
"current": "Liste de tâches actuelle"
},
"plan": {
"viewPlan": "📋 Voir le plan d'implémentation",
"title": "Plan d'implémentation"
},
"usageLimit": {
"resetAt": "Limite d'utilisation Claude atteinte. Votre limite sera réinitialisée à **{{time}} {{timezone}}** - {{date}}"
},
"codex": {
"permissionMode": "Mode de permission",
"modes": {
"default": "Mode par défaut",
"auto": "Mode automatique",
"acceptEdits": "Accepter les modifications",
"bypassPermissions": "Contourner les permissions",
"plan": "Mode planification"
},
"descriptions": {
"default": "Seules les commandes de confiance (ls, cat, grep, git status, etc.) s'exécutent automatiquement. Les autres commandes sont ignorées. Peut écrire dans l'espace de travail.",
"auto": "Un classifieur de modèle décide pour chaque appel d'outil d'approuver ou refuser. Mode mains libres, mais plus sûr que le contournement — des refus peuvent toujours se produire.",
"acceptEdits": "Toutes les commandes s'exécutent automatiquement dans l'espace de travail. Mode entièrement automatique avec exécution sandboxée.",
"bypassPermissions": "Accès système complet sans restrictions. Toutes les commandes s'exécutent automatiquement avec accès disque et réseau complet. À utiliser avec précaution.",
"plan": "Mode planification - aucune commande n'est exécutée"
},
"technicalDetails": "Détails techniques"
},
"gemini": {
"permissionMode": "Mode de permission Gemini",
"description": "Contrôlez comment Gemini CLI gère les approbations d'opérations.",
"modes": {
"default": {
"title": "Standard (demander l'approbation)",
"description": "Gemini demandera une approbation avant d'exécuter des commandes, d'écrire des fichiers et de récupérer des ressources web."
},
"autoEdit": {
"title": "Modification automatique (ignorer les approbations de fichiers)",
"description": "Gemini approuvera automatiquement les modifications de fichiers et les récupérations web, mais demandera toujours pour les commandes shell."
},
"yolo": {
"title": "YOLO (contourner toutes les permissions)",
"description": "Gemini exécutera toutes les opérations sans demander d'approbation. Utilisez avec prudence."
}
}
},
"input": {
"placeholder": "Tapez / pour les commandes, @ pour les fichiers, ou posez une question à {{provider}}...",
"placeholderDefault": "Tapez votre message...",
"disabled": "Saisie désactivée",
"attachFiles": "Joindre des fichiers",
"attachImages": "Joindre des images",
"send": "Envoyer",
"stop": "Arrêter",
"hintText": {
"ctrlEnter": "Ctrl+Entrée pour envoyer • Maj+Entrée pour nouvelle ligne • Tab pour changer de mode • / pour les commandes slash",
"enter": "Entrée pour envoyer • Maj+Entrée pour nouvelle ligne • Tab pour changer de mode • / pour les commandes slash"
},
"clickToChangeMode": "Cliquez pour changer le mode de permission (ou appuyez sur Tab dans la saisie)",
"showAllCommands": "Afficher toutes les commandes",
"clearInput": "Effacer la saisie",
"scrollToBottom": "Défiler vers le bas"
},
"providerSelection": {
"title": "Choisissez votre assistant IA",
"description": "Sélectionnez un fournisseur pour démarrer une nouvelle conversation",
"selectModel": "Sélectionner un modèle",
"providerInfo": {
"anthropic": "par Anthropic",
"openai": "par OpenAI",
"cursorEditor": "Éditeur de code IA",
"google": "par Google"
},
"readyPrompt": {
"claude": "Prêt à utiliser Claude avec {{model}}. Commencez à taper votre message ci-dessous.",
"cursor": "Prêt à utiliser Cursor avec {{model}}. Commencez à taper votre message ci-dessous.",
"codex": "Prêt à utiliser Codex avec {{model}}. Commencez à taper votre message ci-dessous.",
"gemini": "Prêt à utiliser Gemini avec {{model}}. Commencez à taper votre message ci-dessous.",
"opencode": "Prêt à utiliser OpenCode avec {{model}}. Commencez à taper votre message ci-dessous.",
"default": "Sélectionnez un fournisseur ci-dessus pour commencer"
},
"pressToSearch": "Appuyez sur <kbd>{{shortcut}}</kbd> pour rechercher sessions, fichiers et commits"
},
"session": {
"continue": {
"title": "Continuer votre conversation",
"description": "Posez des questions sur votre code, demandez des modifications ou obtenez de l'aide pour vos tâches de développement"
},
"loading": {
"olderMessages": "Chargement des messages précédents...",
"sessionMessages": "Chargement des messages de la session..."
},
"messages": {
"showingOf": "Affichage de {{shown}} sur {{total}} messages",
"scrollToLoad": "Faites défiler vers le haut pour charger plus",
"showingLast": "Affichage des {{count}} derniers messages ({{total}} au total)",
"loadEarlier": "Charger les messages précédents",
"loadAll": "Charger tous les messages",
"loadingAll": "Chargement de tous les messages...",
"allLoaded": "Tous les messages chargés",
"perfWarning": "Tous les messages chargés — le défilement peut être plus lent. Cliquez sur « Défiler vers le bas » pour rétablir les performances."
}
},
"shell": {
"selectProject": {
"title": "Sélectionner un projet",
"description": "Choisissez un projet pour ouvrir un shell interactif dans ce répertoire"
},
"status": {
"newSession": "Nouvelle session",
"initializing": "Initialisation...",
"restarting": "Redémarrage..."
},
"actions": {
"disconnect": "Déconnecter",
"disconnectTitle": "Se déconnecter du shell",
"restart": "Redémarrer",
"restartTitle": "Redémarrer le shell",
"connect": "Continuer dans le shell",
"connectTitle": "Se connecter au shell"
},
"loading": "Chargement du terminal...",
"connecting": "Connexion au shell...",
"startSession": "Démarrer une nouvelle session Claude",
"resumeSession": "Reprendre la session : {{displayName}}...",
"runCommand": "Exécuter {{command}} dans {{projectName}}",
"startCli": "Démarrage du CLI Claude dans {{projectName}}",
"defaultCommand": "commande"
},
"claudeStatus": {
"actions": {
"thinking": "Réflexion",
"processing": "Traitement",
"analyzing": "Analyse",
"working": "Travail",
"computing": "Calcul",
"reasoning": "Raisonnement"
},
"state": {
"live": "En direct",
"paused": "En pause"
},
"elapsed": {
"seconds": "{{count}}s",
"minutesSeconds": "{{minutes}}m {{seconds}}s",
"label": "{{time}} écoulé",
"startingNow": "Démarrage"
},
"controls": {
"stopGeneration": "Arrêter la génération",
"pressEscToStop": "Appuyez sur Échap à tout moment pour arrêter"
},
"providers": {
"assistant": "Assistant"
}
},
"projectSelection": {
"startChatWithProvider": "Sélectionnez un projet pour commencer à chatter avec {{provider}}"
},
"tasks": {
"nextTaskPrompt": "Commencer la prochaine tâche"
}
}

View File

@@ -1,36 +0,0 @@
{
"toolbar": {
"changes": "modifications",
"previousChange": "Modification précédente",
"nextChange": "Modification suivante",
"hideDiff": "Masquer la mise en évidence des différences",
"showDiff": "Afficher la mise en évidence des différences",
"settings": "Paramètres de l'éditeur",
"collapse": "Réduire l'éditeur",
"expand": "Étendre l'éditeur en pleine largeur"
},
"loading": "Chargement de {{fileName}}...",
"header": {
"showingChanges": "Affichage des modifications"
},
"actions": {
"download": "Télécharger le fichier",
"save": "Enregistrer",
"saving": "Enregistrement...",
"saved": "Enregistré !",
"exitFullscreen": "Quitter le plein écran",
"fullscreen": "Plein écran",
"close": "Fermer",
"previewMarkdown": "Aperçu markdown",
"editMarkdown": "Modifier le markdown"
},
"footer": {
"lines": "Lignes :",
"characters": "Caractères :",
"shortcuts": "Ctrl+S pour enregistrer • Échap pour fermer"
},
"binaryFile": {
"title": "Fichier binaire",
"message": "Le fichier \"{{fileName}}\" ne peut pas être affiché dans l'éditeur de texte car c'est un fichier binaire."
}
}

View File

@@ -1,267 +0,0 @@
{
"buttons": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"create": "Créer",
"edit": "Modifier",
"close": "Fermer",
"confirm": "Confirmer",
"submit": "Soumettre",
"retry": "Réessayer",
"refresh": "Actualiser",
"search": "Rechercher",
"clear": "Effacer",
"copy": "Copier",
"download": "Télécharger",
"upload": "Envoyer",
"browse": "Parcourir"
},
"tabs": {
"chat": "Chat",
"shell": "Terminal",
"files": "Fichiers",
"git": "Contrôle de source",
"tasks": "Tâches"
},
"status": {
"loading": "Chargement...",
"success": "Succès",
"error": "Erreur",
"failed": "Échec",
"pending": "En attente",
"completed": "Terminé",
"inProgress": "En cours"
},
"messages": {
"savedSuccessfully": "Enregistré avec succès",
"deletedSuccessfully": "Supprimé avec succès",
"updatedSuccessfully": "Mis à jour avec succès",
"operationFailed": "Opération échouée",
"networkError": "Erreur réseau. Vérifiez votre connexion.",
"unauthorized": "Non autorisé. Veuillez vous connecter.",
"notFound": "Introuvable",
"invalidInput": "Entrée invalide",
"requiredField": "Ce champ est obligatoire",
"unknownError": "Une erreur inconnue s'est produite"
},
"navigation": {
"settings": "Paramètres",
"home": "Accueil",
"back": "Retour",
"next": "Suivant",
"previous": "Précédent",
"logout": "Déconnexion"
},
"common": {
"language": "Langue",
"theme": "Thème",
"darkMode": "Mode sombre",
"lightMode": "Mode clair",
"name": "Nom",
"description": "Description",
"enabled": "Activé",
"disabled": "Désactivé",
"optional": "Optionnel",
"version": "Version",
"select": "Sélectionner",
"selectAll": "Tout sélectionner",
"deselectAll": "Tout désélectionner"
},
"time": {
"justNow": "À l'instant",
"minutesAgo": "Il y a {{count}} min",
"hoursAgo": "Il y a {{count}} h",
"daysAgo": "Il y a {{count}} j",
"yesterday": "Hier"
},
"fileOperations": {
"newFile": "Nouveau fichier",
"newFolder": "Nouveau dossier",
"rename": "Renommer",
"move": "Déplacer",
"copyPath": "Copier le chemin",
"openInEditor": "Ouvrir dans l'éditeur"
},
"mainContent": {
"loading": "Chargement de CloudCLI",
"settingUpWorkspace": "Préparation de votre espace de travail...",
"chooseProject": "Choisissez votre projet",
"selectProjectDescription": "Sélectionnez un projet dans la barre latérale pour commencer à coder avec Claude. Chaque projet contient vos sessions de chat et l'historique des fichiers.",
"tip": "Astuce",
"createProjectMobile": "Appuyez sur le bouton menu ci-dessus pour accéder aux projets",
"createProjectDesktop": "Créez un nouveau projet en cliquant sur l'icône de dossier dans la barre latérale",
"newSession": "Nouvelle session",
"untitledSession": "Session sans titre",
"projectFiles": "Fichiers du projet"
},
"fileTree": {
"loading": "Chargement des fichiers...",
"files": "Fichiers",
"simpleView": "Vue simple",
"compactView": "Vue compacte",
"detailedView": "Vue détaillée",
"searchPlaceholder": "Rechercher fichiers et dossiers...",
"clearSearch": "Effacer la recherche",
"name": "Nom",
"size": "Taille",
"modified": "Modifié",
"permissions": "Permissions",
"noFilesFound": "Aucun fichier trouvé",
"checkProjectPath": "Vérifiez si le chemin du projet est accessible",
"noMatchesFound": "Aucun résultat",
"tryDifferentSearch": "Essayez un autre terme ou effacez la recherche",
"justNow": "à l'instant",
"minAgo": "il y a {{count}} min",
"hoursAgo": "il y a {{count}} h",
"daysAgo": "il y a {{count}} j",
"newFile": "Nouveau fichier (Cmd+N)",
"newFolder": "Nouveau dossier (Cmd+Maj+N)",
"refresh": "Actualiser",
"collapseAll": "Tout réduire",
"context": {
"rename": "Renommer",
"delete": "Supprimer",
"copyPath": "Copier le chemin",
"download": "Télécharger",
"newFile": "Nouveau fichier",
"newFolder": "Nouveau dossier",
"refresh": "Actualiser",
"menuLabel": "Menu contextuel du fichier",
"loading": "Chargement..."
}
},
"projectWizard": {
"title": "Créer un nouveau projet",
"steps": {
"type": "Type",
"configure": "Configurer",
"confirm": "Confirmer"
},
"step1": {
"question": "Avez-vous déjà un espace de travail, ou souhaitez-vous en créer un nouveau ?",
"existing": {
"title": "Espace de travail existant",
"description": "J'ai déjà un espace de travail sur mon serveur et je veux juste l'ajouter à la liste des projets"
},
"new": {
"title": "Nouvel espace de travail",
"description": "Créer un nouvel espace de travail, éventuellement cloné depuis un dépôt GitHub"
}
},
"step2": {
"existingPath": "Chemin de l'espace de travail",
"newPath": "Chemin de l'espace de travail",
"existingPlaceholder": "/chemin/vers/espace-de-travail",
"newPlaceholder": "/chemin/vers/nouvel-espace",
"existingHelp": "Chemin complet vers votre répertoire d'espace de travail existant",
"newHelp": "Chemin complet vers votre répertoire d'espace de travail",
"githubUrl": "URL GitHub (optionnel)",
"githubPlaceholder": "https://github.com/utilisateur/depot",
"githubHelp": "Optionnel : fournissez une URL GitHub pour cloner un dépôt",
"githubAuth": "Authentification GitHub (optionnel)",
"githubAuthHelp": "Uniquement requis pour les dépôts privés. Les dépôts publics peuvent être clonés sans authentification.",
"loadingTokens": "Chargement des tokens enregistrés...",
"storedToken": "Token enregistré",
"newToken": "Nouveau token",
"nonePublic": "Aucun (Public)",
"selectToken": "Sélectionner un token",
"selectTokenPlaceholder": "-- Sélectionner un token --",
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tokenHelp": "Ce token sera utilisé uniquement pour cette opération",
"publicRepoInfo": "Les dépôts publics ne nécessitent pas d'authentification. Vous pouvez ignorer le token pour cloner un dépôt public.",
"noTokensHelp": "Aucun token enregistré. Vous pouvez en ajouter dans Paramètres → Clés API.",
"optionalTokenPublic": "Token GitHub (optionnel pour les dépôts publics)",
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (laisser vide pour les dépôts publics)"
},
"step3": {
"reviewConfig": "Vérifiez votre configuration",
"existingWorkspace": "Espace de travail existant",
"newWorkspace": "Nouvel espace de travail",
"path": "Chemin :",
"cloneFrom": "Cloner depuis :",
"authentication": "Authentification :",
"usingStoredToken": "Utilisation du token enregistré :",
"usingProvidedToken": "Utilisation du token fourni",
"noAuthentication": "Sans authentification",
"sshKey": "Clé SSH",
"existingInfo": "L'espace de travail sera ajouté à votre liste de projets et disponible pour les sessions Claude/Cursor.",
"newWithClone": "Le dépôt sera cloné depuis ce dossier.",
"newEmpty": "L'espace de travail sera ajouté à votre liste de projets et disponible pour les sessions Claude/Cursor.",
"cloningRepository": "Clonage du dépôt..."
},
"buttons": {
"cancel": "Annuler",
"back": "Retour",
"next": "Suivant",
"createProject": "Créer le projet",
"creating": "Création...",
"cloning": "Clonage..."
},
"errors": {
"selectType": "Veuillez indiquer si vous avez un espace de travail existant ou si vous souhaitez en créer un nouveau",
"providePath": "Veuillez fournir un chemin d'espace de travail",
"failedToCreate": "Échec de la création de l'espace de travail",
"failedToCreateFolder": "Échec de la création du dossier"
}
},
"notifications": {
"genericTool": "un outil",
"codes": {
"generic": {
"info": {
"title": "Notification"
}
},
"permission": {
"required": {
"title": "Action requise",
"body": "{{toolName}} attend votre décision."
}
},
"run": {
"stopped": {
"title": "Exécution arrêtée",
"body": "Raison : {{reason}}"
},
"failed": {
"title": "Exécution échouée"
}
},
"agent": {
"notification": {
"title": "Notification de l'agent"
}
}
}
},
"versionUpdate": {
"title": "Mise à jour disponible",
"newVersionReady": "Une nouvelle version est prête",
"currentVersion": "Version actuelle",
"latestVersion": "Dernière version",
"whatsNew": "Nouveautés :",
"viewFullRelease": "Voir les notes de version complètes",
"updateProgress": "Progression de la mise à jour :",
"manualUpgrade": "Mise à jour manuelle :",
"npmUpgradeCommand": "npm install -g @cloudcli-ai/cloudcli@latest",
"manualUpgradeHint": "Ou cliquez sur « Mettre à jour maintenant » pour lancer la mise à jour automatiquement.",
"updateCompleted": "Mise à jour effectuée avec succès !",
"restartServer": "Veuillez redémarrer le serveur pour appliquer les modifications.",
"updateFailed": "Échec de la mise à jour",
"buttons": {
"close": "Fermer",
"later": "Plus tard",
"copyCommand": "Copier la commande",
"updateNow": "Mettre à jour maintenant",
"updating": "Mise à jour..."
},
"ariaLabels": {
"closeModal": "Fermer la fenêtre de mise à jour",
"showSidebar": "Afficher la barre latérale",
"settings": "Paramètres",
"updateAvailable": "Mise à jour disponible",
"closeSidebar": "Masquer la barre latérale"
}
}
}

View File

@@ -1,548 +0,0 @@
{
"title": "Paramètres",
"tabs": {
"account": "Compte",
"permissions": "Permissions",
"mcpServers": "Serveurs MCP",
"appearance": "Apparence"
},
"account": {
"title": "Compte",
"language": "Langue",
"languageLabel": "Langue d'affichage",
"languageDescription": "Choisissez votre langue préférée pour l'interface",
"username": "Nom d'utilisateur",
"email": "E-mail",
"profile": "Profil",
"changePassword": "Changer le mot de passe"
},
"mcp": {
"title": "Serveurs MCP",
"addServer": "Ajouter un serveur",
"editServer": "Modifier le serveur",
"deleteServer": "Supprimer le serveur",
"serverName": "Nom du serveur",
"serverType": "Type de serveur",
"config": "Configuration",
"testConnection": "Tester la connexion",
"status": "Statut",
"connected": "Connecté",
"disconnected": "Déconnecté",
"scope": {
"label": "Portée",
"user": "Utilisateur",
"project": "Projet"
}
},
"appearance": {
"title": "Apparence",
"theme": "Thème",
"codeEditor": "Éditeur de code",
"editorTheme": "Thème de l'éditeur",
"wordWrap": "Retour à la ligne",
"showMinimap": "Afficher la minimap",
"lineNumbers": "Numéros de ligne",
"fontSize": "Taille de police"
},
"actions": {
"saveChanges": "Enregistrer les modifications",
"resetToDefaults": "Rétablir les valeurs par défaut",
"cancelChanges": "Annuler les modifications"
},
"quickSettings": {
"title": "Paramètres rapides",
"sections": {
"appearance": "Apparence",
"toolDisplay": "Affichage des outils",
"viewOptions": "Options d'affichage",
"inputSettings": "Paramètres de saisie"
},
"darkMode": "Mode sombre",
"autoExpandTools": "Développer automatiquement les outils",
"showRawParameters": "Afficher les paramètres bruts",
"showThinking": "Afficher la réflexion",
"autoScrollToBottom": "Défilement automatique vers le bas",
"sendByCtrlEnter": "Envoyer avec Ctrl+Entrée",
"sendByCtrlEnterDescription": "Lorsqu'activé, appuyer sur Ctrl+Entrée envoie le message au lieu de simplement Entrée. Utile pour les utilisateurs IME pour éviter les envois accidentels.",
"dragHandle": {
"dragging": "Glissement en cours",
"closePanel": "Fermer le panneau de paramètres",
"openPanel": "Ouvrir le panneau de paramètres",
"draggingStatus": "Glissement...",
"toggleAndMove": "Cliquer pour basculer, glisser pour déplacer"
}
},
"terminalShortcuts": {
"title": "Raccourcis terminal",
"sectionKeys": "Touches",
"sectionNavigation": "Navigation",
"escape": "Échap",
"tab": "Tab",
"shiftTab": "Maj+Tab",
"arrowUp": "Flèche haut",
"arrowDown": "Flèche bas",
"scrollDown": "Défiler vers le bas",
"handle": {
"closePanel": "Fermer le panneau de raccourcis",
"openPanel": "Ouvrir le panneau de raccourcis"
}
},
"mainTabs": {
"label": "Paramètres",
"agents": "Agents",
"appearance": "Apparence",
"git": "Git",
"apiTokens": "API & Tokens",
"tasks": "Tâches",
"notifications": "Notifications",
"plugins": "Plugins",
"about": "À propos"
},
"notifications": {
"title": "Notifications",
"description": "Contrôlez les événements de notification que vous recevez.",
"webPush": {
"title": "Notifications push web",
"enable": "Activer les notifications push",
"disable": "Désactiver les notifications push",
"enabled": "Les notifications push sont activées",
"loading": "Mise à jour...",
"unsupported": "Les notifications push ne sont pas prises en charge dans ce navigateur.",
"denied": "Les notifications push sont bloquées. Veuillez les autoriser dans les paramètres de votre navigateur."
},
"sound": {
"title": "Son",
"description": "Jouer un court son lorsqu'une exécution de chat se termine.",
"enabled": "Activé",
"test": "Tester le son"
},
"events": {
"title": "Types d'événements",
"actionRequired": "Action requise",
"stop": "Exécution arrêtée",
"error": "Exécution échouée"
}
},
"appearanceSettings": {
"darkMode": {
"label": "Mode sombre",
"description": "Basculer entre les thèmes clair et sombre"
},
"projectSorting": {
"label": "Tri des projets",
"description": "Ordre d'affichage des projets dans la barre latérale",
"alphabetical": "Alphabétique",
"recentActivity": "Activité récente"
},
"codeEditor": {
"title": "Éditeur de code",
"theme": {
"label": "Thème de l'éditeur",
"description": "Thème par défaut pour l'éditeur de code"
},
"wordWrap": {
"label": "Retour à la ligne",
"description": "Activer le retour à la ligne par défaut dans l'éditeur"
},
"showMinimap": {
"label": "Afficher la minimap",
"description": "Afficher une minimap pour une navigation plus facile en vue diff"
},
"lineNumbers": {
"label": "Afficher les numéros de ligne",
"description": "Afficher les numéros de ligne dans l'éditeur"
},
"fontSize": {
"label": "Taille de police",
"description": "Taille de police de l'éditeur en pixels"
}
}
},
"mcpForm": {
"title": {
"add": "Ajouter un serveur MCP",
"edit": "Modifier le serveur MCP"
},
"importMode": {
"form": "Saisie via formulaire",
"json": "Import JSON"
},
"scope": {
"label": "Portée",
"userGlobal": "Utilisateur (global)",
"projectLocal": "Projet (local)",
"userDescription": "Portée utilisateur : Disponible dans tous les projets sur votre machine",
"projectDescription": "Portée locale : Disponible uniquement dans le projet sélectionné",
"cannotChange": "La portée ne peut pas être modifiée lors de la modification d'un serveur existant"
},
"fields": {
"serverName": "Nom du serveur",
"transportType": "Type de transport",
"command": "Commande",
"arguments": "Arguments (un par ligne)",
"jsonConfig": "Configuration JSON",
"url": "URL",
"envVars": "Variables d'environnement (CLÉ=valeur, une par ligne)",
"headers": "En-têtes (CLÉ=valeur, un par ligne)",
"selectProject": "Sélectionner un projet..."
},
"placeholders": {
"serverName": "mon-serveur"
},
"validation": {
"missingType": "Champ requis manquant : type",
"stdioRequiresCommand": "Le type stdio nécessite un champ command",
"httpRequiresUrl": "Le type {{type}} nécessite un champ url",
"invalidJson": "Format JSON invalide",
"jsonHelp": "Collez la configuration de votre serveur MCP en format JSON. Exemples :",
"jsonExampleStdio": "• stdio : {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
"jsonExampleHttp": "• http/sse : {\"type\":\"http\",\"url\":\"https://api.exemple.com/mcp\"}"
},
"configDetails": "Détails de configuration (depuis {{configFile}})",
"projectPath": "Chemin : {{path}}",
"actions": {
"cancel": "Annuler",
"saving": "Enregistrement...",
"addServer": "Ajouter le serveur",
"updateServer": "Mettre à jour le serveur"
}
},
"saveStatus": {
"success": "Paramètres enregistrés avec succès !",
"error": "Échec de l'enregistrement des paramètres",
"saving": "Enregistrement..."
},
"footerActions": {
"save": "Enregistrer les paramètres",
"cancel": "Annuler"
},
"git": {
"title": "Configuration Git",
"description": "Configurez votre identité git pour les commits. Ces paramètres seront appliqués globalement via git config --global",
"name": {
"label": "Nom Git",
"help": "Votre nom pour les commits git"
},
"email": {
"label": "E-mail Git",
"help": "Votre e-mail pour les commits git"
},
"actions": {
"save": "Enregistrer la configuration",
"saving": "Enregistrement..."
},
"status": {
"success": "Enregistré avec succès"
}
},
"apiKeys": {
"title": "Clés API",
"description": "Générez des clés API pour accéder à l'API externe depuis d'autres applications.",
"newKey": {
"alertTitle": "⚠️ Sauvegardez votre clé API",
"alertMessage": "C'est la seule fois que vous verrez cette clé. Stockez-la en lieu sûr.",
"iveSavedIt": "Je l'ai sauvegardée"
},
"form": {
"placeholder": "Nom de la clé API (ex. : Serveur de production)",
"createButton": "Créer",
"cancelButton": "Annuler"
},
"newButton": "Nouvelle clé API",
"empty": "Aucune clé API créée pour l'instant.",
"list": {
"created": "Créée :",
"lastUsed": "Dernière utilisation :"
},
"confirmDelete": "Êtes-vous sûr de vouloir supprimer cette clé API ?",
"status": {
"active": "Active",
"inactive": "Inactive"
},
"github": {
"title": "Tokens GitHub",
"description": "Ajoutez des tokens d'accès personnel GitHub pour cloner des dépôts privés via l'API externe.",
"descriptionAlt": "Ajoutez des tokens d'accès personnel GitHub pour cloner des dépôts privés. Vous pouvez aussi passer des tokens directement dans les requêtes API sans les stocker.",
"addButton": "Ajouter un token",
"form": {
"namePlaceholder": "Nom du token (ex. : Dépôts personnels)",
"tokenPlaceholder": "Token d'accès personnel GitHub (ghp_...)",
"descriptionPlaceholder": "Description (optionnel)",
"addButton": "Ajouter le token",
"cancelButton": "Annuler",
"howToCreate": "Comment créer un token d'accès personnel GitHub →"
},
"empty": "Aucun token GitHub ajouté pour l'instant.",
"added": "Ajouté :",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce token GitHub ?"
},
"apiDocsLink": "Documentation API",
"documentation": {
"title": "Documentation de l'API externe",
"description": "Apprenez à utiliser l'API externe pour déclencher des sessions Claude/Cursor depuis vos applications.",
"viewLink": "Voir la documentation API →"
},
"loading": "Chargement...",
"version": {
"updateAvailable": "Mise à jour disponible : v{{version}}"
}
},
"tasks": {
"checking": "Vérification de l'installation TaskMaster...",
"notInstalled": {
"title": "CLI TaskMaster AI non installé",
"description": "Le CLI TaskMaster est requis pour utiliser les fonctionnalités de gestion des tâches. Installez-le pour commencer :",
"installCommand": "npm install -g task-master-ai",
"viewOnGitHub": "Voir sur GitHub",
"afterInstallation": "Après l'installation :",
"steps": {
"restart": "Redémarrez cette application",
"autoAvailable": "Les fonctionnalités TaskMaster deviendront automatiquement disponibles",
"initCommand": "Utilisez task-master init dans votre répertoire de projet"
}
},
"settings": {
"enableLabel": "Activer l'intégration TaskMaster",
"enableDescription": "Afficher les tâches TaskMaster, les bannières et les indicateurs dans la barre latérale"
}
},
"agents": {
"authStatus": {
"checking": "Vérification...",
"connected": "Connecté",
"notConnected": "Non connecté",
"disconnected": "Déconnecté",
"checkingAuth": "Vérification du statut d'authentification...",
"loggedInAs": "Connecté en tant que {{email}}",
"authenticatedUser": "utilisateur authentifié"
},
"account": {
"claude": {
"description": "Assistant IA Claude d'Anthropic"
},
"cursor": {
"description": "Éditeur de code IA Cursor"
},
"codex": {
"description": "Assistant IA Codex d'OpenAI"
},
"gemini": {
"description": "Assistant IA Gemini de Google"
},
"opencode": {
"description": "Assistant CLI OpenCode"
}
},
"connectionStatus": "Statut de la connexion",
"login": {
"title": "Connexion",
"reAuthenticate": "Se ré-authentifier",
"description": "Connectez-vous à votre compte {{agent}} pour activer les fonctionnalités IA",
"reAuthDescription": "Connectez-vous avec un autre compte ou actualisez les identifiants",
"button": "Se connecter",
"reLoginButton": "Se reconnecter"
},
"error": "Erreur : {{error}}"
},
"permissions": {
"title": "Paramètres de permission",
"skipPermissions": {
"label": "Ignorer les invites de permission (à utiliser avec précaution)",
"claudeDescription": "Équivalent au flag --dangerously-skip-permissions",
"cursorDescription": "Équivalent au flag -f dans le CLI Cursor"
},
"allowedTools": {
"title": "Outils autorisés",
"description": "Outils automatiquement autorisés sans demande de permission",
"placeholder": "ex. : \"Bash(git log:*)\" ou \"Write\"",
"quickAdd": "Ajout rapide d'outils courants :",
"empty": "Aucun outil autorisé configuré"
},
"blockedTools": {
"title": "Outils bloqués",
"description": "Outils automatiquement bloqués sans demande de permission",
"placeholder": "ex. : \"Bash(rm:*)\"",
"empty": "Aucun outil bloqué configuré"
},
"allowedCommands": {
"title": "Commandes shell autorisées",
"description": "Commandes shell automatiquement autorisées sans demande",
"placeholder": "ex. : \"Shell(ls)\" ou \"Shell(git status)\"",
"quickAdd": "Ajout rapide de commandes courantes :",
"empty": "Aucune commande autorisée configurée"
},
"blockedCommands": {
"title": "Commandes shell bloquées",
"description": "Commandes shell automatiquement bloquées",
"placeholder": "ex. : \"Shell(rm -rf)\" ou \"Shell(sudo)\"",
"empty": "Aucune commande bloquée configurée"
},
"toolExamples": {
"title": "Exemples de motifs d'outils :",
"bashGitLog": "- Autoriser toutes les commandes git log",
"bashGitDiff": "- Autoriser toutes les commandes git diff",
"write": "- Autoriser toutes les utilisations de l'outil Write",
"bashRm": "- Bloquer toutes les commandes rm (dangereux)"
},
"shellExamples": {
"title": "Exemples de commandes shell :",
"ls": "- Autoriser la commande ls",
"gitStatus": "- Autoriser git status",
"npmInstall": "- Autoriser npm install",
"rmRf": "- Bloquer la suppression récursive"
},
"codex": {
"permissionMode": "Mode de permission",
"description": "Contrôle comment Codex gère les modifications de fichiers et l'exécution des commandes",
"modes": {
"default": {
"title": "Par défaut",
"description": "Seules les commandes de confiance (ls, cat, grep, git status, etc.) s'exécutent automatiquement. Les autres sont ignorées. Peut écrire dans l'espace de travail."
},
"acceptEdits": {
"title": "Accepter les modifications",
"description": "Toutes les commandes s'exécutent automatiquement dans l'espace de travail. Mode entièrement automatique avec exécution sandboxée."
},
"bypassPermissions": {
"title": "Contourner les permissions",
"description": "Accès système complet sans restrictions. Toutes les commandes s'exécutent automatiquement avec accès disque et réseau complet. À utiliser avec précaution."
}
},
"technicalDetails": "Détails techniques",
"technicalInfo": {
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted. Commandes de confiance : cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (sans -exec), etc.",
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never. Toutes les commandes s'exécutent automatiquement dans le répertoire du projet.",
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never. Accès système complet, à utiliser uniquement dans des environnements de confiance.",
"overrideNote": "Vous pouvez remplacer ce mode par session via le bouton de mode dans l'interface de chat."
}
},
"actions": {
"add": "Ajouter"
}
},
"mcpServers": {
"title": "Serveurs MCP",
"description": {
"claude": "Les serveurs Model Context Protocol fournissent des outils et sources de données supplémentaires à Claude",
"cursor": "Les serveurs Model Context Protocol fournissent des outils et sources de données supplémentaires à Cursor",
"codex": "Les serveurs Model Context Protocol fournissent des outils et sources de données supplémentaires à Codex",
"opencode": "Les serveurs Model Context Protocol fournissent des outils et sources de données supplémentaires à OpenCode"
},
"addButton": "Ajouter un serveur MCP",
"empty": "Aucun serveur MCP configuré",
"serverType": "Type",
"scope": {
"local": "local",
"user": "utilisateur"
},
"config": {
"command": "Commande",
"url": "URL",
"args": "Arguments",
"environment": "Environnement"
},
"tools": {
"title": "Outils",
"count": "({{count}}) :",
"more": "+{{count}} de plus"
},
"actions": {
"edit": "Modifier le serveur",
"delete": "Supprimer le serveur"
},
"help": {
"title": "À propos de Codex MCP",
"description": "Codex prend en charge les serveurs MCP basés sur stdio. Vous pouvez ajouter des serveurs qui étendent les capacités de Codex avec des outils et ressources supplémentaires."
}
},
"pluginSettings": {
"title": "Plugins",
"description": "Étendez l'interface avec des plugins personnalisés. Installez depuis git ou déposez un dossier dans ~/.claude-code-ui/plugins/",
"installPlaceholder": "https://github.com/utilisateur/mon-plugin",
"installButton": "Installer",
"installing": "Installation…",
"securityWarning": "N'installez que des plugins dont vous avez examiné le code source ou provenant d'auteurs de confiance.",
"scanningPlugins": "Analyse des plugins…",
"noPluginsInstalled": "Aucun plugin installé",
"pullLatest": "Récupérer la dernière version depuis git",
"noGitRemote": "Pas de remote git — mise à jour indisponible",
"uninstallPlugin": "Désinstaller le plugin",
"confirmUninstall": "Cliquez à nouveau pour confirmer",
"confirmUninstallMessage": "Supprimer {{name}} ? Cette action est irréversible.",
"cancel": "Annuler",
"remove": "Supprimer",
"updateFailed": "Échec de la mise à jour",
"installFailed": "Échec de l'installation",
"uninstallFailed": "Échec de la désinstallation",
"toggleFailed": "Échec du basculement",
"starterPluginLabel": "Plugin de démarrage",
"starter": "Démarrage",
"docs": "Docs",
"sections": {
"officialTitle": "Plugins officiels",
"officialDescription": "Maintenus par l'équipe CloudCLI et prêts à être installés directement.",
"unofficialTitle": "Autres plugins",
"unofficialDescription": "Plugins non officiels et intégrations d'autres utilisateurs. Examinez le code source avant d'installer."
},
"starterPlugin": {
"name": "Statistiques du projet",
"badge": "démarrage",
"description": "Nombre de fichiers, lignes de code, répartition par type de fichier et activité récente pour votre projet.",
"install": "Installer"
},
"terminalPlugin": {
"name": "Terminal",
"badge": "officiel",
"description": "Terminal intégré avec accès shell complet directement dans l'interface.",
"install": "Installer"
},
"scheduledPromptPlugin": {
"name": "Invites planifiées",
"badge": "non officiel",
"description": "Planifiez des invites d'espace de travail, consultez l'historique des exécutions et gérez les tâches locales récurrentes.",
"install": "Installer"
},
"claudeWatchPlugin": {
"name": "Claude Watch",
"badge": "non officiel",
"description": "Surveillez les sessions Claude Code longues pour détecter les blocages et exposez les contrôles de processus.",
"install": "Installer"
},
"prismCloudCLI": {
"name": "PRISM CloudCLI",
"badge": "non officiel",
"description": "Intelligence de session pour Claude Code, dans CloudCLI. Voyez pourquoi vos sessions consomment des tokens sans quitter le navigateur.",
"install": "Installer"
},
"sessionManagerPlugin": {
"name": "Sessions",
"badge": "non officiel",
"description": "Visualisez, gérez et terminez les sessions Claude Code actives.",
"install": "Installer"
},
"tokenCostCalculatorPlugin": {
"name": "Calculateur de coût en tokens",
"badge": "non officiel",
"description": "Calculez les coûts API à partir des prix des modèles et de l'utilisation des tokens, avec prise en charge des tarifs préréglés.",
"install": "Installer"
},
"taskQueuePlugin": {
"name": "File de tâches",
"badge": "non officiel",
"description": "Tableau de bord de file de tâches pour visualiser, filtrer et lancer des tâches d'agent.",
"install": "Installer"
},
"githubIssuesBoardPlugin": {
"name": "Tableau des issues GitHub",
"badge": "non officiel",
"description": "Tableau Kanban pour les issues GitHub avec synchronisation bidirectionnelle TaskMaster et installation automatique de la compétence CLI /github-task",
"install": "Installer"
},
"morePlugins": "Plus",
"enable": "Activer",
"disable": "Désactiver",
"installAriaLabel": "URL du dépôt git du plugin",
"tab": "onglet",
"runningStatus": "en cours"
}
}

View File

@@ -1,137 +0,0 @@
{
"projects": {
"title": "Projets",
"newProject": "Nouveau projet",
"deleteProject": "Supprimer le projet",
"renameProject": "Renommer le projet",
"noProjects": "Aucun projet trouvé",
"loadingProjects": "Chargement des projets...",
"searchPlaceholder": "Rechercher des projets...",
"projectNamePlaceholder": "Nom du projet",
"starred": "Favoris",
"all": "Tous",
"untitledSession": "Session sans titre",
"newSession": "Nouvelle session",
"codexSession": "Session Codex",
"fetchingProjects": "Récupération de vos projets et sessions Claude",
"projects": "projets",
"noMatchingProjects": "Aucun projet correspondant",
"tryDifferentSearch": "Essayez d'ajuster votre terme de recherche",
"runClaudeCli": "Exécutez le CLI Claude dans un répertoire de projet pour commencer"
},
"app": {
"title": "CloudCLI",
"subtitle": "Interface d'assistant de codage IA"
},
"sessions": {
"title": "Sessions",
"newSession": "Nouvelle session",
"deleteSession": "Supprimer la session",
"renameSession": "Renommer la session",
"noSessions": "Aucune session pour l'instant",
"loadingSessions": "Chargement des sessions...",
"unnamed": "Sans nom",
"loading": "Chargement...",
"showMore": "Afficher plus de sessions"
},
"tooltips": {
"viewEnvironments": "Voir les environnements",
"hideSidebar": "Masquer la barre latérale",
"createProject": "Créer un nouveau projet",
"refresh": "Actualiser les projets et sessions (Ctrl+R)",
"renameProject": "Renommer le projet (F2)",
"deleteProject": "Retirer le projet de la barre latérale (Suppr)",
"addToFavorites": "Ajouter aux favoris",
"removeFromFavorites": "Retirer des favoris",
"editSessionName": "Modifier manuellement le nom de la session",
"deleteSession": "Supprimer définitivement cette session",
"activeSessionIndicator": "Session récemment active (10 dernières minutes)",
"save": "Enregistrer",
"cancel": "Annuler",
"clearSearch": "Effacer la recherche",
"openCommandPalette": "Ouvrir la palette de commandes"
},
"navigation": {
"chat": "Chat",
"files": "Fichiers",
"git": "Git",
"terminal": "Terminal",
"tasks": "Tâches"
},
"actions": {
"refresh": "Actualiser",
"settings": "Paramètres",
"collapseAll": "Tout réduire",
"expandAll": "Tout développer",
"cancel": "Annuler",
"save": "Enregistrer",
"delete": "Supprimer",
"rename": "Renommer",
"joinCommunity": "Rejoindre la communauté",
"reportIssue": "Signaler un problème",
"starOnGithub": "Étoile sur GitHub"
},
"branding": {
"openSource": "Open Source"
},
"status": {
"active": "Actif",
"inactive": "Inactif",
"thinking": "Réflexion...",
"error": "Erreur",
"aborted": "Annulé",
"unknown": "Inconnu"
},
"time": {
"justNow": "À l'instant",
"oneMinuteAgo": "Il y a 1 min",
"minutesAgo": "Il y a {{count}} min",
"oneHourAgo": "Il y a 1 heure",
"hoursAgo": "Il y a {{count}} heures",
"oneDayAgo": "Il y a 1 jour",
"daysAgo": "Il y a {{count}} jours"
},
"messages": {
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer ceci ?",
"renameSuccess": "Renommé avec succès",
"deleteSuccess": "Supprimé avec succès",
"errorOccurred": "Une erreur s'est produite",
"deleteSessionConfirm": "Êtes-vous sûr de vouloir supprimer cette session ? Cette action est irréversible.",
"deleteProjectConfirm": "Retirer ce projet de la barre latérale ? Vos fichiers, mémoires et données de session ne seront pas supprimés.",
"enterProjectPath": "Veuillez entrer un chemin de projet",
"deleteSessionFailed": "Échec de la suppression de la session. Veuillez réessayer.",
"deleteSessionError": "Erreur lors de la suppression de la session. Veuillez réessayer.",
"renameSessionFailed": "Échec du renommage de la session. Veuillez réessayer.",
"renameSessionError": "Erreur lors du renommage de la session. Veuillez réessayer.",
"deleteProjectFailed": "Échec de la suppression du projet. Veuillez réessayer.",
"deleteProjectError": "Erreur lors de la suppression du projet. Veuillez réessayer.",
"createProjectFailed": "Échec de la création du projet. Veuillez réessayer.",
"createProjectError": "Erreur lors de la création du projet. Veuillez réessayer."
},
"version": {
"updateAvailable": "Mise à jour disponible"
},
"search": {
"modeProjects": "Projets",
"modeConversations": "Conversations",
"conversationsPlaceholder": "Rechercher dans les conversations...",
"searching": "Recherche en cours...",
"noResults": "Aucun résultat trouvé",
"tryDifferentQuery": "Essayez une autre requête de recherche",
"matches_one": "{{count}} résultat",
"matches_other": "{{count}} résultats",
"projectsScanned_one": "{{count}} projet analysé",
"projectsScanned_other": "{{count}} projets analysés"
},
"deleteConfirmation": {
"deleteProject": "Supprimer le projet",
"deleteSession": "Supprimer la session",
"confirmDelete": "Que souhaitez-vous faire avec",
"sessionCount_one": "Ce projet contient {{count}} conversation.",
"sessionCount_other": "Ce projet contient {{count}} conversations.",
"removeFromSidebar": "Retirer de la barre latérale uniquement",
"deleteAllData": "Supprimer toutes les données définitivement",
"allConversationsDeleted": "Le projet sera retiré de la barre latérale. Vos fichiers, mémoires et données de session seront conservés.",
"cannotUndo": "Vous pourrez rajouter le projet ultérieurement."
}
}

View File

@@ -1,142 +0,0 @@
{
"notConfigured": {
"title": "TaskMaster AI n'est pas configuré",
"description": "TaskMaster aide à décomposer des projets complexes en tâches gérables avec une assistance IA",
"whatIsTitle": "🎯 Qu'est-ce que TaskMaster ?",
"features": {
"aiPowered": "Gestion des tâches assistée par IA : Décomposez des projets complexes en sous-tâches gérables",
"prdTemplates": "Modèles PRD : Générez des tâches à partir de documents d'exigences produit",
"dependencyTracking": "Suivi des dépendances : Comprenez les relations entre tâches et l'ordre d'exécution",
"progressVisualization": "Visualisation de l'avancement : Tableaux Kanban et analyses détaillées des tâches",
"cliIntegration": "Intégration CLI : Utilisez les commandes taskmaster pour des flux de travail avancés"
},
"initializeButton": "Initialiser TaskMaster AI"
},
"gettingStarted": {
"title": "Démarrer avec TaskMaster",
"subtitle": "TaskMaster est initialisé ! Voici la suite :",
"steps": {
"createPRD": {
"title": "Créer un document d'exigences produit (PRD)",
"description": "Discutez de votre idée de projet et créez un PRD décrivant ce que vous voulez construire.",
"addButton": "Ajouter un PRD",
"existingPRDs": "PRDs existants :"
},
"generateTasks": {
"title": "Générer des tâches à partir du PRD",
"description": "Une fois votre PRD prêt, demandez à votre assistant IA de l'analyser et TaskMaster le décomposera automatiquement en tâches gérables avec des détails d'implémentation."
},
"analyzeTasks": {
"title": "Analyser et développer les tâches",
"description": "Demandez à votre assistant IA d'analyser la complexité des tâches et de les développer en sous-tâches détaillées pour une implémentation plus facile."
},
"startBuilding": {
"title": "Commencer à construire",
"description": "Demandez à votre assistant IA de commencer à travailler sur les tâches, mettre à jour leur statut et ajouter de nouvelles tâches au fur et à mesure."
}
},
"tip": "💡 Astuce : Commencez par un PRD pour tirer le meilleur parti de la génération de tâches IA de TaskMaster"
},
"setupModal": {
"title": "Configuration TaskMaster",
"subtitle": "CLI interactif pour {{projectName}}",
"willStart": "L'initialisation de TaskMaster démarrera automatiquement",
"completed": "Configuration TaskMaster terminée ! Vous pouvez fermer cette fenêtre.",
"closeButton": "Fermer",
"closeContinueButton": "Fermer et continuer"
},
"helpGuide": {
"title": "Démarrer avec TaskMaster",
"subtitle": "Votre guide pour une gestion productive des tâches",
"examples": {
"parsePRD": "💬 Exemple :\n« Je viens d'initialiser un nouveau projet avec Claude Task Master. J'ai un PRD dans .taskmaster/docs/prd.txt. Pouvez-vous m'aider à l'analyser et configurer les tâches initiales ? »",
"expandTask": "💬 Exemple :\n« La tâche 5 semble complexe. Pouvez-vous la décomposer en sous-tâches ? »",
"addTask": "💬 Exemple :\n« Veuillez ajouter une nouvelle tâche pour implémenter le téléchargement d'images de profil utilisateur avec Cloudinary, recherchez la meilleure approche. »"
},
"moreExamples": "Voir plus d'exemples et de patterns d'utilisation →",
"proTips": {
"title": "💡 Conseils pro",
"search": "Utilisez la barre de recherche pour trouver rapidement des tâches spécifiques",
"views": "Basculez entre les vues Kanban, Liste et Grille via les boutons de vue",
"filters": "Utilisez les filtres pour vous concentrer sur des statuts ou priorités de tâches spécifiques",
"details": "Cliquez sur une tâche pour voir les détails et gérer les sous-tâches"
},
"learnMore": {
"title": "📚 En savoir plus",
"description": "TaskMaster AI est un système avancé de gestion des tâches conçu pour les développeurs. Documentation, exemples et contributions au projet.",
"githubButton": "Voir sur GitHub"
}
},
"search": {
"placeholder": "Rechercher des tâches..."
},
"filters": {
"button": "Filtres",
"status": "Statut",
"priority": "Priorité",
"sortBy": "Trier par",
"allStatuses": "Tous les statuts",
"allPriorities": "Toutes les priorités",
"showing": "Affichage de {{filtered}} sur {{total}} tâches",
"clearFilters": "Effacer les filtres"
},
"sort": {
"id": "ID",
"status": "Statut",
"priority": "Priorité",
"idAsc": "ID (croissant)",
"idDesc": "ID (décroissant)",
"titleAsc": "Titre (A-Z)",
"titleDesc": "Titre (Z-A)",
"statusAsc": "Statut (en attente en premier)",
"statusDesc": "Statut (terminé en premier)",
"priorityAsc": "Priorité (haute en premier)",
"priorityDesc": "Priorité (basse en premier)"
},
"views": {
"kanban": "Vue Kanban",
"list": "Vue liste",
"grid": "Vue grille"
},
"kanban": {
"pending": "📋 À faire",
"inProgress": "🚀 En cours",
"done": "✅ Terminé",
"blocked": "🚫 Bloqué",
"deferred": "⏳ Différé",
"cancelled": "❌ Annulé",
"noTasksYet": "Aucune tâche pour l'instant",
"tasksWillAppear": "Les tâches apparaîtront ici",
"moveTasksHere": "Déplacez les tâches ici au démarrage",
"completedTasksHere": "Les tâches terminées apparaissent ici",
"statusTasksHere": "Les tâches avec ce statut apparaîtront ici"
},
"buttons": {
"help": "Guide de démarrage TaskMaster",
"prds": "PRDs",
"addPRD": "Ajouter un PRD",
"addTask": "Ajouter une tâche",
"createNewPRD": "Créer un nouveau PRD",
"prdsAvailable": "{{count}} PRD(s) disponible(s)"
},
"prd": {
"modified": "Modifié : {{date}}"
},
"statuses": {
"pending": "En attente",
"in-progress": "En cours",
"done": "Terminé",
"blocked": "Bloqué",
"deferred": "Différé",
"cancelled": "Annulé"
},
"priorities": {
"high": "Haute",
"medium": "Moyenne",
"low": "Basse"
},
"noMatchingTasks": {
"title": "Aucune tâche ne correspond à vos filtres",
"description": "Essayez d'ajuster votre recherche ou vos critères de filtre."
}
}

View File

@@ -22,8 +22,7 @@
"shell": "Terminale", "shell": "Terminale",
"files": "File", "files": "File",
"git": "Controllo Versione", "git": "Controllo Versione",
"tasks": "Attività", "tasks": "Attività"
"browser": "Browser"
}, },
"status": { "status": {
"loading": "Caricamento...", "loading": "Caricamento...",

View File

@@ -22,8 +22,7 @@
"shell": "シェル", "shell": "シェル",
"files": "ファイル", "files": "ファイル",
"git": "ソース管理", "git": "ソース管理",
"tasks": "タスク", "tasks": "タスク"
"browser": "Browser"
}, },
"status": { "status": {
"loading": "読み込み中...", "loading": "読み込み中...",

View File

@@ -22,8 +22,7 @@
"shell": "Shell", "shell": "Shell",
"files": "파일", "files": "파일",
"git": "소스 관리", "git": "소스 관리",
"tasks": "작업", "tasks": "작업"
"browser": "Browser"
}, },
"status": { "status": {
"loading": "로딩 중...", "loading": "로딩 중...",

View File

@@ -22,8 +22,7 @@
"shell": "Терминал", "shell": "Терминал",
"files": "Файлы", "files": "Файлы",
"git": "Система контроля версий", "git": "Система контроля версий",
"tasks": "Задачи", "tasks": "Задачи"
"browser": "Browser"
}, },
"status": { "status": {
"loading": "Загрузка...", "loading": "Загрузка...",

View File

@@ -22,8 +22,7 @@
"shell": "Shell", "shell": "Shell",
"files": "Dosyalar", "files": "Dosyalar",
"git": "Kaynak Kontrolü", "git": "Kaynak Kontrolü",
"tasks": "Görevler", "tasks": "Görevler"
"browser": "Browser"
}, },
"status": { "status": {
"loading": "Yükleniyor...", "loading": "Yükleniyor...",

View File

@@ -22,8 +22,7 @@
"shell": "终端", "shell": "终端",
"files": "文件", "files": "文件",
"git": "源代码管理", "git": "源代码管理",
"tasks": "任务", "tasks": "任务"
"browser": "Browser"
}, },
"status": { "status": {
"loading": "加载中...", "loading": "加载中...",

View File

@@ -22,8 +22,7 @@
"shell": "終端機", "shell": "終端機",
"files": "檔案", "files": "檔案",
"git": "版本控制", "git": "版本控制",
"tasks": "任務", "tasks": "任務"
"browser": "Browser"
}, },
"status": { "status": {
"loading": "載入中...", "loading": "載入中...",

View File

@@ -17,7 +17,7 @@ export type ProviderModelsCacheInfo = {
source: 'memory' | 'disk' | 'fresh'; source: 'memory' | 'disk' | 'fresh';
}; };
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'browser' | `plugin:${string}`; export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
export interface ProjectSession { export interface ProjectSession {
id: string; id: string;