Compare commits

..

16 Commits

Author SHA1 Message Date
simosmik
ca247cddae refactor(git-panel): simplify setCommitMessage with plain function 2026-03-09 07:47:17 +00:00
simosmik
4061a2761e fix(plugins): async shutdown and asset/RPC fixes
Await stopPluginServer/stopAllPlugins in signal handlers and route
handlers so process exit and state transitions wait for clean plugin
shutdown instead of racing ahead.

Validate asset paths are regular files before streaming to prevent
directory traversal returning unexpected content; add a stream error
handler to avoid unhandled crashes on read failures.

Fix RPC proxy body detection to use the content-length header instead
of Object.keys, so falsy but valid JSON payloads (null, false, 0, {})
are forwarded correctly to plugin servers.

Track in-flight start operations via a startingPlugins map to prevent
duplicate concurrent plugin starts.
2026-03-09 07:39:00 +00:00
simosmik
c368451891 fix(plugins): support concurrent plugin updates
Replace single updatingPlugin string state with a Set to allow
multiple plugins to update simultaneously. Also disable the update
button and show a descriptive tooltip when a plugin has no git
remote configured.
2026-03-09 07:35:01 +00:00
simosmik
efdee162c9 fix(plugins): harden path traversal and respect enabled state
Use realpathSync to canonicalize paths before the plugin asset
boundary check, preventing symlink-based traversal bypasses that
could escape the plugin directory.

PluginTabContent now guards on plugin.enabled before mounting the
plugin module, and re-mounts when the enabled state changes so
toggling a plugin takes effect without a page reload.

PluginIcon safely handles a missing iconFile prop and skips
processing non-OK fetch responses instead of attempting to parse
error bodies as SVG.

Register 'plugins' as a known main tab so the settings router
preserves the tab on navigation.
2026-03-09 06:51:58 +00:00
viper151
a7e8b12ef4 fix: coderabbit nitpick comments 2026-03-06 15:53:11 +01:00
viper151
24430fa343 fix: lint errors and deleting plugin error on windows 2026-03-06 15:44:32 +01:00
Haileyesus
38accf6505 Merge branch 'main' into feat/plugin-system 2026-03-06 17:09:52 +03:00
simosmik
e80fd4b09b fix(plugins): prevent git arg injection, add repo URL detection 2026-03-06 12:09:25 +00:00
simosmik
1d62df68d6 fix: design changes to plugins settings tab 2026-03-06 12:01:11 +00:00
simosmik
0a3e22905f fix: coderabbit changes and new plugin name & repo 2026-03-06 11:50:01 +00:00
viper151
a09aa5f68e feat(plugins): add SVG icon support with authenticated inline rendering 2026-03-06 12:36:15 +01:00
Haileyesus
95ba61ea3e Merge branch 'main' into feat/plugin-system 2026-03-06 13:07:30 +03:00
Simos Mikelatos
6e4ea7f333 Update manifest.json 2026-03-06 10:20:56 +01:00
Simos Mikelatos
6d4cea0435 Potential fix for code scanning alert no. 312: Uncontrolled data used in path expression
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-06 01:16:52 +01:00
Simos Mikelatos
ba197cc286 Merge branch 'main' into feat/plugin-system
# Conflicts:
#	src/App.tsx
#	src/components/app/MobileNav.tsx
#	src/components/main-content/view/MainContent.tsx
#	src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx
#	src/components/main-content/view/subcomponents/MainContentTitle.tsx
#	src/components/settings/view/SettingsMainTabs.tsx
2026-03-05 22:53:54 +00:00
Simos Mikelatos
b4169887ab feat: new plugin system 2026-03-05 22:51:27 +00:00
47 changed files with 381 additions and 2452 deletions

7
.gitignore vendored
View File

@@ -108,7 +108,7 @@ temp/
.serena/
CLAUDE.md
.mcp.json
.gemini/
# Database files
*.db
@@ -130,8 +130,3 @@ dev-debug.log
# Task files
tasks.json
tasks/
# Translations
!src/i18n/locales/en/tasks.json
!src/i18n/locales/ja/tasks.json
!src/i18n/locales/ru/tasks.json

View File

@@ -3,33 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.25.0](https://github.com/siteboon/claudecodeui/compare/v1.24.0...v1.25.0) (2026-03-10)
### New Features
* add copy as text or markdown feature for assistant messages ([#519](https://github.com/siteboon/claudecodeui/issues/519)) ([1dc2a20](https://github.com/siteboon/claudecodeui/commit/1dc2a205dc2a3cbf960625d7669c7c63a2b6905f))
* add full Russian language support; update Readme.md files, and .gitignore update ([#514](https://github.com/siteboon/claudecodeui/issues/514)) ([c7dcba8](https://github.com/siteboon/claudecodeui/commit/c7dcba8d9117e84db8aac7d8a7bf6a3aa683e115))
* new plugin system ([#489](https://github.com/siteboon/claudecodeui/issues/489)) ([8afb46a](https://github.com/siteboon/claudecodeui/commit/8afb46af2e5514c9284030367281793fbb014e4f))
### Bug Fixes
* resolve duplicate key issue when rendering model options ([#520](https://github.com/siteboon/claudecodeui/issues/520)) ([9bceab9](https://github.com/siteboon/claudecodeui/commit/9bceab9e1a6e063b0b4f934ed2d9f854fcc9c6a4))
### Maintenance
* add plugins section in readme ([e581a0e](https://github.com/siteboon/claudecodeui/commit/e581a0e1ccd59fd7ec7306ca76a13e73d7c674c1))
## [1.24.0](https://github.com/siteboon/claudecodeui/compare/v1.23.2...v1.24.0) (2026-03-09)
### New Features
* add full-text search across conversations ([#482](https://github.com/siteboon/claudecodeui/issues/482)) ([3950c0e](https://github.com/siteboon/claudecodeui/commit/3950c0e47f41e93227af31494690818d45c8bc7a))
### Bug Fixes
* **git:** prevent shell injection in git routes ([86c33c1](https://github.com/siteboon/claudecodeui/commit/86c33c1c0cb34176725a38f46960213714fc3e04))
* replace getDatabase with better-sqlite3 db in getGithubTokenById ([#501](https://github.com/siteboon/claudecodeui/issues/501)) ([cb4fd79](https://github.com/siteboon/claudecodeui/commit/cb4fd795c938b1cc86d47f401973bfccdd68fdee))
## [1.23.2](https://github.com/siteboon/claudecodeui/compare/v1.22.1...v1.23.2) (2026-03-06)
### New Features

View File

@@ -6,7 +6,7 @@
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview)、[Codex](https://developers.openai.com/codex) 向けのデスクトップ・モバイル UI です。ローカルまたはリモートで使用して、Claude Code、Cursor、Codex のアクティブなプロジェクトやセッションを確認し、どこからでも(モバイルやデスクトップから)変更を加えることができます。どこでも使える適切なインターフェースを提供します。
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a></i></div>
## スクリーンショット
@@ -193,8 +193,8 @@ npm run dev
Claude Code の全機能を使用するには、手動でツールを有効にする必要があります:
1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック
2. **選択的に有効化** - 必要なツールのみを有効にする
3. **設定を適用** - 環境設定はローカルに保存されます
3. **選択的に有効化** - 必要なツールのみを有効にする
4. **設定を適用** - 環境設定はローカルに保存されます
<div align="center">

View File

@@ -6,7 +6,7 @@
[Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) 및 [Codex](https://developers.openai.com/codex)를 위한 데스크톱 및 모바일 UI입니다. 로컬 또는 원격으로 사용하여 Claude Code, Cursor 또는 Codex의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다.
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
## 스크린샷
@@ -193,8 +193,8 @@ npm run dev
Claude Code의 전체 기능을 사용하려면 수동으로 도구를 활성화해야 합니다:
1. **도구 설정 열기** - 사이드바의 톱니바퀴 아이콘을 클릭
2. **선택적으로 활성화** - 필요한 도구만 활성화
3. **설정 적용** - 환경설정은 로컬에 저장됩니다
3. **선택적으로 활성화** - 필요한 도구만 활성화
4. **설정 적용** - 환경설정은 로컬에 저장됩니다
<div align="center">

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
---
@@ -43,7 +43,7 @@
<h3>CLI Selection</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br>
<em>Select between Claude Code, Gemini, Cursor CLI and Codex</em>
<em>Select between Claude Code, Cursor CLI and Codex</em>
</td>
</tr>
</table>
@@ -60,7 +60,6 @@
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
@@ -130,8 +129,8 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
To use Claude Code's full functionality, you'll need to manually enable tools:
1. **Open Tools Settings** - Click the gear icon in the sidebar
2. **Enable Selectively** - Turn on only the tools you need
3. **Apply Settings** - Your preferences are saved locally
3. **Enable Selectively** - Turn on only the tools you need
4. **Apply Settings** - Your preferences are saved locally
<div align="center">
@@ -142,24 +141,6 @@ To use Claude Code's full functionality, you'll need to manually enable tools:
**Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.
---
## Plugins
CloudCLI has a plugin system that lets you add custom tabs with their own frontend UI and optional Node.js backend. Install plugins from git repos directly in **Settings > Plugins**, or build your own.
### Available Plugins
| 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 |
### 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 Documentation →](https://cloudcli.ai/docs/plugin-overview)** — full guide to the plugin API, manifest format, security model, and more.
---
## FAQ

View File

@@ -1,218 +0,0 @@
<div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (aka Claude Code UI)</h1>
</div>
Десктопный и мобильный UI для [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex) и [Gemini-CLI](https://geminicli.com/). Его можно использовать локально или удаленно, чтобы просматривать активные проекты и сессии и вносить изменения откуда угодно, с мобильного или десктопа. Это дает полноценный интерфейс, который работает везде.
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Сообщить об ошибке</a> · <a href="CONTRIBUTING.md">Участие в разработке</a>
</p>
<p align="center">
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Join our Discord"></a>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
## Скриншоты
<div align="center">
<table>
<tr>
<td align="center">
<h3>Версия для десктопа</h3>
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
<br>
<em>Основной интерфейс с обзором проекта и чатом</em>
</td>
<td align="center">
<h3>Мобильный режим</h3>
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
<br>
<em>Адаптивный мобильный интерфейс с сенсорной навигацией</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>Выбор CLI</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br>
<em>Выбор между Claude Code, Cursor CLI, Codex и Gemini CLI</em>
</td>
</tr>
</table>
</div>
## Возможности
- **Адаптивный дизайн** - одинаково хорошо работает на десктопе, планшете и телефоне, поэтому пользоваться агентами можно и с мобильных устройств
- **Интерактивный чат-интерфейс** - встроенный чат для удобного взаимодействия с агентами
- **Встроенный shell-терминал** - прямой доступ к CLI агентов через встроенную оболочку
- **Файловый менеджер** - интерактивное дерево файлов с подсветкой синтаксиса и live-редактированием
- **Git Explorer** - просмотр, stage и commit изменений, а также переключение веток
- **Управление сессиями** - возобновление диалогов, работа с несколькими сессиями и история
- **Интеграция с TaskMaster AI** *(опционально)* - расширенное управление проектами с AI-планированием задач, разбором PRD и автоматизацией workflows
- **Совместимость с моделями** - работает с Claude Sonnet 4.5, Opus 4.5, GPT-5.2 и Gemini.
## Быстрый старт
### CloudCLI Cloud (рекомендуется)
Самый быстрый способ начать работу: локальная настройка не требуется. Вы получаете полностью управляемую контейнеризированную среду разработки с доступом из браузера, мобильного приложения, API или любимой IDE.
**[Начать с CloudCLI Cloud](https://cloudcli.ai)**
### Self-Hosted (open source)
Попробовать CloudCLI UI можно сразу через **npx** (нужен **Node.js** v22+):
```bash
npx @siteboon/claude-code-ui
```
Или установить **глобально** для постоянного использования:
```bash
npm install -g @siteboon/claude-code-ui
cloudcli
```
Откройте `http://localhost:3001` — все существующие сессии будут обнаружены автоматически.
Больше вариантов настройки, PM2, удаленный сервер и остальное описаны в **[документации →](https://cloudcli.ai/docs)**
---
## Какой вариант подойдет вам?
CloudCLI UI - это open source UI-слой, на котором построен CloudCLI Cloud. Вы можете развернуть его у себя на машине или использовать CloudCLI Cloud, который добавляет полностью управляемую облачную среду, командные функции и более глубокие интеграции.
| | CloudCLI UI (self-hosted) | CloudCLI Cloud |
|---|---|---|
| **Лучше всего подходит для** | Разработчиков, которым нужен полноценный UI для локальных агентских сессий на своей машине | Команд и разработчиков, которым нужны агенты в облаке с доступом откуда угодно |
| **Способ доступа** | Браузер через `[yourip]:port` | Браузер, любая IDE, REST API, n8n |
| **Настройка** | `npx @siteboon/claude-code-ui` | Настройка не требуется |
| **Машина должна оставаться включенной** | Да | Нет |
| **Доступ с мобильных устройств** | Любой браузер в вашей сети | Любое устройство, нативное приложение в разработке |
| **Доступные сессии** | Все сессии автоматически обнаруживаются в `~/.claude` | Все сессии внутри вашей облачной среды |
| **Поддерживаемые агенты** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
| **Файловый менеджер и Git** | Да, встроены в UI | Да, встроены в UI |
| **Конфигурация MCP** | Управляется через UI, синхронизируется с локальным `~/.claude` | Управляется через UI |
| **Доступ из IDE** | Ваша локальная IDE | Любая IDE, подключенная к облачной среде |
| **REST API** | Да | Да |
| **Узел n8n** | Нет | Да |
| **Совместная работа в команде** | Нет | Да |
| **Стоимость платформы** | Бесплатно, open source | От $7/месяц |
> В обоих вариантах используются ваши собственные AI-подписки (Claude, Cursor и т.д.) — CloudCLI предоставляет среду, а не сам AI.
---
## Безопасность и настройка инструментов
**🔒 Важно**: все инструменты Claude Code **по умолчанию отключены**. Это предотвращает автоматический запуск потенциально опасных операций.
### Включение инструментов
Чтобы использовать всю функциональность Claude Code, инструменты нужно включить вручную:
1. **Откройте настройки инструментов** - нажмите на иконку шестеренки в боковой панели
2. **Включайте выборочно** - активируйте только те инструменты, которые действительно нужны
3. **Примените настройки** - предпочтения сохраняются локально
<div align="center">
![Tools Settings Modal](public/screenshots/tools-modal.png)
*Окно настройки инструментов - включайте только то, что вам нужно*
</div>
**Рекомендуемый подход**: начните с базовых инструментов и добавляйте остальные по мере необходимости. Эти настройки всегда можно поменять позже.
---
## FAQ
<details>
<summary>Чем это отличается от Claude Code Remote Control?</summary>
Claude Code Remote Control позволяет отправлять сообщения в сессию, уже запущенную в локальном терминале. При этом ваша машина должна оставаться включенной, терминал должен быть открыт, а сессии завершаются примерно через 10 минут без сетевого соединения.
CloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работают рядом с ним — ваши MCP-серверы, разрешения, настройки и сессии остаются теми же самыми, что и в нативном Claude Code. Ничего не дублируется и не управляется отдельно.
Вот что это означает на практике:
- **Все ваши сессии, а не одна** — CloudCLI UI автоматически находит каждую сессию из папки `~/.claude`. Remote Control предоставляет только одну активную сессию, чтобы сделать ее доступной в мобильном приложении Claude.
- **Ваши настройки остаются вашими** — MCP-серверы, права инструментов и конфигурация проекта, измененные в CloudCLI UI, записываются напрямую в конфиг Claude Code и вступают в силу сразу же, и наоборот.
- **Поддержка большего числа агентов** — Claude Code, Cursor CLI, Codex и Gemini CLI, а не только Claude Code.
- **Полноценный UI, а не просто окно чата** — встроены файловый менеджер, Git-интеграция, управление MCP и shell-терминал.
- **CloudCLI Cloud работает в облаке** — можно закрыть ноутбук, а агент продолжит работу. Не нужно держать терминал открытым и машину в активном состоянии.
</details>
<details>
<summary>Нужно ли отдельно платить за AI-подписку?</summary>
Да. CloudCLI предоставляет среду, а не сам AI. Вы используете собственную подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud стоит от $7/месяц за хостируемую среду сверх этого.
</details>
<details>
<summary>Можно ли пользоваться CloudCLI UI с телефона?</summary>
Да. Для self-hosted запустите сервер на своей машине и откройте `[yourip]:port` в любом браузере внутри вашей сети. Для CloudCLI Cloud откройте сервис с любого устройства — без VPN, проброса портов и дополнительной настройки. Нативное приложение тоже разрабатывается.
</details>
<details>
<summary>Повлияют ли изменения, сделанные в UI, на мой локальный Claude Code?</summary>
Да, в self-hosted режиме. CloudCLI UI читает и записывает тот же конфиг `~/.claude`, который нативно использует Claude Code. MCP-серверы, добавленные через UI, сразу появляются в Claude Code, и наоборот.
</details>
---
## Сообщество и поддержка
- **[Документация](https://cloudcli.ai/docs)** — установка, настройка, возможности и устранение неполадок
- **[Discord](https://discord.gg/buxwujPNRE)** — помощь и общение с другими пользователями
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — баг-репорты и запросы новых функций
- **[Руководство для контрибьюторов](CONTRIBUTING.md)** — как участвовать в развитии проекта
## Лицензия
GNU General Public License v3.0 - подробности в файле [LICENSE](LICENSE).
Этот проект открыт и может свободно использоваться, изменяться и распространяться по лицензии GPL v3.
## Благодарности
### Используется
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - официальный CLI от Anthropic
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - официальный CLI от Cursor
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - библиотека пользовательских интерфейсов
- **[Vite](https://vitejs.dev/)** - быстрый инструмент сборки и dev-сервер
- **[Tailwind CSS](https://tailwindcss.com/)** - utility-first CSS framework
- **[CodeMirror](https://codemirror.net/)** - продвинутый редактор кода
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(опционально)* - AI-управление проектами и планирование задач
### Спонсоры
- [Siteboon - AI powered website builder](https://siteboon.ai)
---
<div align="center">
<strong>Сделано с любовью к сообществу Claude Code, Cursor и Codex.</strong>
</div>

View File

@@ -6,7 +6,7 @@
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview) 和 [Codex](https://developers.openai.com/codex) 的桌面端和移动端界面。您可以在本地或远程使用它来查看 Claude Code、Cursor 或 Codex 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.ja.md">日本語</a></i></div>
## 截图
@@ -194,8 +194,8 @@ npm run dev
要使用 Claude Code 的完整功能,您需要手动启用工具:
1. **打开工具设置** - 点击侧边栏中的齿轮图标
2. **选择性启用** - 仅打开您需要的工具
3. **应用设置** - 您的偏好设置将保存在本地
3. **选择性启用** - 仅打开您需要的工具
4. **应用设置** - 您的偏好设置将保存在本地
<div align="center">
@@ -344,4 +344,4 @@ GNU General Public License v3.0 - 详见 [LICENSE](LICENSE) 文件。
<div align="center">
<strong>为 Claude Code、Cursor 和 Codex 社区精心打造。</strong>
</div>
</div>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.25.0",
"version": "1.23.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.25.0",
"version": "1.23.2",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.25.0",
"version": "1.23.2",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

@@ -59,15 +59,6 @@ if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGAC
// Create database connection
const db = new Database(DB_PATH);
// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
// runMigrations() also creates this table, but it runs too late for existing installations
// where auth.js is imported before initializeDatabase() is called.
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Show app installation path prominently
const appInstallPath = path.join(__dirname, '../..');
console.log('');
@@ -100,13 +91,6 @@ const runMigrations = () => {
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
}
// Create app_config table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Create session_names table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -430,33 +414,6 @@ function applyCustomSessionNames(sessions, provider) {
}
}
// App config database operations
const appConfigDb = {
get: (key) => {
try {
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
return row?.value || null;
} catch (err) {
return null;
}
},
set: (key, value) => {
db.prepare(
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, value);
},
getOrCreateJwtSecret: () => {
let secret = appConfigDb.get('jwt_secret');
if (!secret) {
secret = crypto.randomBytes(64).toString('hex');
appConfigDb.set('jwt_secret', secret);
}
return secret;
}
};
// Backward compatibility - keep old names pointing to new system
const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -484,6 +441,5 @@ export {
credentialsDb,
sessionNamesDb,
applyCustomSessionNames,
appConfigDb,
githubTokensDb // Backward compatibility
};

View File

@@ -62,11 +62,4 @@ CREATE TABLE IF NOT EXISTS session_names (
UNIQUE(session_id, provider)
);
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
-- App configuration table (auto-generated secrets, settings, etc.)
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);

View File

@@ -326,7 +326,7 @@ const wss = new WebSocketServer({
// Make WebSocket server available to routes
app.locals.wss = wss;
app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
app.use(cors());
app.use(express.json({
limit: '50mb',
type: (req) => {
@@ -1699,43 +1699,50 @@ function handleShellConnection(ws) {
}));
try {
// Validate projectPath — resolve to absolute and verify it exists
const resolvedProjectPath = path.resolve(projectPath);
try {
const stats = fs.statSync(resolvedProjectPath);
if (!stats.isDirectory()) {
throw new Error('Not a directory');
}
} catch (pathErr) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
return;
}
// Validate sessionId — only allow safe characters
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
return;
}
// Build shell command — use cwd for project path (never interpolate into shell string)
// Prepare the shell command adapted to the platform and provider
let shellCommand;
if (isPlainShell) {
// Plain shell mode - run the initial command in the project directory
shellCommand = initialCommand;
} else if (provider === 'cursor') {
if (hasSession && sessionId) {
shellCommand = `cursor-agent --resume="${sessionId}"`;
// Plain shell mode - just run the initial command in the project directory
if (os.platform() === 'win32') {
shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
} else {
shellCommand = 'cursor-agent';
shellCommand = `cd "${projectPath}" && ${initialCommand}`;
}
} else if (provider === 'codex') {
if (hasSession && sessionId) {
shellCommand = `codex resume "${sessionId}" || codex`;
} else if (provider === 'cursor') {
// Use cursor-agent command
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
}
} else {
shellCommand = 'codex';
if (hasSession && sessionId) {
shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
} else {
shellCommand = `cd "${projectPath}" && cursor-agent`;
}
}
} else if (provider === 'codex') {
// Use codex command
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
}
} else {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
} else {
shellCommand = `cd "${projectPath}" && codex`;
}
}
} else if (provider === 'gemini') {
// Use gemini command
const command = initialCommand || 'gemini';
let resumeId = sessionId;
if (hasSession && sessionId) {
@@ -1746,28 +1753,41 @@ function handleShellConnection(ws) {
const sess = sessionManager.getSession(sessionId);
if (sess && sess.cliSessionId) {
resumeId = sess.cliSessionId;
// Validate the looked-up CLI session ID too
if (!safeSessionIdPattern.test(resumeId)) {
resumeId = null;
}
}
} catch (err) {
console.error('Failed to get Gemini CLI session ID:', err);
}
}
if (hasSession && resumeId) {
shellCommand = `${command} --resume "${resumeId}"`;
if (os.platform() === 'win32') {
if (hasSession && resumeId) {
shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
}
} else {
shellCommand = command;
if (hasSession && resumeId) {
shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
}
} else {
// Claude (default provider)
// Use claude command (default) or initialCommand if provided
const command = initialCommand || 'claude';
if (hasSession && sessionId) {
shellCommand = `claude --resume "${sessionId}" || claude`;
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to new session if it fails
shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
}
} else {
shellCommand = command;
if (hasSession && sessionId) {
shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
} else {
shellCommand = `cd "${projectPath}" && ${command}`;
}
}
}
@@ -1786,7 +1806,7 @@ function handleShellConnection(ws) {
name: 'xterm-256color',
cols: termCols,
rows: termRows,
cwd: resolvedProjectPath,
cwd: os.homedir(),
env: {
...process.env,
TERM: 'xterm-256color',

View File

@@ -1,9 +1,9 @@
import jwt from 'jsonwebtoken';
import { userDb, appConfigDb } from '../database/db.js';
import { userDb } from '../database/db.js';
import { IS_PLATFORM } from '../constants/config.js';
// Use env var if set, otherwise auto-generate a unique secret per installation
const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
// Get JWT secret from environment or use default (for development)
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
// Optional API key middleware
const validateApiKey = (req, res, next) => {
@@ -58,16 +58,6 @@ const authenticateToken = async (req, res, next) => {
return res.status(401).json({ error: 'Invalid token. User not found.' });
}
// Auto-refresh: if token is past halfway through its lifetime, issue a new one
if (decoded.exp && decoded.iat) {
const now = Math.floor(Date.now() / 1000);
const halfLife = (decoded.exp - decoded.iat) / 2;
if (now > decoded.iat + halfLife) {
const newToken = generateToken(user);
res.setHeader('X-Refreshed-Token', newToken);
}
}
req.user = user;
next();
} catch (error) {
@@ -76,15 +66,15 @@ const authenticateToken = async (req, res, next) => {
}
};
// Generate JWT token
// Generate JWT token (never expires)
const generateToken = (user) => {
return jwt.sign(
{
userId: user.id,
username: user.username
{
userId: user.id,
username: user.username
},
JWT_SECRET,
{ expiresIn: '7d' }
JWT_SECRET
// No expiration - token lasts forever
);
};
@@ -111,12 +101,7 @@ const authenticateWebSocket = (token) => {
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Verify user actually exists in database (matches REST authenticateToken behavior)
const user = userDb.getUserById(decoded.userId);
if (!user) {
return null;
}
return { userId: user.id, username: user.username };
return decoded;
} catch (error) {
console.error('WebSocket token verification error:', error);
return null;

View File

@@ -1,5 +1,6 @@
import express from 'express';
import { spawn } from 'child_process';
import { exec, spawn } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { promises as fs } from 'fs';
import { extractProjectDirectory } from '../projects.js';
@@ -7,6 +8,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
const router = express.Router();
const execAsync = promisify(exec);
function spawnAsync(command, args, options = {}) {
return new Promise((resolve, reject) => {
@@ -45,36 +47,6 @@ function spawnAsync(command, args, options = {}) {
});
}
// Input validation helpers (defense-in-depth)
function validateCommitRef(commit) {
// Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names
if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) {
throw new Error('Invalid commit reference');
}
return commit;
}
function validateBranchName(branch) {
if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) {
throw new Error('Invalid branch name');
}
return branch;
}
function validateFilePath(file) {
if (!file || file.includes('\0')) {
throw new Error('Invalid file path');
}
return file;
}
function validateRemoteName(remote) {
if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
throw new Error('Invalid remote name');
}
return remote;
}
// Helper function to get the actual project path from the encoded project name
async function getActualProjectPath(projectName) {
try {
@@ -126,14 +98,14 @@ async function validateGitRepository(projectPath) {
try {
// Allow any directory that is inside a work tree (repo root or nested folder).
const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
if (!isInsideWorkTree) {
throw new Error('Not inside a git work tree');
}
// Ensure git can resolve the repository root for this directory.
await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
} catch {
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
}
@@ -157,7 +129,7 @@ router.get('/status', async (req, res) => {
let branch = 'main';
let hasCommits = true;
try {
const { stdout: branchOutput } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
branch = branchOutput.trim();
} catch (error) {
// No HEAD exists - repository has no commits yet
@@ -170,7 +142,7 @@ router.get('/status', async (req, res) => {
}
// Get git status
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath });
const modified = [];
const added = [];
@@ -229,11 +201,8 @@ router.get('/diff', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
// Validate file path
validateFilePath(file);
// Check if file is untracked or deleted
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
const isUntracked = statusOutput.startsWith('??');
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
@@ -254,21 +223,21 @@ router.get('/diff', async (req, res) => {
}
} else if (isDeleted) {
// For deleted files, show the entire file content from HEAD as deletions
const { stdout: fileContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
const lines = fileContent.split('\n');
diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
lines.map(line => `-${line}`).join('\n');
} else {
// Get diff for tracked files
// First check for unstaged changes (working tree vs index)
const { stdout: unstagedDiff } = await spawnAsync('git', ['diff', '--', file], { cwd: projectPath });
const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
if (unstagedDiff) {
// Show unstaged changes if they exist
diff = stripDiffHeaders(unstagedDiff);
} else {
// If no unstaged changes, check for staged changes (index vs HEAD)
const { stdout: stagedDiff } = await spawnAsync('git', ['diff', '--cached', '--', file], { cwd: projectPath });
const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
diff = stripDiffHeaders(stagedDiff) || '';
}
}
@@ -294,11 +263,8 @@ router.get('/file-with-diff', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
// Validate file path
validateFilePath(file);
// Check file status
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
const isUntracked = statusOutput.startsWith('??');
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
@@ -307,7 +273,7 @@ router.get('/file-with-diff', async (req, res) => {
if (isDeleted) {
// For deleted files, get content from HEAD
const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
oldContent = headContent;
currentContent = headContent; // Show the deleted content in editor
} else {
@@ -325,7 +291,7 @@ router.get('/file-with-diff', async (req, res) => {
if (!isUntracked) {
// Get the old content from HEAD for tracked files
try {
const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
oldContent = headContent;
} catch (error) {
// File might be newly added to git (staged but not committed)
@@ -362,17 +328,17 @@ router.post('/initial-commit', async (req, res) => {
// Check if there are already commits
try {
await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath });
await execAsync('git rev-parse HEAD', { cwd: projectPath });
return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
} catch (error) {
// No HEAD - this is good, we can create initial commit
}
// Add all files
await spawnAsync('git', ['add', '.'], { cwd: projectPath });
await execAsync('git add .', { cwd: projectPath });
// Create initial commit
const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath });
const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
} catch (error) {
@@ -406,12 +372,11 @@ router.post('/commit', async (req, res) => {
// Stage selected files
for (const file of files) {
validateFilePath(file);
await spawnAsync('git', ['add', file], { cwd: projectPath });
await execAsync(`git add "${file}"`, { cwd: projectPath });
}
// Commit with message
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: projectPath });
const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath });
res.json({ success: true, output: stdout });
} catch (error) {
@@ -435,7 +400,7 @@ router.get('/branches', async (req, res) => {
await validateGitRepository(projectPath);
// Get all branches
const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
// Parse branches
const branches = stdout
@@ -474,8 +439,7 @@ router.post('/checkout', async (req, res) => {
const projectPath = await getActualProjectPath(project);
// Checkout the branch
validateBranchName(branch);
const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath });
const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
res.json({ success: true, output: stdout });
} catch (error) {
@@ -496,8 +460,7 @@ router.post('/create-branch', async (req, res) => {
const projectPath = await getActualProjectPath(project);
// Create and checkout new branch
validateBranchName(branch);
const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath });
const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
res.json({ success: true, output: stdout });
} catch (error) {
@@ -546,8 +509,8 @@ router.get('/commits', async (req, res) => {
// Get stats for each commit
for (const commit of commits) {
try {
const { stdout: stats } = await spawnAsync(
'git', ['show', '--stat', '--format=', commit.hash],
const { stdout: stats } = await execAsync(
`git show --stat --format='' ${commit.hash}`,
{ cwd: projectPath }
);
commit.stats = stats.trim().split('\n').pop(); // Get the summary line
@@ -573,13 +536,10 @@ router.get('/commit-diff', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
// Validate commit reference (defense-in-depth)
validateCommitRef(commit);
// Get diff for the commit
const { stdout } = await spawnAsync(
'git', ['show', commit],
const { stdout } = await execAsync(
`git show ${commit}`,
{ cwd: projectPath }
);
@@ -610,9 +570,8 @@ router.post('/generate-commit-message', async (req, res) => {
let diffContext = '';
for (const file of files) {
try {
validateFilePath(file);
const { stdout } = await spawnAsync(
'git', ['diff', 'HEAD', '--', file],
const { stdout } = await execAsync(
`git diff HEAD -- "${file}"`,
{ cwd: projectPath }
);
if (stdout) {
@@ -805,14 +764,14 @@ router.get('/remote-status', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
// Check if there's a remote tracking branch (smart detection)
let trackingBranch;
let remoteName;
try {
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
trackingBranch = stdout.trim();
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
} catch (error) {
@@ -820,7 +779,7 @@ router.get('/remote-status', async (req, res) => {
let hasRemote = false;
let remoteName = null;
try {
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
const { stdout } = await execAsync('git remote', { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length > 0) {
hasRemote = true;
@@ -829,8 +788,8 @@ router.get('/remote-status', async (req, res) => {
} catch (remoteError) {
// No remotes configured
}
return res.json({
return res.json({
hasRemote,
hasUpstream: false,
branch,
@@ -840,8 +799,8 @@ router.get('/remote-status', async (req, res) => {
}
// Get ahead/behind counts
const { stdout: countOutput } = await spawnAsync(
'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],
const { stdout: countOutput } = await execAsync(
`git rev-list --count --left-right ${trackingBranch}...HEAD`,
{ cwd: projectPath }
);
@@ -876,21 +835,20 @@ router.post('/fetch', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback
try {
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
remoteName = stdout.trim().split('/')[0]; // Extract remote name
} catch (error) {
// No upstream, try to fetch from origin anyway
console.log('No upstream configured, using origin as fallback');
}
validateRemoteName(remoteName);
const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath });
const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath });
res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
} catch (error) {
console.error('Git fetch error:', error);
@@ -918,13 +876,13 @@ router.post('/pull', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
try {
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
const tracking = stdout.trim();
remoteName = tracking.split('/')[0]; // Extract remote name
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
@@ -933,19 +891,17 @@ router.post('/pull', async (req, res) => {
console.log('No upstream configured, using origin/branch as fallback');
}
validateRemoteName(remoteName);
validateBranchName(remoteBranch);
const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Pull completed successfully',
const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Pull completed successfully',
remoteName,
remoteBranch
});
} catch (error) {
console.error('Git pull error:', error);
// Enhanced error handling for common pull scenarios
let errorMessage = 'Pull failed';
let details = error.message;
@@ -987,13 +943,13 @@ router.post('/push', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
try {
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
const tracking = stdout.trim();
remoteName = tracking.split('/')[0]; // Extract remote name
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
@@ -1002,13 +958,11 @@ router.post('/push', async (req, res) => {
console.log('No upstream configured, using origin/branch as fallback');
}
validateRemoteName(remoteName);
validateBranchName(remoteBranch);
const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Push completed successfully',
const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Push completed successfully',
remoteName,
remoteBranch
});
@@ -1058,39 +1012,35 @@ router.post('/publish', async (req, res) => {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Validate branch name
validateBranchName(branch);
// Get current branch to verify it matches the requested branch
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const currentBranchName = currentBranch.trim();
if (currentBranchName !== branch) {
return res.status(400).json({
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
return res.status(400).json({
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
});
}
// Check if remote exists
let remoteName = 'origin';
try {
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
const { stdout } = await execAsync('git remote', { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length === 0) {
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
});
}
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
} catch (error) {
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
});
}
// Publish the branch (set upstream and push)
validateRemoteName(remoteName);
const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath });
const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
res.json({
success: true,
@@ -1138,12 +1088,9 @@ router.post('/discard', async (req, res) => {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Validate file path
validateFilePath(file);
// Check file status to determine correct discard command
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'No changes to discard for this file' });
}
@@ -1162,10 +1109,10 @@ router.post('/discard', async (req, res) => {
}
} else if (status.includes('M') || status.includes('D')) {
// Modified or deleted file - restore from HEAD
await spawnAsync('git', ['restore', file], { cwd: projectPath });
await execAsync(`git restore "${file}"`, { cwd: projectPath });
} else if (status.includes('A')) {
// Added file - unstage it
await spawnAsync('git', ['reset', 'HEAD', file], { cwd: projectPath });
await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath });
}
res.json({ success: true, message: `Changes discarded for ${file}` });
@@ -1187,11 +1134,8 @@ router.post('/delete-untracked', async (req, res) => {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Validate file path
validateFilePath(file);
// Check if file is actually untracked
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'File is not untracked or does not exist' });

View File

@@ -39,9 +39,6 @@ router.get('/', (req, res) => {
// GET /:name/manifest — Get single plugin manifest
router.get('/:name/manifest', (req, res) => {
try {
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === req.params.name);
if (!plugin) {
@@ -56,9 +53,6 @@ router.get('/:name/manifest', (req, res) => {
// GET /:name/assets/* — Serve plugin static files
router.get('/:name/assets/*', (req, res) => {
const pluginName = req.params.name;
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
const assetPath = req.params[0];
if (!assetPath) {
@@ -258,11 +252,7 @@ router.all('/:name/rpc/*', async (req, res) => {
});
proxyReq.on('error', (err) => {
if (!res.headersSent) {
res.status(502).json({ error: 'Plugin server error', details: err.message });
} else {
res.end();
}
res.status(502).json({ error: 'Plugin server error', details: err.message });
});
// Forward body (already parsed by express JSON middleware, so re-stringify).

View File

@@ -311,11 +311,13 @@ router.post('/create-workspace', async (req, res) => {
* Helper function to get GitHub token from database
*/
async function getGithubTokenById(tokenId, userId) {
const { db } = await import('../database/db.js');
const { getDatabase } = await import('../database/db.js');
const db = await getDatabase();
const credential = db.prepare(
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1'
).get(tokenId, userId, 'github_token');
const credential = await db.get(
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
[tokenId, userId, 'github_token']
);
// Return in the expected format (github_token field for compatibility)
if (credential) {

View File

@@ -2,29 +2,12 @@ import express from 'express';
import { userDb } from '../database/db.js';
import { authenticateToken } from '../middleware/auth.js';
import { getSystemGitConfig } from '../utils/gitConfig.js';
import { spawn } from 'child_process';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const router = express.Router();
function spawnAsync(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { ...options, shell: false });
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.stderr.on('data', (data) => { stderr += data.toString(); });
child.on('error', (error) => { reject(error); });
child.on('close', (code) => {
if (code === 0) { resolve({ stdout, stderr }); return; }
const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
error.code = code;
error.stdout = stdout;
error.stderr = stderr;
reject(error);
});
});
}
router.get('/git-config', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
@@ -72,8 +55,8 @@ router.post('/git-config', authenticateToken, async (req, res) => {
userDb.updateGitConfig(userId, gitName, gitEmail);
try {
await spawnAsync('git', ['config', '--global', 'user.name', gitName]);
await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]);
await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`);
await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`);
console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
} catch (gitError) {
console.error('Error applying git config:', gitError);

View File

@@ -1,17 +1,7 @@
import { spawn } from 'child_process';
import { exec } from 'child_process';
import { promisify } from 'util';
function spawnAsync(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { shell: false });
let stdout = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.on('error', (error) => { reject(error); });
child.on('close', (code) => {
if (code === 0) { resolve({ stdout }); return; }
reject(new Error(`Command failed with code ${code}`));
});
});
}
const execAsync = promisify(exec);
/**
* Read git configuration from system's global git config
@@ -20,8 +10,8 @@ function spawnAsync(command, args) {
export async function getSystemGitConfig() {
try {
const [nameResult, emailResult] = await Promise.all([
spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '' })),
spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '' }))
execAsync('git config --global user.name').catch(() => ({ stdout: '' })),
execAsync('git config --global user.email').catch(() => ({ stdout: '' }))
]);
return {

View File

@@ -7,19 +7,6 @@ const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
/** Strip embedded credentials from a repo URL before exposing it to the client. */
function sanitizeRepoUrl(raw) {
try {
const u = new URL(raw);
u.username = '';
u.password = '';
return u.toString().replace(/\/$/, '');
} catch {
// Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
return raw.replace(/\/\/[^@/]+@/, '//');
}
}
const ALLOWED_TYPES = ['react', 'module'];
const ALLOWED_SLOTS = ['tab'];
@@ -105,12 +92,8 @@ export function scanPlugins() {
return plugins;
}
const seenNames = new Set();
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Skip transient temp directories from in-progress installs
if (entry.name.startsWith('.tmp-')) continue;
const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
if (!fs.existsSync(manifestPath)) continue;
@@ -123,13 +106,6 @@ export function scanPlugins() {
continue;
}
// Skip duplicate manifest names
if (seenNames.has(manifest.name)) {
console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
continue;
}
seenNames.add(manifest.name);
// Try to read git remote URL
let repoUrl = null;
try {
@@ -143,8 +119,6 @@ export function scanPlugins() {
if (repoUrl.startsWith('git@')) {
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
}
// Strip embedded credentials (e.g. https://user:pass@host/...)
repoUrl = sanitizeRepoUrl(repoUrl);
}
}
} catch { /* ignore */ }

View File

@@ -13,14 +13,14 @@
export const CLAUDE_MODELS = {
// Models in SDK format (what the actual SDK accepts)
OPTIONS: [
{ value: "sonnet", label: "Sonnet" },
{ value: "opus", label: "Opus" },
{ value: "haiku", label: "Haiku" },
{ value: "opusplan", label: "Opus Plan" },
{ value: "sonnet[1m]", label: "Sonnet [1M]" },
{ value: 'sonnet', label: 'Sonnet' },
{ value: 'opus', label: 'Opus' },
{ value: 'haiku', label: 'Haiku' },
{ value: 'opusplan', label: 'Opus Plan' },
{ value: 'sonnet[1m]', label: 'Sonnet [1M]' }
],
DEFAULT: "sonnet",
DEFAULT: 'sonnet'
};
/**
@@ -28,28 +28,28 @@ export const CLAUDE_MODELS = {
*/
export const CURSOR_MODELS = {
OPTIONS: [
{ value: "opus-4.6-thinking", label: "Claude 4.6 Opus (Thinking)" },
{ value: "gpt-5.3-codex", label: "GPT-5.3" },
{ value: "gpt-5.2-high", label: "GPT-5.2 High" },
{ value: "gemini-3-pro", label: "Gemini 3 Pro" },
{ value: "opus-4.5-thinking", label: "Claude 4.5 Opus (Thinking)" },
{ value: "gpt-5.2", label: "GPT-5.2" },
{ value: "gpt-5.1", label: "GPT-5.1" },
{ value: "gpt-5.1-high", label: "GPT-5.1 High" },
{ value: "composer-1", label: "Composer 1" },
{ value: "auto", label: "Auto" },
{ value: "sonnet-4.5", label: "Claude 4.5 Sonnet" },
{ value: "sonnet-4.5-thinking", label: "Claude 4.5 Sonnet (Thinking)" },
{ value: "opus-4.5", label: "Claude 4.5 Opus" },
{ value: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
{ value: "gpt-5.1-codex-high", label: "GPT-5.1 Codex High" },
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ value: "gpt-5.1-codex-max-high", label: "GPT-5.1 Codex Max High" },
{ value: "opus-4.1", label: "Claude 4.1 Opus" },
{ value: "grok", label: "Grok" },
{ value: 'opus-4.6-thinking', label: 'Claude 4.6 Opus (Thinking)' },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3' },
{ value: 'gpt-5.2-high', label: 'GPT-5.2 High' },
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
{ value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' },
{ value: 'gpt-5.2', label: 'GPT-5.2' },
{ value: 'gpt-5.1', label: 'GPT-5.1' },
{ value: 'gpt-5.1-high', label: 'GPT-5.1 High' },
{ value: 'composer-1', label: 'Composer 1' },
{ value: 'auto', label: 'Auto' },
{ value: 'sonnet-4.5', label: 'Claude 4.5 Sonnet' },
{ value: 'sonnet-4.5-thinking', label: 'Claude 4.5 Sonnet (Thinking)' },
{ value: 'opus-4.5', label: 'Claude 4.5 Opus' },
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.1-codex-high', label: 'GPT-5.1 Codex High' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'gpt-5.1-codex-max-high', label: 'GPT-5.1 Codex Max High' },
{ value: 'opus-4.1', label: 'Claude 4.1 Opus' },
{ value: 'grok', label: 'Grok' }
],
DEFAULT: "gpt-5-3-codex",
DEFAULT: 'gpt-5-3-codex'
};
/**
@@ -57,16 +57,17 @@ export const CURSOR_MODELS = {
*/
export const CODEX_MODELS = {
OPTIONS: [
{ value: "gpt-5.4", label: "GPT-5.4" },
{ value: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
{ value: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
{ value: "gpt-5.2", label: "GPT-5.2" },
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ value: "o3", label: "O3" },
{ value: "o4-mini", label: "O4-mini" },
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ value: 'gpt-5.2', label: 'GPT-5.2' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'o3', label: 'O3' },
{ value: 'o4-mini', label: 'O4-mini' }
],
DEFAULT: "gpt-5.4",
DEFAULT: 'gpt-5.4'
};
/**
@@ -74,19 +75,16 @@ export const CODEX_MODELS = {
*/
export const GEMINI_MODELS = {
OPTIONS: [
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
{ value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
{
value: "gemini-2.0-flash-thinking-exp",
label: "Gemini 2.0 Flash Thinking",
},
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }
],
DEFAULT: "gemini-2.5-flash",
DEFAULT: 'gemini-2.5-flash'
};

View File

@@ -1,35 +1,13 @@
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
import {
MessageSquare,
Folder,
Terminal,
GitBranch,
ClipboardCheck,
Ellipsis,
Puzzle,
Box,
Database,
Globe,
Wrench,
Zap,
BarChart3,
type LucideIcon,
} from 'lucide-react';
import { useState, useRef, useEffect, Dispatch, SetStateAction } from 'react';
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3 } from 'lucide-react';
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
import { usePlugins } from '../../contexts/PluginsContext';
import { AppTab } from '../../types/app';
const PLUGIN_ICON_MAP: Record<string, LucideIcon> = {
const PLUGIN_ICON_MAP = {
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
};
type CoreTabId = Exclude<AppTab, `plugin:${string}` | 'preview'>;
type CoreNavItem = {
id: CoreTabId;
icon: LucideIcon;
label: string;
};
type MobileNavProps = {
activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>;
@@ -41,7 +19,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const { plugins } = usePlugins();
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef<HTMLDivElement | null>(null);
const moreRef = useRef(null);
const enabledPlugins = plugins.filter((p) => p.enabled);
const hasPlugins = enabledPlugins.length > 0;
@@ -50,9 +28,8 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
// Close the menu on outside tap
useEffect(() => {
if (!moreOpen) return;
const handleTap = (e: PointerEvent) => {
const target = e.target;
if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
const handleTap = (e) => {
if (moreRef.current && !moreRef.current.contains(e.target)) {
setMoreOpen(false);
}
};
@@ -61,21 +38,18 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
}, [moreOpen]);
// Close menu when a plugin tab is selected
const selectPlugin = (name: string) => {
const pluginTab = `plugin:${name}` as AppTab;
setActiveTab(pluginTab);
const selectPlugin = (name) => {
setActiveTab(`plugin:${name}`);
setMoreOpen(false);
};
const baseCoreItems: CoreNavItem[] = [
const coreItems = [
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
{ id: 'shell', icon: Terminal, label: 'Shell' },
{ id: 'files', icon: Folder, label: 'Files' },
{ id: 'git', icon: GitBranch, label: 'Git' },
...(shouldShowTasksTab ? [{ id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] : []),
];
const coreItems: CoreNavItem[] = shouldShowTasksTab
? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
: baseCoreItems;
return (
<div

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type {
@@ -9,10 +9,10 @@ import type {
} from '../../types/types';
import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl';
type DiffLine = {
type: string;
@@ -20,7 +20,7 @@ type DiffLine = {
lineNum: number;
};
type MessageComponentProps = {
interface MessageComponentProps {
message: ChatMessage;
prevMessage: ChatMessage | null;
createDiff: (oldStr: string, newStr: string) => DiffLine[];
@@ -32,7 +32,7 @@ type MessageComponentProps = {
showThinking?: boolean;
selectedProject?: Project | null;
provider: Provider | string;
};
}
type InteractiveOption = {
number: string;
@@ -41,7 +41,6 @@ type InteractiveOption = {
};
type PermissionGrantState = 'idle' | 'granted' | 'error';
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat');
@@ -50,32 +49,18 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
(prevMessage.type === 'user') ||
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const messageRef = React.useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = React.useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')),
[message.content]
);
const assistantCopyContent = message.isToolUse
? String(message.displayText || message.content || '')
: formattedMessageContent;
const isCommandOrFileEditToolResponse = Boolean(
message.isToolUse && COPY_HIDDEN_TOOL_NAMES.has(String(message.toolName || ''))
);
const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0;
const shouldShowAssistantCopyControl = message.type === 'assistant' &&
assistantCopyContent.trim().length > 0 &&
!isCommandOrFileEditToolResponse;
const [permissionGrantState, setPermissionGrantState] = React.useState<PermissionGrantState>('idle');
const [messageCopied, setMessageCopied] = React.useState(false);
useEffect(() => {
React.useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
useEffect(() => {
React.useEffect(() => {
const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return;
@@ -135,9 +120,43 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</div>
)}
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
{shouldShowUserCopyControl && (
<MessageCopyControl content={userCopyContent} messageType="user" />
)}
<button
type="button"
onClick={() => {
const text = String(message.content || '');
if (!text) return;
copyTextToClipboard(text).then((success) => {
if (!success) return;
setMessageCopied(true);
});
}}
title={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
aria-label={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
>
{messageCopied ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
)}
</button>
<span>{formattedTime}</span>
</div>
</div>
@@ -411,7 +430,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
)}
{(() => {
const content = formattedMessageContent;
const content = formatUsageLimitText(String(message.content || ''));
// Detect if content is pure JSON (starts with { or [)
const trimmedContent = content.trim();
@@ -457,12 +476,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</div>
)}
{(shouldShowAssistantCopyControl || !isGrouped) && (
<div className="mt-1 flex w-full items-center gap-2 text-[11px] text-gray-400 dark:text-gray-500">
{shouldShowAssistantCopyControl && (
<MessageCopyControl content={assistantCopyContent} messageType="assistant" />
)}
{!isGrouped && <span>{formattedTime}</span>}
{!isGrouped && (
<div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
{formattedTime}
</div>
)}
</div>

View File

@@ -1,215 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { copyTextToClipboard } from '../../../../utils/clipboard';
const COPY_SUCCESS_TIMEOUT_MS = 2000;
type CopyFormat = 'text' | 'markdown';
type CopyFormatOption = {
format: CopyFormat;
label: string;
};
// Converts markdown into readable plain text for "Copy as text".
const convertMarkdownToPlainText = (markdown: string): string => {
let plainText = markdown.replace(/\r\n/g, '\n');
const codeBlocks: string[] = [];
plainText = plainText.replace(/```[\w-]*\n([\s\S]*?)```/g, (_match, code: string) => {
const placeholder = `@@CODEBLOCK${codeBlocks.length}@@`;
codeBlocks.push(code.replace(/\n$/, ''));
return placeholder;
});
plainText = plainText.replace(/`([^`]+)`/g, '$1');
plainText = plainText.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1');
plainText = plainText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
plainText = plainText.replace(/^>\s?/gm, '');
plainText = plainText.replace(/^#{1,6}\s+/gm, '');
plainText = plainText.replace(/^[-*+]\s+/gm, '');
plainText = plainText.replace(/^\d+\.\s+/gm, '');
plainText = plainText.replace(/(\*\*|__)(.*?)\1/g, '$2');
plainText = plainText.replace(/(\*|_)(.*?)\1/g, '$2');
plainText = plainText.replace(/~~(.*?)~~/g, '$1');
plainText = plainText.replace(/<\/?[^>]+(>|$)/g, '');
plainText = plainText.replace(/\n{3,}/g, '\n\n');
plainText = plainText.replace(/@@CODEBLOCK(\d+)@@/g, (_match, index: string) => codeBlocks[Number(index)] ?? '');
return plainText.trim();
};
const MessageCopyControl = ({
content,
messageType,
}: {
content: string;
messageType: 'user' | 'assistant';
}) => {
const { t } = useTranslation('chat');
const canSelectCopyFormat = messageType === 'assistant';
const defaultFormat: CopyFormat = canSelectCopyFormat ? 'markdown' : 'text';
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
const [copied, setCopied] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const copyFormatOptions: CopyFormatOption[] = useMemo(
() => [
{
format: 'markdown',
label: t('copyMessage.copyAsMarkdown', { defaultValue: 'Copy as markdown' }),
},
{
format: 'text',
label: t('copyMessage.copyAsText', { defaultValue: 'Copy as text' }),
},
],
[t]
);
const selectedFormatTag = selectedFormat === 'markdown'
? t('copyMessage.markdownShort', { defaultValue: 'MD' })
: t('copyMessage.textShort', { defaultValue: 'TXT' });
const copyPayload = useMemo(() => {
if (selectedFormat === 'markdown') {
return content;
}
return convertMarkdownToPlainText(content);
}, [content, selectedFormat]);
useEffect(() => {
setSelectedFormat(defaultFormat);
setIsDropdownOpen(false);
}, [defaultFormat]);
useEffect(() => {
// Close the dropdown when clicking anywhere outside this control.
const closeOnOutsideClick = (event: MouseEvent) => {
if (!isDropdownOpen) return;
const target = event.target as Node;
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
setIsDropdownOpen(false);
}
};
window.addEventListener('mousedown', closeOnOutsideClick);
return () => {
window.removeEventListener('mousedown', closeOnOutsideClick);
};
}, [isDropdownOpen]);
useEffect(() => {
return () => {
if (copyFeedbackTimerRef.current) {
clearTimeout(copyFeedbackTimerRef.current);
}
};
}, []);
const handleCopyClick = async () => {
if (!copyPayload.trim()) return;
const didCopy = await copyTextToClipboard(copyPayload);
if (!didCopy) return;
setCopied(true);
if (copyFeedbackTimerRef.current) {
clearTimeout(copyFeedbackTimerRef.current);
}
copyFeedbackTimerRef.current = setTimeout(() => {
setCopied(false);
}, COPY_SUCCESS_TIMEOUT_MS);
};
const handleFormatChange = (format: CopyFormat) => {
setSelectedFormat(format);
setIsDropdownOpen(false);
};
const toneClass = messageType === 'user'
? 'text-blue-100 hover:text-white'
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300';
const copyTitle = copied ? t('copyMessage.copied') : t('copyMessage.copy');
const rootClassName = canSelectCopyFormat
? 'relative flex min-w-0 flex-1 items-center gap-0.5 sm:min-w-max sm:flex-none sm:w-auto'
: 'relative flex items-center gap-0.5';
return (
<div ref={dropdownRef} className={rootClassName}>
<button
type="button"
onClick={handleCopyClick}
title={copyTitle}
aria-label={copyTitle}
className={`inline-flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${toneClass}`}
>
{copied ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
)}
<span className="text-[10px] font-semibold uppercase tracking-wide">{selectedFormatTag}</span>
</button>
{canSelectCopyFormat && (
<>
<button
type="button"
onClick={() => setIsDropdownOpen((prev) => !prev)}
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
>
<svg
className={`h-3 w-3 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isDropdownOpen && (
<div className="absolute left-auto top-full z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
{copyFormatOptions.map((option) => {
const isSelected = option.format === selectedFormat;
return (
<button
key={option.format}
type="button"
onClick={() => handleFormatChange(option.format)}
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
}`}
>
<span className="block text-xs font-medium">{option.label}</span>
</button>
);
})}
</div>
)}
</>
)}
</div>
);
};
export default MessageCopyControl;

View File

@@ -1,17 +1,12 @@
import React from "react";
import { Check, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import {
CLAUDE_MODELS,
CURSOR_MODELS,
CODEX_MODELS,
GEMINI_MODELS,
} from "../../../../../shared/modelConstants";
import type { ProjectSession, SessionProvider } from "../../../../types/app";
import { NextTaskBanner } from "../../../task-master";
import React from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants';
import type { ProjectSession, SessionProvider } from '../../../../types/app';
import { NextTaskBanner } from '../../../task-master';
type ProviderSelectionEmptyStateProps = {
interface ProviderSelectionEmptyStateProps {
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: SessionProvider;
@@ -29,7 +24,7 @@ type ProviderSelectionEmptyStateProps = {
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
setInput: React.Dispatch<React.SetStateAction<string>>;
};
}
type ProviderDef = {
id: SessionProvider;
@@ -42,56 +37,50 @@ type ProviderDef = {
const PROVIDERS: ProviderDef[] = [
{
id: "claude",
name: "Claude Code",
infoKey: "providerSelection.providerInfo.anthropic",
accent: "border-primary",
ring: "ring-primary/15",
check: "bg-primary text-primary-foreground",
id: 'claude',
name: 'Claude Code',
infoKey: 'providerSelection.providerInfo.anthropic',
accent: 'border-primary',
ring: 'ring-primary/15',
check: 'bg-primary text-primary-foreground',
},
{
id: "cursor",
name: "Cursor",
infoKey: "providerSelection.providerInfo.cursorEditor",
accent: "border-violet-500 dark:border-violet-400",
ring: "ring-violet-500/15",
check: "bg-violet-500 text-white",
id: 'cursor',
name: 'Cursor',
infoKey: 'providerSelection.providerInfo.cursorEditor',
accent: 'border-violet-500 dark:border-violet-400',
ring: 'ring-violet-500/15',
check: 'bg-violet-500 text-white',
},
{
id: "codex",
name: "Codex",
infoKey: "providerSelection.providerInfo.openai",
accent: "border-emerald-600 dark:border-emerald-400",
ring: "ring-emerald-600/15",
check: "bg-emerald-600 dark:bg-emerald-500 text-white",
id: 'codex',
name: 'Codex',
infoKey: 'providerSelection.providerInfo.openai',
accent: 'border-emerald-600 dark:border-emerald-400',
ring: 'ring-emerald-600/15',
check: 'bg-emerald-600 dark:bg-emerald-500 text-white',
},
{
id: "gemini",
name: "Gemini",
infoKey: "providerSelection.providerInfo.google",
accent: "border-blue-500 dark:border-blue-400",
ring: "ring-blue-500/15",
check: "bg-blue-500 text-white",
id: 'gemini',
name: 'Gemini',
infoKey: 'providerSelection.providerInfo.google',
accent: 'border-blue-500 dark:border-blue-400',
ring: 'ring-blue-500/15',
check: 'bg-blue-500 text-white',
},
];
function getModelConfig(p: SessionProvider) {
if (p === "claude") return CLAUDE_MODELS;
if (p === "codex") return CODEX_MODELS;
if (p === "gemini") return GEMINI_MODELS;
if (p === 'claude') return CLAUDE_MODELS;
if (p === 'codex') return CODEX_MODELS;
if (p === 'gemini') return GEMINI_MODELS;
return CURSOR_MODELS;
}
function getModelValue(
p: SessionProvider,
c: string,
cu: string,
co: string,
g: string,
) {
if (p === "claude") return c;
if (p === "codex") return co;
if (p === "gemini") return g;
function getModelValue(p: SessionProvider, c: string, cu: string, co: string, g: string) {
if (p === 'claude') return c;
if (p === 'codex') return co;
if (p === 'gemini') return g;
return cu;
}
@@ -114,41 +103,24 @@ export default function ProviderSelectionEmptyState({
onShowAllTasks,
setInput,
}: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation("chat");
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
defaultValue: "Start the next task",
});
const { t } = useTranslation('chat');
const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' });
const selectProvider = (next: SessionProvider) => {
setProvider(next);
localStorage.setItem("selected-provider", next);
localStorage.setItem('selected-provider', next);
setTimeout(() => textareaRef.current?.focus(), 100);
};
const handleModelChange = (value: string) => {
if (provider === "claude") {
setClaudeModel(value);
localStorage.setItem("claude-model", value);
} else if (provider === "codex") {
setCodexModel(value);
localStorage.setItem("codex-model", value);
} else if (provider === "gemini") {
setGeminiModel(value);
localStorage.setItem("gemini-model", value);
} else {
setCursorModel(value);
localStorage.setItem("cursor-model", value);
}
if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); }
else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-model', value); }
else if (provider === 'gemini') { setGeminiModel(value); localStorage.setItem('gemini-model', value); }
else { setCursorModel(value); localStorage.setItem('cursor-model', value); }
};
const modelConfig = getModelConfig(provider);
const currentModel = getModelValue(
provider,
claudeModel,
cursorModel,
codexModel,
geminiModel,
);
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel, geminiModel);
/* ── New session — provider picker ── */
if (!selectedSession && !currentSessionId) {
@@ -158,10 +130,10 @@ export default function ProviderSelectionEmptyState({
{/* Heading */}
<div className="mb-8 text-center">
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
{t("providerSelection.title")}
{t('providerSelection.title')}
</h2>
<p className="mt-1 text-[13px] text-muted-foreground">
{t("providerSelection.description")}
{t('providerSelection.description')}
</p>
</div>
@@ -177,30 +149,23 @@ export default function ProviderSelectionEmptyState({
relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2
pb-4 pt-5 transition-all duration-150
active:scale-[0.97]
${
active
? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
: "border-border bg-card/60 hover:border-border/80 hover:bg-card"
${active
? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
: 'border-border bg-card/60 hover:border-border/80 hover:bg-card'
}
`}
>
<SessionProviderLogo
provider={p.id}
className={`h-9 w-9 transition-transform duration-150 ${active ? "scale-110" : ""}`}
className={`h-9 w-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`}
/>
<div className="text-center">
<p className="text-[13px] font-semibold leading-none text-foreground">
{p.name}
</p>
<p className="mt-1 text-[10px] leading-tight text-muted-foreground">
{t(p.infoKey)}
</p>
<p className="text-[13px] font-semibold leading-none text-foreground">{p.name}</p>
<p className="mt-1 text-[10px] leading-tight text-muted-foreground">{t(p.infoKey)}</p>
</div>
{/* Check badge */}
{active && (
<div
className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}
>
<div className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}>
<Check className="h-2.5 w-2.5" strokeWidth={3} />
</div>
)}
@@ -210,13 +175,9 @@ export default function ProviderSelectionEmptyState({
</div>
{/* Model picker — appears after provider is chosen */}
<div
className={`transition-all duration-200 ${provider ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1 opacity-0"}`}
>
<div className={`transition-all duration-200 ${provider ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'}`}>
<div className="mb-5 flex items-center justify-center gap-2">
<span className="text-sm text-muted-foreground">
{t("providerSelection.selectModel")}
</span>
<span className="text-sm text-muted-foreground">{t('providerSelection.selectModel')}</span>
<div className="relative">
<select
value={currentModel}
@@ -224,13 +185,9 @@ export default function ProviderSelectionEmptyState({
tabIndex={-1}
className="cursor-pointer appearance-none rounded-lg border border-border/60 bg-muted/50 py-1.5 pl-3 pr-7 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{modelConfig.OPTIONS.map(
({ value, label }: { value: string; label: string }) => (
<option key={value + label} value={value}>
{label}
</option>
),
)}
{modelConfig.OPTIONS.map(({ value, label }: { value: string; label: string }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
</div>
@@ -239,18 +196,10 @@ export default function ProviderSelectionEmptyState({
<p className="text-center text-sm text-muted-foreground/70">
{
{
claude: t("providerSelection.readyPrompt.claude", {
model: claudeModel,
}),
cursor: t("providerSelection.readyPrompt.cursor", {
model: cursorModel,
}),
codex: t("providerSelection.readyPrompt.codex", {
model: codexModel,
}),
gemini: t("providerSelection.readyPrompt.gemini", {
model: geminiModel,
}),
claude: t('providerSelection.readyPrompt.claude', { model: claudeModel }),
cursor: t('providerSelection.readyPrompt.cursor', { model: cursorModel }),
codex: t('providerSelection.readyPrompt.codex', { model: codexModel }),
gemini: t('providerSelection.readyPrompt.gemini', { model: geminiModel }),
}[provider]
}
</p>
@@ -259,10 +208,7 @@ export default function ProviderSelectionEmptyState({
{/* Task banner */}
{provider && tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner
onStartTask={() => setInput(nextTaskPrompt)}
onShowAllTasks={onShowAllTasks}
/>
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
</div>
)}
</div>
@@ -275,19 +221,12 @@ export default function ProviderSelectionEmptyState({
return (
<div className="flex h-full items-center justify-center">
<div className="max-w-md px-6 text-center">
<p className="mb-1.5 text-lg font-semibold text-foreground">
{t("session.continue.title")}
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
{t("session.continue.description")}
</p>
<p className="mb-1.5 text-lg font-semibold text-foreground">{t('session.continue.title')}</p>
<p className="text-sm leading-relaxed text-muted-foreground">{t('session.continue.description')}</p>
{tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner
onStartTask={() => setInput(nextTaskPrompt)}
onShowAllTasks={onShowAllTasks}
/>
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
</div>
)}
</div>

View File

@@ -107,7 +107,6 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
{activeView === 'changes' && (
<ChangesView
key={selectedProject.fullPath}
isMobile={isMobile}
projectPath={selectedProject.fullPath}
gitStatus={gitStatus}

View File

@@ -3,7 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../file-tree/view/FileTree';
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import GitPanel from '../../git-panel/view/GitPanel';
import PluginTabContent from '../../plugins/view/PluginTabContent';
import PluginTabContent from '../../plugins/PluginTabContent';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from '../../../../shared/view/ui';
import type { AppTab } from '../../../../types/app';
import { usePlugins } from '../../../../contexts/PluginsContext';
import PluginIcon from '../../../plugins/view/PluginIcon';
import PluginIcon from '../../../plugins/PluginIcon';
type MainContentTabSwitcherProps = {
activeTab: AppTab;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { authenticatedFetch } from '../../utils/api';
type Props = {
pluginName: string;

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
import { usePlugins } from '../../../contexts/PluginsContext';
import type { Plugin } from '../../../contexts/PluginsContext';
import { usePlugins } from '../../contexts/PluginsContext';
import type { Plugin } from '../../contexts/PluginsContext';
import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
@@ -312,13 +312,8 @@ export default function PluginSettingsTab() {
setConfirmUninstall(name);
return;
}
const result = await uninstallPlugin(name);
if (result.success) {
setConfirmUninstall(null);
} else {
setInstallError(result.error || 'Uninstall failed');
setConfirmUninstall(null);
}
await uninstallPlugin(name);
setConfirmUninstall(null);
};
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
@@ -355,7 +350,6 @@ export default function PluginSettingsTab() {
setInstallError(null);
}}
placeholder="https://github.com/user/my-plugin"
aria-label="Plugin git repository URL"
className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
onKeyDown={(e) => {
if (e.key === 'Enter') void handleInstall();
@@ -405,7 +399,7 @@ export default function PluginSettingsTab() {
key={plugin.name}
plugin={plugin}
index={index}
onToggle={(enabled) => void togglePlugin(plugin.name, enabled).then(r => { if (!r.success) setInstallError(r.error || 'Toggle failed'); })}
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)}
onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugins.has(plugin.name)}

View File

@@ -1,8 +1,8 @@
import { useEffect, useRef } from 'react';
import { useTheme } from '../../../contexts/ThemeContext';
import { authenticatedFetch } from '../../../utils/api';
import { usePlugins } from '../../../contexts/PluginsContext';
import type { Project, ProjectSession } from '../../../types/app';
import { useTheme } from '../../contexts/ThemeContext';
import { authenticatedFetch } from '../../utils/api';
import { usePlugins } from '../../contexts/PluginsContext';
import type { Project, ProjectSession } from '../../types/app';
type PluginTabContentProps = {
pluginName: string;
@@ -24,16 +24,10 @@ function buildContext(
return {
theme: isDarkMode ? 'dark' : 'light',
project: selectedProject
? {
name: selectedProject.name,
path: selectedProject.fullPath || selectedProject.path || '',
}
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path }
: null,
session: selectedSession
? {
id: selectedSession.id,
title: selectedSession.title || selectedSession.name || selectedSession.id,
}
? { id: selectedSession.id, title: selectedSession.title }
: null,
};
}
@@ -50,7 +44,7 @@ export default function PluginTabContent({
// Stable refs so effects don't need context values in their dep arrays
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
const moduleRef = useRef<any>(null);
const plugin = plugins.find(p => p.name === pluginName);
@@ -71,13 +65,12 @@ export default function PluginTabContent({
let active = true;
const container = containerRef.current;
const entryFile = plugin?.entry ?? 'index.js';
const contextCallbacks = contextCallbacksRef.current;
(async () => {
try {
// Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).
// Then import it via a Blob URL so the browser never makes an unauthenticated request.
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(entryFile)}`;
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${entryFile}`;
const res = await authenticatedFetch(assetUrl);
if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
const jsText = await res.text();
@@ -93,8 +86,8 @@ export default function PluginTabContent({
get context(): PluginContext { return contextRef.current; },
onContextChange(cb: (ctx: PluginContext) => void): () => void {
contextCallbacks.add(cb);
return () => contextCallbacks.delete(cb);
contextCallbacksRef.current.add(cb);
return () => contextCallbacksRef.current.delete(cb);
},
async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
@@ -121,10 +114,7 @@ export default function PluginTabContent({
if (!active) return;
console.error(`[Plugin:${pluginName}] Failed to load:`, err);
if (containerRef.current) {
const errDiv = document.createElement('div');
errDiv.style.cssText = 'padding:16px;font-size:13px;color:#dc2626';
errDiv.textContent = `Plugin failed to load: ${String(err)}`;
containerRef.current.replaceChildren(errDiv);
containerRef.current.innerHTML = `<div style="padding:16px;font-size:13px;color:#dc2626">Plugin failed to load: ${String(err)}</div>`;
}
}
})();
@@ -132,7 +122,7 @@ export default function PluginTabContent({
return () => {
active = false;
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
contextCallbacks.clear();
contextCallbacksRef.current.clear();
moduleRef.current = null;
};
}, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes

View File

@@ -10,7 +10,7 @@ import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
import PluginSettingsTab from '../../plugins/PluginSettingsTab';
import { useSettingsController } from '../hooks/useSettingsController';
import type { SettingsProps } from '../types/types';

View File

@@ -49,15 +49,6 @@ import jaCodeEditor from './locales/ja/codeEditor.json';
// eslint-disable-next-line import-x/order
import jaTasks from './locales/ja/tasks.json';
import ruCommon from './locales/ru/common.json';
import ruSettings from './locales/ru/settings.json';
import ruAuth from './locales/ru/auth.json';
import ruSidebar from './locales/ru/sidebar.json';
import ruChat from './locales/ru/chat.json';
import ruCodeEditor from './locales/ru/codeEditor.json';
// eslint-disable-next-line import-x/order
import ruTasks from './locales/ru/tasks.json';
// Import supported languages configuration
import { languages } from './languages.js';
@@ -116,15 +107,6 @@ i18n
codeEditor: jaCodeEditor,
tasks: jaTasks,
},
ru: {
common: ruCommon,
settings: ruSettings,
auth: ruAuth,
sidebar: ruSidebar,
chat: ruChat,
codeEditor: ruCodeEditor,
tasks: ruTasks,
},
},
// Default language

View File

@@ -29,11 +29,6 @@ export const languages = [
label: 'Japanese',
nativeName: '日本語',
},
{
value: 'ru',
label: 'Russian',
nativeName: 'Русский',
},
];
/**

View File

@@ -6,10 +6,7 @@
},
"copyMessage": {
"copy": "Copy message",
"copied": "Message copied",
"selectFormat": "Select copy format",
"copyAsMarkdown": "Copy as markdown",
"copyAsText": "Copy as text"
"copied": "Message copied"
},
"messageTypes": {
"user": "U",

View File

@@ -6,10 +6,7 @@
},
"copyMessage": {
"copy": "メッセージをコピー",
"copied": "メッセージをコピーしました",
"selectFormat": "コピー形式を選択",
"copyAsMarkdown": "Markdownとしてコピー",
"copyAsText": "テキストとしてコピー"
"copied": "メッセージをコピーしました"
},
"messageTypes": {
"user": "U",

View File

@@ -6,10 +6,7 @@
},
"copyMessage": {
"copy": "메시지 복사",
"copied": "메시지 복사됨",
"selectFormat": "복사 형식 선택",
"copyAsMarkdown": "마크다운으로 복사",
"copyAsText": "텍스트로 복사"
"copied": "메시지 복사됨"
},
"messageTypes": {
"user": "U",

View File

@@ -1,37 +0,0 @@
{
"login": {
"title": "Добро пожаловать",
"description": "Войдите в свой аккаунт Claude Code UI",
"username": "Имя пользователя",
"password": "Пароль",
"submit": "Войти",
"loading": "Вход...",
"errors": {
"invalidCredentials": "Неверное имя пользователя или пароль",
"requiredFields": "Пожалуйста, заполните все поля",
"networkError": "Ошибка сети. Попробуйте снова."
},
"placeholders": {
"username": "Введите имя пользователя",
"password": "Введите пароль"
}
},
"register": {
"title": "Создать аккаунт",
"username": "Имя пользователя",
"password": "Пароль",
"confirmPassword": "Подтвердите пароль",
"submit": "Создать аккаунт",
"loading": "Создание аккаунта...",
"errors": {
"passwordMismatch": "Пароли не совпадают",
"usernameTaken": "Имя пользователя уже занято",
"weakPassword": "Пароль слишком слабый"
}
},
"logout": {
"title": "Выйти",
"confirm": "Вы уверены, что хотите выйти?",
"button": "Выйти"
}
}

View File

@@ -1,272 +0,0 @@
{
"codeBlock": {
"copy": "Копировать",
"copied": "Скопировано",
"copyCode": "Копировать код"
},
"copyMessage": {
"copy": "Копировать сообщение",
"copied": "Сообщение скопировано",
"selectFormat": "Выбрать формат копирования",
"copyAsMarkdown": "Копировать как Markdown",
"copyAsText": "Копировать как текст"
},
"messageTypes": {
"user": "П",
"error": "Ошибка",
"tool": "Инструмент",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
"gemini": "Gemini"
},
"tools": {
"settings": "Настройки инструмента",
"error": "Ошибка инструмента",
"result": "Результат инструмента",
"viewParams": "Просмотр входных параметров",
"viewRawParams": "Просмотр сырых параметров",
"viewDiff": "Просмотр различий редактирования для",
"creatingFile": "Создание нового файла:",
"updatingTodo": "Обновление списка задач",
"read": "Чтение",
"readFile": "Чтение файла",
"updateTodo": "Обновить список задач",
"readTodo": "Прочитать список задач",
"searchResults": "результаты"
},
"search": {
"found": "Найдено {{count}} {{type}}",
"file": "файл",
"files": "файлов",
"pattern": "шаблон:",
"in": "в:"
},
"fileOperations": {
"updated": "Файл успешно обновлен",
"created": "Файл успешно создан",
"written": "Файл успешно записан",
"diff": "Различия",
"newFile": "Новый файл",
"viewContent": "Просмотр содержимого файла",
"viewFullOutput": "Просмотр полного вывода ({{count}} символов)",
"contentDisplayed": "Содержимое файла отображено в представлении различий выше"
},
"interactive": {
"title": "Интерактивный запрос",
"waiting": "Ожидание вашего ответа в CLI",
"instruction": "Пожалуйста, выберите опцию в терминале, где запущен Claude.",
"selectedOption": "✓ Claude выбрал опцию {{number}}",
"instructionDetail": "В CLI вы бы выбрали эту опцию интерактивно, используя клавиши со стрелками или введя номер."
},
"thinking": {
"title": "Думаю...",
"emoji": "💭 Думаю..."
},
"json": {
"response": "JSON ответ"
},
"permissions": {
"grant": "Предоставить разрешение для {{tool}}",
"added": "Разрешение добавлено",
"addTo": "Добавляет {{entry}} в разрешенные инструменты.",
"retry": "Разрешение сохранено. Повторите запрос для использования инструмента.",
"error": "Не удалось обновить разрешения. Попробуйте снова.",
"openSettings": "Открыть настройки"
},
"todo": {
"updated": "Список задач успешно обновлен",
"current": "Текущий список задач"
},
"plan": {
"viewPlan": "📋 Просмотр плана реализации",
"title": "План реализации"
},
"usageLimit": {
"resetAt": "Достигнут лимит использования Claude. Ваш лимит будет сброшен в **{{time}} {{timezone}}** - {{date}}"
},
"codex": {
"permissionMode": "Режим разрешений",
"modes": {
"default": "Режим по умолчанию",
"acceptEdits": "Принимать правки",
"bypassPermissions": "Обход разрешений",
"plan": "Режим планирования"
},
"descriptions": {
"default": "Только доверенные команды (ls, cat, grep, git status и т.д.) выполняются автоматически. Другие команды пропускаются. Может записывать в рабочее пространство.",
"acceptEdits": "Все команды выполняются автоматически в рабочем пространстве. Полный автоматический режим с изолированным выполнением.",
"bypassPermissions": "Полный системный доступ без ограничений. Все команды выполняются автоматически с полным доступом к диску и сети. Используйте с осторожностью.",
"plan": "Режим планирования - команды не выполняются"
},
"technicalDetails": "Технические детали"
},
"gemini": {
"permissionMode": "Режим разрешений Gemini",
"description": "Управление тем, как Gemini CLI обрабатывает подтверждения операций.",
"modes": {
"default": {
"title": "Стандартный (запрашивать подтверждение)",
"description": "Gemini будет запрашивать подтверждение перед выполнением команд, записью файлов и получением веб-ресурсов."
},
"autoEdit": {
"title": "Автоматическое редактирование (пропускать подтверждения файлов)",
"description": "Gemini будет автоматически подтверждать редактирование файлов и веб-запросы, но все еще будет запрашивать подтверждение для команд оболочки."
},
"yolo": {
"title": "YOLO (обход всех разрешений)",
"description": "Gemini будет выполнять все операции без запроса подтверждения. Будьте осторожны."
}
}
},
"input": {
"placeholder": "Введите / для команд, @ для файлов, или спросите {{provider}} что угодно...",
"placeholderDefault": "Введите ваше сообщение...",
"disabled": "Ввод отключен",
"attachFiles": "Прикрепить файлы",
"attachImages": "Прикрепить изображения",
"send": "Отправить",
"stop": "Остановить",
"hintText": {
"ctrlEnter": "Ctrl+Enter для отправки • Shift+Enter для новой строки • Tab для смены режима • / для команд",
"enter": "Enter для отправки • Shift+Enter для новой строки • Tab для смены режима • / для команд"
},
"clickToChangeMode": "Нажмите для смены режима разрешений (или нажмите Tab в поле ввода)",
"showAllCommands": "Показать все команды",
"clearInput": "Очистить ввод",
"scrollToBottom": "Прокрутить вниз"
},
"thinkingMode": {
"selector": {
"title": "Режим размышления",
"description": "Расширенное размышление дает Claude больше времени для оценки альтернатив",
"active": "Активен",
"tip": "Более высокие режимы размышления занимают больше времени, но обеспечивают более тщательный анализ"
},
"modes": {
"none": {
"name": "Стандартный",
"description": "Обычный ответ Claude",
"prefix": ""
},
"think": {
"name": "Думать",
"description": "Базовое расширенное размышление",
"prefix": "думать"
},
"thinkHard": {
"name": "Думать усердно",
"description": "Более тщательная оценка",
"prefix": "думать усердно"
},
"thinkHarder": {
"name": "Думать еще усерднее",
"description": "Глубокий анализ с альтернативами",
"prefix": "думать еще усерднее"
},
"ultrathink": {
"name": "Ультра-размышление",
"description": "Максимальный бюджет размышления",
"prefix": "ультра-размышление"
}
},
"buttonTitle": "Режим размышления: {{mode}}"
},
"providerSelection": {
"title": "Выберите вашего AI-ассистента",
"description": "Выберите провайдера для начала нового разговора",
"selectModel": "Выбрать модель",
"providerInfo": {
"anthropic": "от Anthropic",
"openai": "от OpenAI",
"cursorEditor": "AI редактор кода",
"google": "от Google"
},
"readyPrompt": {
"claude": "Готов использовать Claude с {{model}}. Начните вводить сообщение ниже.",
"cursor": "Готов использовать Cursor с {{model}}. Начните вводить сообщение ниже.",
"codex": "Готов использовать Codex с {{model}}. Начните вводить сообщение ниже.",
"gemini": "Готов использовать Gemini с {{model}}. Начните вводить сообщение ниже.",
"default": "Выберите провайдера выше для начала"
}
},
"session": {
"continue": {
"title": "Продолжить разговор",
"description": "Задавайте вопросы о вашем коде, запрашивайте изменения или получайте помощь с задачами разработки"
},
"loading": {
"olderMessages": "Загрузка старых сообщений...",
"sessionMessages": "Загрузка сообщений сеанса..."
},
"messages": {
"showingOf": "Показано {{shown}} из {{total}} сообщений",
"scrollToLoad": "Прокрутите вверх для загрузки еще",
"showingLast": "Показаны последние {{count}} сообщений (всего {{total}})",
"loadEarlier": "Загрузить более ранние сообщения",
"loadAll": "Загрузить все сообщения",
"loadingAll": "Загрузка всех сообщений...",
"allLoaded": "Все сообщения загружены",
"perfWarning": "Все сообщения загружены — прокрутка может быть медленнее. Нажмите \"Прокрутить вниз\" для восстановления производительности."
}
},
"shell": {
"selectProject": {
"title": "Выберите проект",
"description": "Выберите проект для открытия интерактивной оболочки в этом каталоге"
},
"status": {
"newSession": "Новый сеанс",
"initializing": "Инициализация...",
"restarting": "Перезапуск..."
},
"actions": {
"disconnect": "Отключиться",
"disconnectTitle": "Отключиться от оболочки",
"restart": "Перезапустить",
"restartTitle": "Перезапустить оболочку (сначала отключитесь)",
"connect": "Продолжить в оболочке",
"connectTitle": "Подключиться к оболочке"
},
"loading": "Загрузка терминала...",
"connecting": "Подключение к оболочке...",
"startSession": "Начать новый сеанс Claude",
"resumeSession": "Возобновить сеанс: {{displayName}}...",
"runCommand": "Выполнить {{command}} в {{projectName}}",
"startCli": "Запуск Claude CLI в {{projectName}}",
"defaultCommand": "команда"
},
"claudeStatus": {
"actions": {
"thinking": "Думает",
"processing": "Обрабатывает",
"analyzing": "Анализирует",
"working": "Работает",
"computing": "Вычисляет",
"reasoning": "Рассуждает"
},
"state": {
"live": "В сети",
"paused": "Приостановлен"
},
"elapsed": {
"seconds": "{{count}}с",
"minutesSeconds": "{{minutes}}м {{seconds}}с",
"label": "Прошло {{time}}",
"startingNow": "Начинается сейчас"
},
"controls": {
"stopGeneration": "Остановить генерацию",
"pressEscToStop": "Нажмите Esc в любое время для остановки"
},
"providers": {
"assistant": "Ассистент"
}
},
"projectSelection": {
"startChatWithProvider": "Выберите проект для начала чата с {{provider}}"
},
"tasks": {
"nextTaskPrompt": "Начать следующую задачу"
}
}

View File

@@ -1,36 +0,0 @@
{
"toolbar": {
"changes": "изменения",
"previousChange": "Предыдущее изменение",
"nextChange": "Следующее изменение",
"hideDiff": "Скрыть подсветку различий",
"showDiff": "Показать подсветку различий",
"settings": "Настройки редактора",
"collapse": "Свернуть редактор",
"expand": "Развернуть редактор на всю ширину"
},
"loading": "Загрузка {{fileName}}...",
"header": {
"showingChanges": "Показаны изменения"
},
"actions": {
"download": "Скачать файл",
"save": "Сохранить",
"saving": "Сохранение...",
"saved": "Сохранено!",
"exitFullscreen": "Выйти из полноэкранного режима",
"fullscreen": "Полноэкранный режим",
"close": "Закрыть",
"previewMarkdown": "Предпросмотр markdown",
"editMarkdown": "Редактировать markdown"
},
"footer": {
"lines": "Строк:",
"characters": "Символов:",
"shortcuts": "Нажмите Ctrl+S для сохранения • Esc для закрытия"
},
"binaryFile": {
"title": "Бинарный файл",
"message": "Файл \"{{fileName}}\" не может быть отображен в текстовом редакторе, так как это бинарный файл."
}
}

View File

@@ -1,238 +0,0 @@
{
"buttons": {
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
"create": "Создать",
"edit": "Редактировать",
"close": "Закрыть",
"confirm": "Подтвердить",
"submit": "Отправить",
"retry": "Повторить",
"refresh": "Обновить",
"search": "Поиск",
"clear": "Очистить",
"copy": "Копировать",
"download": "Скачать",
"upload": "Загрузить",
"browse": "Обзор"
},
"tabs": {
"chat": "Чат",
"shell": "Терминал",
"files": "Файлы",
"git": "Система контроля версий",
"tasks": "Задачи"
},
"status": {
"loading": "Загрузка...",
"success": "Успешно",
"error": "Ошибка",
"failed": "Не удалось",
"pending": "Ожидание",
"completed": "Завершено",
"inProgress": "В процессе"
},
"messages": {
"savedSuccessfully": "Успешно сохранено",
"deletedSuccessfully": "Успешно удалено",
"updatedSuccessfully": "Успешно обновлено",
"operationFailed": "Операция не удалась",
"networkError": "Ошибка сети. Проверьте подключение.",
"unauthorized": "Не авторизован. Пожалуйста, войдите.",
"notFound": "Не найдено",
"invalidInput": "Неверный ввод",
"requiredField": "Это поле обязательно",
"unknownError": "Произошла неизвестная ошибка"
},
"navigation": {
"settings": "Настройки",
"home": "Главная",
"back": "Назад",
"next": "Далее",
"previous": "Предыдущий",
"logout": "Выйти"
},
"common": {
"language": "Язык",
"theme": "Тема",
"darkMode": "Темная тема",
"lightMode": "Светлая тема",
"name": "Имя",
"description": "Описание",
"enabled": "Включено",
"disabled": "Отключено",
"optional": "Необязательно",
"version": "Версия",
"select": "Выбрать",
"selectAll": "Выбрать все",
"deselectAll": "Снять выделение"
},
"time": {
"justNow": "Только что",
"minutesAgo": "{{count}} мин. назад",
"hoursAgo": "{{count}} ч. назад",
"daysAgo": "{{count}} дн. назад",
"yesterday": "Вчера"
},
"fileOperations": {
"newFile": "Новый файл",
"newFolder": "Новая папка",
"rename": "Переименовать",
"move": "Переместить",
"copyPath": "Копировать путь",
"openInEditor": "Открыть в редакторе"
},
"mainContent": {
"loading": "Загрузка Claude Code UI",
"settingUpWorkspace": "Настройка рабочего пространства...",
"chooseProject": "Выберите проект",
"selectProjectDescription": "Выберите проект на боковой панели, чтобы начать работу с Claude. Каждый проект содержит ваши сеансы чата и историю файлов.",
"tip": "Совет",
"createProjectMobile": "Нажмите кнопку меню выше для доступа к проектам",
"createProjectDesktop": "Создайте новый проект, нажав на значок папки на боковой панели",
"newSession": "Новый сеанс",
"untitledSession": "Безымянный сеанс",
"projectFiles": "Файлы проекта"
},
"fileTree": {
"loading": "Загрузка файлов...",
"files": "Файлы",
"simpleView": "Простой вид",
"compactView": "Компактный вид",
"detailedView": "Подробный вид",
"searchPlaceholder": "Поиск файлов и папок...",
"clearSearch": "Очистить поиск",
"name": "Имя",
"size": "Размер",
"modified": "Изменено",
"permissions": "Права доступа",
"noFilesFound": "Файлы не найдены",
"checkProjectPath": "Проверьте доступность пути к проекту",
"noMatchesFound": "Совпадений не найдено",
"tryDifferentSearch": "Попробуйте другой поисковый запрос или очистите поиск",
"justNow": "только что",
"minAgo": "{{count}} мин. назад",
"hoursAgo": "{{count}} ч. назад",
"daysAgo": "{{count}} дн. назад",
"newFile": "Новый файл (Cmd+N)",
"newFolder": "Новая папка (Cmd+Shift+N)",
"refresh": "Обновить",
"collapseAll": "Свернуть все",
"context": {
"rename": "Переименовать",
"delete": "Удалить",
"copyPath": "Копировать путь",
"download": "Скачать",
"newFile": "Новый файл",
"newFolder": "Новая папка",
"refresh": "Обновить",
"menuLabel": "Контекстное меню файла",
"loading": "Загрузка..."
}
},
"projectWizard": {
"title": "Создать новый проект",
"steps": {
"type": "Тип",
"configure": "Настройка",
"confirm": "Подтверждение"
},
"step1": {
"question": "У вас уже есть рабочее пространство или вы хотите создать новое?",
"existing": {
"title": "Существующее рабочее пространство",
"description": "У меня уже есть рабочее пространство на сервере, нужно только добавить его в список проектов"
},
"new": {
"title": "Новое рабочее пространство",
"description": "Создать новое рабочее пространство, опционально клонировать из репозитория GitHub"
}
},
"step2": {
"existingPath": "Путь к рабочему пространству",
"newPath": "Путь к рабочему пространству",
"existingPlaceholder": "/путь/к/существующему/пространству",
"newPlaceholder": "/путь/к/новому/пространству",
"existingHelp": "Полный путь к каталогу вашего рабочего пространства",
"newHelp": "Полный путь к каталогу вашего рабочего пространства",
"githubUrl": "URL GitHub (необязательно)",
"githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "Необязательно: укажите URL GitHub для клонирования репозитория",
"githubAuth": "Аутентификация GitHub (необязательно)",
"githubAuthHelp": "Требуется только для приватных репозиториев. Публичные репозитории можно клонировать без аутентификации.",
"loadingTokens": "Загрузка сохраненных токенов...",
"storedToken": "Сохраненный токен",
"newToken": "Новый токен",
"nonePublic": "Нет (публичный)",
"selectToken": "Выбрать токен",
"selectTokenPlaceholder": "-- Выберите токен --",
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tokenHelp": "Этот токен будет использован только для этой операции",
"publicRepoInfo": "Публичные репозитории не требуют аутентификации. Вы можете пропустить токен при клонировании публичного репозитория.",
"noTokensHelp": "Нет доступных сохраненных токенов. Вы можете добавить токены в Настройки → API ключи для удобного повторного использования.",
"optionalTokenPublic": "Токен GitHub (необязательно для публичных репозиториев)",
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (оставьте пустым для публичных репозиториев)"
},
"step3": {
"reviewConfig": "Проверьте вашу конфигурацию",
"workspaceType": "Тип рабочего пространства:",
"existingWorkspace": "Существующее рабочее пространство",
"newWorkspace": "Новое рабочее пространство",
"path": "Путь:",
"cloneFrom": "Клонировать из:",
"authentication": "Аутентификация:",
"usingStoredToken": "Использование сохраненного токена:",
"usingProvidedToken": "Использование предоставленного токена",
"noAuthentication": "Без аутентификации",
"sshKey": "SSH ключ",
"existingInfo": "Рабочее пространство будет добавлено в список проектов и будет доступно для сеансов Claude/Cursor.",
"newWithClone": "Репозиторий будет клонирован в эту папку.",
"newEmpty": "Рабочее пространство будет добавлено в список проектов и будет доступно для сеансов Claude/Cursor.",
"cloningRepository": "Клонирование репозитория..."
},
"buttons": {
"cancel": "Отмена",
"back": "Назад",
"next": "Далее",
"createProject": "Создать проект",
"creating": "Создание...",
"cloning": "Клонирование..."
},
"errors": {
"selectType": "Пожалуйста, выберите, есть ли у вас существующее рабочее пространство или вы хотите создать новое",
"providePath": "Пожалуйста, укажите путь к рабочему пространству",
"failedToCreate": "Не удалось создать рабочее пространство",
"failedToCreateFolder": "Не удалось создать папку"
}
},
"versionUpdate": {
"title": "Доступно обновление",
"newVersionReady": "Новая версия готова",
"currentVersion": "Текущая версия",
"latestVersion": "Последняя версия",
"whatsNew": "Что нового:",
"viewFullRelease": "Посмотреть полный релиз",
"updateProgress": "Прогресс обновления:",
"manualUpgrade": "Ручное обновление:",
"npmUpgradeCommand": "npm install -g @siteboon/claude-code-ui@latest",
"manualUpgradeHint": "Или нажмите \"Обновить сейчас\" для автоматического обновления.",
"updateCompleted": "Обновление успешно завершено!",
"restartServer": "Пожалуйста, перезапустите сервер для применения изменений.",
"updateFailed": "Обновление не удалось",
"buttons": {
"close": "Закрыть",
"later": "Позже",
"copyCommand": "Копировать команду",
"updateNow": "Обновить сейчас",
"updating": "Обновление..."
},
"ariaLabels": {
"closeModal": "Закрыть модальное окно обновления версии",
"showSidebar": "Показать боковую панель",
"settings": "Настройки",
"updateAvailable": "Доступно обновление",
"closeSidebar": "Закрыть боковую панель"
}
}
}

View File

@@ -1,434 +0,0 @@
{
"title": "Настройки",
"tabs": {
"account": "Аккаунт",
"permissions": "Разрешения",
"mcpServers": "MCP серверы",
"appearance": "Внешний вид"
},
"account": {
"title": "Аккаунт",
"language": "Язык",
"languageLabel": "Язык интерфейса",
"languageDescription": "Выберите предпочитаемый язык для интерфейса",
"username": "Имя пользователя",
"email": "Email",
"profile": "Профиль",
"changePassword": "Изменить пароль"
},
"mcp": {
"title": "MCP серверы",
"addServer": "Добавить сервер",
"editServer": "Редактировать сервер",
"deleteServer": "Удалить сервер",
"serverName": "Имя сервера",
"serverType": "Тип сервера",
"config": "Конфигурация",
"testConnection": "Проверить подключение",
"status": "Статус",
"connected": "Подключен",
"disconnected": "Отключен",
"scope": {
"label": "Область",
"user": "Пользователь",
"project": "Проект"
}
},
"appearance": {
"title": "Внешний вид",
"theme": "Тема",
"codeEditor": "Редактор кода",
"editorTheme": "Тема редактора",
"wordWrap": "Перенос слов",
"showMinimap": "Показать миникарту",
"lineNumbers": "Номера строк",
"fontSize": "Размер шрифта"
},
"actions": {
"saveChanges": "Сохранить изменения",
"resetToDefaults": "Сбросить к значениям по умолчанию",
"cancelChanges": "Отменить изменения"
},
"quickSettings": {
"title": "Быстрые настройки",
"sections": {
"appearance": "Внешний вид",
"toolDisplay": "Отображение инструментов",
"viewOptions": "Параметры просмотра",
"inputSettings": "Настройки ввода",
"whisperDictation": "Диктовка Whisper"
},
"darkMode": "Темная тема",
"autoExpandTools": "Автоматически разворачивать инструменты",
"showRawParameters": "Показывать сырые параметры",
"showThinking": "Показывать размышления",
"autoScrollToBottom": "Автопрокрутка вниз",
"sendByCtrlEnter": "Отправка по Ctrl+Enter",
"sendByCtrlEnterDescription": "Когда включено, нажатие Ctrl+Enter будет отправлять сообщение вместо просто Enter. Это полезно для пользователей IME, чтобы избежать случайной отправки.",
"dragHandle": {
"dragging": "Перетаскивание ручки",
"closePanel": "Закрыть панель настроек",
"openPanel": "Открыть панель настроек",
"draggingStatus": "Перетаскивание...",
"toggleAndMove": "Нажмите для переключения, перетащите для перемещения"
},
"whisper": {
"modes": {
"default": "Режим по умолчанию",
"defaultDescription": "Прямая транскрипция вашей речи",
"prompt": "Улучшение запроса",
"promptDescription": "Преобразование грубых идей в четкие, детальные AI-запросы",
"vibe": "Режим Vibe",
"vibeDescription": "Форматирование идей как четких инструкций агента с деталями"
}
}
},
"terminalShortcuts": {
"title": "Горячие клавиши терминала",
"sectionKeys": "Клавиши",
"sectionNavigation": "Навигация",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "Стрелка вверх",
"arrowDown": "Стрелка вниз",
"scrollDown": "Прокрутка вниз",
"handle": {
"closePanel": "Закрыть панель горячих клавиш",
"openPanel": "Открыть панель горячих клавиш"
}
},
"mainTabs": {
"label": "Настройки",
"agents": "Агенты",
"appearance": "Внешний вид",
"git": "Git",
"apiTokens": "API и токены",
"tasks": "Задачи"
},
"appearanceSettings": {
"darkMode": {
"label": "Темная тема",
"description": "Переключение между светлой и темной темами"
},
"projectSorting": {
"label": "Сортировка проектов",
"description": "Как проекты упорядочены на боковой панели",
"alphabetical": "По алфавиту",
"recentActivity": "По недавней активности"
},
"codeEditor": {
"title": "Редактор кода",
"theme": {
"label": "Тема редактора",
"description": "Тема по умолчанию для редактора кода"
},
"wordWrap": {
"label": "Перенос слов",
"description": "Включить перенос слов по умолчанию в редакторе"
},
"showMinimap": {
"label": "Показать миникарту",
"description": "Отображать миникарту для упрощения навигации в представлении различий"
},
"lineNumbers": {
"label": "Показать номера строк",
"description": "Отображать номера строк в редакторе"
},
"fontSize": {
"label": "Размер шрифта",
"description": "Размер шрифта редактора в пикселях"
}
}
},
"mcpForm": {
"title": {
"add": "Добавить MCP сервер",
"edit": "Редактировать MCP сервер"
},
"importMode": {
"form": "Ввод формы",
"json": "Импорт JSON"
},
"scope": {
"label": "Область",
"userGlobal": "Пользователь (глобально)",
"projectLocal": "Проект (локально)",
"userDescription": "Область пользователя: доступно во всех проектах на вашей машине",
"projectDescription": "Локальная область: доступно только в выбранном проекте",
"cannotChange": "Область не может быть изменена при редактировании существующего сервера"
},
"fields": {
"serverName": "Имя сервера",
"transportType": "Тип транспорта",
"command": "Команда",
"arguments": "Аргументы (по одному на строку)",
"jsonConfig": "JSON конфигурация",
"url": "URL",
"envVars": "Переменные окружения (КЛЮЧ=значение, по одной на строку)",
"headers": "Заголовки (КЛЮЧ=значение, по одному на строку)",
"selectProject": "Выберите проект..."
},
"placeholders": {
"serverName": "мой-сервер"
},
"validation": {
"missingType": "Отсутствует обязательное поле: type",
"stdioRequiresCommand": "тип stdio требует поле command",
"httpRequiresUrl": "тип {{type}} требует поле url",
"invalidJson": "Неверный формат JSON",
"jsonHelp": "Вставьте конфигурацию вашего MCP сервера в формате JSON. Примеры форматов:",
"jsonExampleStdio": "• stdio: {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
"jsonExampleHttp": "• http/sse: {\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}"
},
"configDetails": "Детали конфигурации (из {{configFile}})",
"projectPath": "Путь: {{path}}",
"actions": {
"cancel": "Отмена",
"saving": "Сохранение...",
"addServer": "Добавить сервер",
"updateServer": "Обновить сервер"
}
},
"saveStatus": {
"success": "Настройки успешно сохранены!",
"error": "Не удалось сохранить настройки",
"saving": "Сохранение..."
},
"footerActions": {
"save": "Сохранить настройки",
"cancel": "Отмена"
},
"git": {
"title": "Конфигурация Git",
"description": "Настройте вашу git идентичность для коммитов. Эти настройки будут применены глобально через git config --global",
"name": {
"label": "Имя Git",
"help": "Ваше имя для git коммитов"
},
"email": {
"label": "Email Git",
"help": "Ваш email для git коммитов"
},
"actions": {
"save": "Сохранить конфигурацию",
"saving": "Сохранение..."
},
"status": {
"success": "Успешно сохранено"
}
},
"apiKeys": {
"title": "API ключи",
"description": "Генерируйте API ключи для доступа к внешнему API из других приложений.",
"newKey": {
"alertTitle": "⚠️ Сохраните ваш API ключ",
"alertMessage": "Это единственный раз, когда вы увидите этот ключ. Сохраните его в безопасном месте.",
"iveSavedIt": "Я сохранил его"
},
"form": {
"placeholder": "Имя API ключа (например, Продакшн сервер)",
"createButton": "Создать",
"cancelButton": "Отмена"
},
"newButton": "Новый API ключ",
"empty": "API ключи еще не созданы.",
"list": {
"created": "Создан:",
"lastUsed": "Последнее использование:"
},
"confirmDelete": "Вы уверены, что хотите удалить этот API ключ?",
"status": {
"active": "Активен",
"inactive": "Неактивен"
},
"github": {
"title": "GitHub токены",
"description": "Добавьте персональные токены доступа GitHub для клонирования приватных репозиториев через внешний API.",
"descriptionAlt": "Добавьте персональные токены доступа GitHub для клонирования приватных репозиториев. Вы также можете передавать токены напрямую в API запросах без их сохранения.",
"addButton": "Добавить токен",
"form": {
"namePlaceholder": "Имя токена (например, Личные репозитории)",
"tokenPlaceholder": "Персональный токен доступа GitHub (ghp_...)",
"descriptionPlaceholder": "Описание (необязательно)",
"addButton": "Добавить токен",
"cancelButton": "Отмена",
"howToCreate": "Как создать персональный токен доступа GitHub →"
},
"empty": "GitHub токены еще не добавлены.",
"added": "Добавлен:",
"confirmDelete": "Вы уверены, что хотите удалить этот GitHub токен?"
},
"apiDocsLink": "Документация API",
"documentation": {
"title": "Документация внешнего API",
"description": "Узнайте, как использовать внешний API для запуска сеансов Claude/Cursor из ваших приложений.",
"viewLink": "Просмотр документации API →"
},
"loading": "Загрузка...",
"version": {
"updateAvailable": "Доступно обновление: v{{version}}"
}
},
"tasks": {
"checking": "Проверка установки TaskMaster...",
"notInstalled": {
"title": "TaskMaster AI CLI не установлен",
"description": "TaskMaster CLI требуется для использования функций управления задачами. Установите его для начала работы:",
"installCommand": "npm install -g task-master-ai",
"viewOnGitHub": "Посмотреть на GitHub",
"afterInstallation": "После установки:",
"steps": {
"restart": "Перезапустите это приложение",
"autoAvailable": "Функции TaskMaster станут автоматически доступны",
"initCommand": "Используйте task-master init в каталоге вашего проекта"
}
},
"settings": {
"enableLabel": "Включить интеграцию TaskMaster",
"enableDescription": "Показывать задачи TaskMaster, баннеры и индикаторы боковой панели в интерфейсе"
}
},
"agents": {
"authStatus": {
"checking": "Проверка...",
"connected": "Подключен",
"notConnected": "Не подключен",
"disconnected": "Отключен",
"checkingAuth": "Проверка статуса аутентификации...",
"loggedInAs": "Вошли как {{email}}",
"authenticatedUser": "аутентифицированный пользователь"
},
"account": {
"claude": {
"description": "AI-ассистент Anthropic Claude"
},
"cursor": {
"description": "Редактор кода с AI Cursor"
},
"codex": {
"description": "AI-ассистент OpenAI Codex"
}
},
"connectionStatus": "Статус подключения",
"login": {
"title": "Вход",
"reAuthenticate": "Повторная аутентификация",
"description": "Войдите в ваш аккаунт {{agent}} для включения AI функций",
"reAuthDescription": "Войдите с другим аккаунтом или обновите учетные данные",
"button": "Войти",
"reLoginButton": "Войти снова"
},
"error": "Ошибка: {{error}}"
},
"permissions": {
"title": "Настройки разрешений",
"skipPermissions": {
"label": "Пропускать запросы разрешений (используйте с осторожностью)",
"claudeDescription": "Эквивалентно флагу --dangerously-skip-permissions",
"cursorDescription": "Эквивалентно флагу -f в Cursor CLI"
},
"allowedTools": {
"title": "Разрешенные инструменты",
"description": "Инструменты, которые автоматически разрешены без запроса разрешения",
"placeholder": "например, \"Bash(git log:*)\" или \"Write\"",
"quickAdd": "Быстро добавить общие инструменты:",
"empty": "Разрешенные инструменты не настроены"
},
"blockedTools": {
"title": "Заблокированные инструменты",
"description": "Инструменты, которые автоматически блокируются без запроса разрешения",
"placeholder": "например, \"Bash(rm:*)\"",
"empty": "Заблокированные инструменты не настроены"
},
"allowedCommands": {
"title": "Разрешенные команды оболочки",
"description": "Команды оболочки, которые автоматически разрешены без запроса",
"placeholder": "например, \"Shell(ls)\" или \"Shell(git status)\"",
"quickAdd": "Быстро добавить общие команды:",
"empty": "Разрешенные команды не настроены"
},
"blockedCommands": {
"title": "Заблокированные команды оболочки",
"description": "Команды оболочки, которые автоматически блокируются",
"placeholder": "например, \"Shell(rm -rf)\" или \"Shell(sudo)\"",
"empty": "Заблокированные команды не настроены"
},
"toolExamples": {
"title": "Примеры шаблонов инструментов:",
"bashGitLog": "- Разрешить все команды git log",
"bashGitDiff": "- Разрешить все команды git diff",
"write": "- Разрешить все использование инструмента Write",
"bashRm": "- Заблокировать все команды rm (опасно)"
},
"shellExamples": {
"title": "Примеры команд оболочки:",
"ls": "- Разрешить команду ls",
"gitStatus": "- Разрешить git status",
"npmInstall": "- Разрешить npm install",
"rmRf": "- Заблокировать рекурсивное удаление"
},
"codex": {
"permissionMode": "Режим разрешений",
"description": "Управляет тем, как Codex обрабатывает изменения файлов и выполнение команд",
"modes": {
"default": {
"title": "По умолчанию",
"description": "Только доверенные команды (ls, cat, grep, git status и т.д.) выполняются автоматически. Другие команды пропускаются. Может записывать в рабочее пространство."
},
"acceptEdits": {
"title": "Принимать правки",
"description": "Все команды выполняются автоматически в рабочем пространстве. Полный автоматический режим с изолированным выполнением."
},
"bypassPermissions": {
"title": "Обход разрешений",
"description": "Полный системный доступ без ограничений. Все команды выполняются автоматически с полным доступом к диску и сети. Используйте с осторожностью."
}
},
"technicalDetails": "Технические детали",
"technicalInfo": {
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted. Доверенные команды: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (без -exec) и т.д.",
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never. Все команды автоматически выполняются в каталоге проекта.",
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never. Полный системный доступ, используйте только в доверенных средах.",
"overrideNote": "Вы можете переопределить это для каждого сеанса, используя кнопку режима в интерфейсе чата."
}
},
"actions": {
"add": "Добавить"
}
},
"mcpServers": {
"title": "MCP серверы",
"description": {
"claude": "Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Claude",
"cursor": "Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Cursor",
"codex": "Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Codex"
},
"addButton": "Добавить MCP сервер",
"empty": "MCP серверы не настроены",
"serverType": "Тип",
"scope": {
"local": "локальный",
"user": "пользователь"
},
"config": {
"command": "Команда",
"url": "URL",
"args": "Аргументы",
"environment": "Окружение"
},
"tools": {
"title": "Инструменты",
"count": "({{count}}):",
"more": "+{{count}} еще"
},
"actions": {
"edit": "Редактировать сервер",
"delete": "Удалить сервер"
},
"help": {
"title": "О Codex MCP",
"description": "Codex поддерживает MCP серверы на основе stdio. Вы можете добавлять серверы, которые расширяют возможности Codex дополнительными инструментами и ресурсами."
}
}
}

View File

@@ -1,134 +0,0 @@
{
"projects": {
"title": "Проекты",
"newProject": "Новый проект",
"deleteProject": "Удалить проект",
"renameProject": "Переименовать проект",
"noProjects": "Проекты не найдены",
"loadingProjects": "Загрузка проектов...",
"searchPlaceholder": "Поиск проектов...",
"projectNamePlaceholder": "Имя проекта",
"starred": "Избранное",
"all": "Все",
"untitledSession": "Безымянный сеанс",
"newSession": "Новый сеанс",
"codexSession": "Сеанс Codex",
"fetchingProjects": "Получение ваших проектов и сеансов Claude",
"projects": "проекты",
"noMatchingProjects": "Нет подходящих проектов",
"tryDifferentSearch": "Попробуйте изменить поисковый запрос",
"runClaudeCli": "Запустите Claude CLI в каталоге проекта для начала работы"
},
"app": {
"title": "Claude Code UI",
"subtitle": "Интерфейс AI помощника для программирования"
},
"sessions": {
"title": "Сеансы",
"newSession": "Новый сеанс",
"deleteSession": "Удалить сеанс",
"renameSession": "Переименовать сеанс",
"noSessions": "Сеансов пока нет",
"loadingSessions": "Загрузка сеансов...",
"unnamed": "Без имени",
"loading": "Загрузка...",
"showMore": "Показать больше сеансов"
},
"tooltips": {
"viewEnvironments": "Просмотр окружений",
"hideSidebar": "Скрыть боковую панель",
"createProject": "Создать новый проект",
"refresh": "Обновить проекты и сеансы (Ctrl+R)",
"renameProject": "Переименовать проект (F2)",
"deleteProject": "Удалить пустой проект (Delete)",
"addToFavorites": "Добавить в избранное",
"removeFromFavorites": "Удалить из избранного",
"editSessionName": "Вручную редактировать имя сеанса",
"deleteSession": "Удалить этот сеанс навсегда",
"save": "Сохранить",
"cancel": "Отмена",
"clearSearch": "Очистить поиск"
},
"navigation": {
"chat": "Чат",
"files": "Файлы",
"git": "Git",
"terminal": "Терминал",
"tasks": "Задачи"
},
"actions": {
"refresh": "Обновить",
"settings": "Настройки",
"collapseAll": "Свернуть все",
"expandAll": "Развернуть все",
"cancel": "Отмена",
"save": "Сохранить",
"delete": "Удалить",
"rename": "Переименовать",
"joinCommunity": "Присоединиться к сообществу"
},
"status": {
"active": "Активен",
"inactive": "Неактивен",
"thinking": "Думает...",
"error": "Ошибка",
"aborted": "Прервано",
"unknown": "Неизвестно"
},
"time": {
"justNow": "Только что",
"oneMinuteAgo": "1 мин. назад",
"minutesAgo": "{{count}} мин. назад",
"oneHourAgo": "1 час назад",
"hoursAgo": "{{count}} ч. назад",
"oneDayAgo": "1 день назад",
"daysAgo": "{{count}} дн. назад"
},
"messages": {
"deleteConfirm": "Вы уверены, что хотите это удалить?",
"renameSuccess": "Успешно переименовано",
"deleteSuccess": "Успешно удалено",
"errorOccurred": "Произошла ошибка",
"deleteSessionConfirm": "Вы уверены, что хотите удалить этот сеанс? Это действие нельзя отменить.",
"deleteProjectConfirm": "Вы уверены, что хотите удалить этот пустой проект? Это действие нельзя отменить.",
"enterProjectPath": "Пожалуйста, введите путь к проекту",
"deleteSessionFailed": "Не удалось удалить сеанс. Попробуйте снова.",
"deleteSessionError": "Ошибка при удалении сеанса. Попробуйте снова.",
"renameSessionFailed": "Не удалось переименовать сеанс. Попробуйте снова.",
"renameSessionError": "Ошибка при переименовании сеанса. Попробуйте снова.",
"deleteProjectFailed": "Не удалось удалить проект. Попробуйте снова.",
"deleteProjectError": "Ошибка при удалении проекта. Попробуйте снова.",
"createProjectFailed": "Не удалось создать проект. Попробуйте снова.",
"createProjectError": "Ошибка при создании проекта. Попробуйте снова."
},
"version": {
"updateAvailable": "Доступно обновление"
},
"search": {
"modeProjects": "Проекты",
"modeConversations": "Разговоры",
"conversationsPlaceholder": "Поиск в разговорах...",
"searching": "Поиск...",
"noResults": "Результаты не найдены",
"tryDifferentQuery": "Попробуйте другой поисковый запрос",
"matches_one": "{{count}} совпадение",
"matches_few": "{{count}} совпадения",
"matches_many": "{{count}} совпадений",
"matches_other": "{{count}} совпадений",
"projectsScanned_one": "{{count}} проект просканирован",
"projectsScanned_few": "{{count}} проекта просканировано",
"projectsScanned_many": "{{count}} проектов просканировано",
"projectsScanned_other": "{{count}} проектов просканировано"
},
"deleteConfirmation": {
"deleteProject": "Удалить проект",
"deleteSession": "Удалить сеанс",
"confirmDelete": "Вы уверены, что хотите удалить",
"sessionCount_one": "Этот проект содержит {{count}} разговор.",
"sessionCount_few": "Этот проект содержит {{count}} разговора.",
"sessionCount_many": "Этот проект содержит {{count}} разговоров.",
"sessionCount_other": "Этот проект содержит {{count}} разговоров.",
"allConversationsDeleted": "Все разговоры будут удалены навсегда.",
"cannotUndo": "Это действие нельзя отменить."
}
}

View File

@@ -1,142 +0,0 @@
{
"notConfigured": {
"title": "TaskMaster AI не настроен",
"description": "TaskMaster помогает разбивать сложные проекты на управляемые задачи с помощью AI",
"whatIsTitle": "🎯 Что такое TaskMaster?",
"features": {
"aiPowered": "Управление задачами с AI: разбивайте сложные проекты на управляемые подзадачи",
"prdTemplates": "Шаблоны PRD: генерируйте задачи из документов требований к продукту",
"dependencyTracking": "Отслеживание зависимостей: понимайте связи задач и порядок выполнения",
"progressVisualization": "Визуализация прогресса: канбан-доски и детальная аналитика задач",
"cliIntegration": "Интеграция с CLI: используйте команды taskmaster для продвинутых рабочих процессов"
},
"initializeButton": "Инициализировать TaskMaster AI"
},
"gettingStarted": {
"title": "Начало работы с TaskMaster",
"subtitle": "TaskMaster инициализирован! Вот что делать дальше:",
"steps": {
"createPRD": {
"title": "Создайте документ требований к продукту (PRD)",
"description": "Обсудите идею вашего проекта и создайте PRD, описывающий то, что вы хотите построить.",
"addButton": "Добавить PRD",
"existingPRDs": "Существующие PRD:"
},
"generateTasks": {
"title": "Генерация задач из PRD",
"description": "Когда у вас есть PRD, попросите вашего AI-ассистента разобрать его, и TaskMaster автоматически разобьет его на управляемые задачи с деталями реализации."
},
"analyzeTasks": {
"title": "Анализ и расширение задач",
"description": "Попросите вашего AI-ассистента проанализировать сложность задач и расширить их в детальные подзадачи для упрощения реализации."
},
"startBuilding": {
"title": "Начните разработку",
"description": "Попросите вашего AI-ассистента начать работу над задачами, обновлять их статус и добавлять новые задачи по мере развития вашего проекта."
}
},
"tip": "💡 Совет: начните с PRD, чтобы получить максимум от AI-генерации задач TaskMaster"
},
"setupModal": {
"title": "Настройка TaskMaster",
"subtitle": "Интерактивный CLI для {{projectName}}",
"willStart": "Инициализация TaskMaster начнется автоматически",
"completed": "Настройка TaskMaster завершена! Теперь вы можете закрыть это окно.",
"closeButton": "Закрыть",
"closeContinueButton": "Закрыть и продолжить"
},
"helpGuide": {
"title": "Начало работы с TaskMaster",
"subtitle": "Ваш гид по продуктивному управлению задачами",
"examples": {
"parsePRD": "💬 Пример:\n\"Я только что инициализировал новый проект с Claude Task Master. У меня есть PRD в .taskmaster/docs/prd.txt. Можете помочь мне разобрать его и настроить начальные задачи?\"",
"expandTask": "💬 Пример:\n\"Задача 5 кажется сложной. Можете разбить её на подзадачи?\"",
"addTask": "💬 Пример:\n\"Пожалуйста, добавьте новую задачу для реализации загрузки изображений профиля пользователя с использованием Cloudinary, изучите лучший подход.\""
},
"moreExamples": "Посмотреть больше примеров и шаблонов использования →",
"proTips": {
"title": "💡 Профессиональные советы",
"search": "Используйте строку поиска для быстрого поиска конкретных задач",
"views": "Переключайтесь между представлениями Канбан, Список и Сетка, используя переключатели представлений",
"filters": "Используйте фильтры для фокусировки на конкретных статусах или приоритетах задач",
"details": "Нажмите на любую задачу для просмотра детальной информации и управления подзадачами"
},
"learnMore": {
"title": "📚 Узнать больше",
"description": "TaskMaster AI - это продвинутая система управления задачами, созданная для разработчиков. Получите документацию, примеры и внесите вклад в проект.",
"githubButton": "Посмотреть на GitHub"
}
},
"search": {
"placeholder": "Поиск задач..."
},
"filters": {
"button": "Фильтры",
"status": "Статус",
"priority": "Приоритет",
"sortBy": "Сортировать по",
"allStatuses": "Все статусы",
"allPriorities": "Все приоритеты",
"showing": "Показано {{filtered}} из {{total}} задач",
"clearFilters": "Очистить фильтры"
},
"sort": {
"id": "ID",
"status": "Статус",
"priority": "Приоритет",
"idAsc": "ID (по возрастанию)",
"idDesc": "ID (по убыванию)",
"titleAsc": "Название (А-Я)",
"titleDesc": "Название (Я-А)",
"statusAsc": "Статус (сначала ожидающие)",
"statusDesc": "Статус (сначала выполненные)",
"priorityAsc": "Приоритет (сначала высокий)",
"priorityDesc": "Приоритет (сначала низкий)"
},
"views": {
"kanban": "Представление Канбан",
"list": "Представление списком",
"grid": "Представление сеткой"
},
"kanban": {
"pending": "📋 К выполнению",
"inProgress": "🚀 В процессе",
"done": "✅ Выполнено",
"blocked": "🚫 Заблокировано",
"deferred": "⏳ Отложено",
"cancelled": "❌ Отменено",
"noTasksYet": "Задач пока нет",
"tasksWillAppear": "Задачи появятся здесь",
"moveTasksHere": "Перемещайте задачи сюда при начале работы",
"completedTasksHere": "Завершенные задачи появляются здесь",
"statusTasksHere": "Задачи с этим статусом появятся здесь"
},
"buttons": {
"help": "Руководство по началу работы с TaskMaster",
"prds": "PRD",
"addPRD": "Добавить PRD",
"addTask": "Добавить задачу",
"createNewPRD": "Создать новый PRD",
"prdsAvailable": "Доступно {{count}} PRD"
},
"prd": {
"modified": "Изменено: {{date}}"
},
"statuses": {
"pending": "Ожидание",
"in-progress": "В процессе",
"done": "Выполнено",
"blocked": "Заблокировано",
"deferred": "Отложено",
"cancelled": "Отменено"
},
"priorities": {
"high": "Высокий",
"medium": "Средний",
"low": "Низкий"
},
"noMatchingTasks": {
"title": "Нет задач, соответствующих вашим фильтрам",
"description": "Попробуйте изменить критерии поиска или фильтрации."
}
}

View File

@@ -6,10 +6,7 @@
},
"copyMessage": {
"copy": "复制消息",
"copied": "消息已复制",
"selectFormat": "选择复制格式",
"copyAsMarkdown": "复制为 Markdown",
"copyAsText": "复制为纯文本"
"copied": "消息已复制"
},
"messageTypes": {
"user": "U",

View File

@@ -21,12 +21,6 @@ export const authenticatedFetch = (url, options = {}) => {
...defaultHeaders,
...options.headers,
},
}).then((response) => {
const refreshedToken = response.headers.get('X-Refreshed-Token');
if (refreshedToken) {
localStorage.setItem('auth-token', refreshedToken);
}
return response;
});
};