Compare commits

..

14 Commits

Author SHA1 Message Date
Haileyesus
907cf510a3 refactor(providers): remove debug logging from Claude authentication status checks 2026-04-17 16:01:16 +03:00
Haileyesus
eb6268748b chore(api): remove unused backend endpoints after MCP audit
Remove legacy backend routes that no longer have frontend or internal
callers, including the old Claude/Codex MCP APIs, unused Cursor and Codex
helper endpoints, stale TaskMaster detection/next/initialize routes,
and unused command/project helpers.

This reduces duplicated MCP behavior now handled by the provider-based
MCP API, shrinks the exposed backend surface, and removes probe/service
code that only existed for deleted endpoints.

Add an MCP settings API audit document to capture the route-usage
analysis and explain why the legacy MCP endpoints were considered safe
to remove.
2026-04-17 15:57:07 +03:00
Haileyesus
4e962272cd refactor(providers): move auth status routes under provider API
Move provider authentication status endpoints out of the legacy `/api/cli` route
namespace so auth status is exposed through the same provider module that owns
provider auth and MCP behavior.

Add `GET /api/providers/:provider/auth/status` to the provider router and route
it through the provider auth service. Remove the old `cli-auth` route file and
`/api/cli` mount now that provider auth status is handled by the unified provider
API.

Update the frontend provider auth endpoint map to call the new provider-scoped
routes and rename the endpoint constant to reflect that it is no longer CLI
specific.
2026-04-17 15:39:25 +03:00
Haileyesus
0f1e515b39 refactor(providers): move session message delegation into sessions service
Move provider-backed session history and message normalization calls out of the
generic providers service so the service name reflects the behavior it owns.

Add a dedicated sessions service for listing session-capable providers,
normalizing live provider events, and fetching persisted session history through
the provider registry. Update realtime handlers and the unified messages route to
depend on `sessionsService` instead of `providersService`.

This separates session message operations from other provider concerns such as
auth and MCP, keeping the provider services easier to navigate as the module
grows.
2026-04-17 15:29:35 +03:00
Haileyesus
b74b5fb967 refactor(providers): clarify provider auth and MCP naming
Rename provider auth/MCP contracts to remove the overloaded Runtime suffix so
the shared interfaces read as stable provider capabilities instead of execution
implementation details.

Add a consistent provider-first auth class naming convention by renaming
ClaudeAuthProvider, CodexAuthProvider, CursorAuthProvider, and GeminiAuthProvider
to ClaudeProviderAuth, CodexProviderAuth, CursorProviderAuth, and
GeminiProviderAuth.

This keeps the provider module API easier to scan and aligns auth naming with
the main provider ownership model.
2026-04-17 15:23:12 +03:00
Haileyesus
32dfd27156 refactor(providers): move auth status checks into provider runtimes
Move provider authentication status logic out of the CLI auth route so auth checks
live with the provider implementations that understand each provider's install
and credential model.

Add provider-specific auth runtime classes for Claude, Codex, Cursor, and Gemini,
and expose them through the shared provider contract as `provider.auth`. Add a
provider auth service that resolves providers through the registry and delegates
status checks via `auth.getStatus()`.

Keep the existing `/api/cli/<provider>/status` endpoints, but make them thin route
adapters over the new provider auth service. This removes duplicated route-local
credential parsing and makes auth status a first-class provider capability beside
MCP and message handling.
2026-04-17 15:15:26 +03:00
Haileyesus
7832429011 refactor(providers): centralize message handling in provider module
Move provider-specific normalizeMessage and fetchHistory logic out of the legacy
server/providers adapters and into the refactored provider classes so callers can
depend on the main provider contract instead of parallel adapter plumbing.

Add a providers service to resolve concrete providers through the registry and
delegate message normalization/history loading from realtime handlers and the
unified messages route. Add shared TypeScript message/history types and normalized
message helpers so provider implementations and callers use the same contract.

Remove the old adapter registry/files now that Claude, Codex, Cursor, and Gemini
implement the required behavior directly.
2026-04-17 14:22:29 +03:00
Haileyesus
1a6eb57043 feat: implement platform-specific provider visibility for cursor agent 2026-04-16 23:21:21 +03:00
Haileyesus
d979c315cd feat(mcp): add global MCP server creation flow
Add a separate global MCP add path in the settings MCP module so users can create
one shared MCP server configuration across Claude, Cursor, Codex, and Gemini from
the same screen.

The provider-specific add flow is still kept next to it because these two actions
have different intent. A global MCP server must be constrained to the subset of
configuration that every provider can accept, while a provider-specific server can
still use that provider's own supported scopes, transports, and fields. Naming the
buttons as "Add Global MCP Server" and "Add <Provider> MCP Server" makes that
distinction explicit without forcing users to infer it from the selected tab.

This also moves the explanatory copy to button hover text to keep the MCP toolbar
compact while still documenting the difference between global and provider-only
adds at the point of action.

Implementation details:
- Add global MCP form mode with shared user/project scopes and stdio/http transports.
- Submit global creates through `/api/providers/mcp/servers/global`.
- Reuse the existing MCP form modal with configurable scopes, transports, labels,
  and descriptions instead of duplicating form logic.
- Disable provider-only fields for the global flow because those fields cannot be
  safely written to every provider.
- Clear the MCP server cache globally after a global add because every provider tab
  may have changed.
- Surface partial global add failures with provider-specific error messages.

Validation:
- npx eslint src/components/mcp/view/McpServers.tsx
- npm run typecheck
- npm run build:client
2026-04-16 22:43:18 +03:00
Haileyesus
5143a92021 fix(mcp): form with multiline text handling for args, env, headers, and envVars 2026-04-16 22:29:34 +03:00
Haileyesus
358f47d020 refactor(settings): move MCP server management into provider module
Extract MCP server settings out of the settings controller and agents tab into a
dedicated frontend MCP module. The settings UI now delegates MCP rendering and
behavior to a single module that only needs the selected provider and current
projects.

Changes:
- Add `src/components/mcp` as the single frontend MCP module
- Move MCP server list rendering into `McpServers`
- Move MCP add/edit modal into `McpServerFormModal`
- Move MCP API/state logic into `useMcpServers`
- Move MCP form state/validation logic into `useMcpServerForm`
- Add provider-specific MCP constants, types, and formatting helpers
- Use the unified `/api/providers/:provider/mcp/servers` API for all providers
- Support MCP management for Claude, Cursor, Codex, and Gemini
- Remove old settings-owned Claude/Codex MCP modal components
- Remove old provider-specific `McpServersContent` branching from settings
- Strip MCP server state, fetch, save, delete, and modal ownership from
  `useSettingsController`
- Simplify agents settings props so MCP only receives `selectedProvider` and
  `currentProjects`
- Keep Claude working-directory unsupported while preserving cwd support for
  Cursor, Codex, and Gemini
- Add progressive MCP loading:
  - render user/global scope first
  - load project/local scopes in the background
  - append project results as they resolve
  - cache MCP lists briefly to avoid slow tab-switch refetches
  - ignore stale async responses after provider switches

Verification:
- `npx eslint src/components/mcp`
- `npm run typecheck`
- `npm run build:client`
2026-04-16 22:22:35 +03:00
Haileyesus
5c53100651 refactor: put /api/providers in index.js and remove /providers prefix from provider.routes.ts 2026-04-16 20:58:16 +03:00
Haileyesus
63b9606e78 chore: remove dead code related to MCP server 2026-04-16 20:57:17 +03:00
Haileyesus
016e8673f2 feat: implement MCP provider registry and service
- Add provider registry to manage LLM providers (Claude, Codex, Cursor, Gemini).
- Create provider routes for MCP server operations (list, upsert, delete, run).
- Implement MCP service for handling server operations and validations.
- Introduce abstract provider class and MCP provider base for shared functionality.
- Add tests for MCP server operations across different providers and scopes.
- Define shared interfaces and types for MCP functionality.
- Implement utility functions for handling JSON config files and API responses.
2026-04-15 20:16:26 +03:00
105 changed files with 3515 additions and 8038 deletions

View File

@@ -1,51 +0,0 @@
name: Docker
on:
workflow_dispatch:
inputs:
extra_tag:
description: 'Additional tag to push alongside the template tag (e.g. v1.2.3, leave empty for none)'
required: false
type: string
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
template: [claude-code, codex, gemini]
steps:
- uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute tags
id: tags
run: |
TAGS="docker.io/cloudcliai/sandbox:${{ matrix.template }}"
if [ -n "${{ inputs.extra_tag }}" ]; then
TAGS="$TAGS,docker.io/cloudcliai/sandbox:${{ matrix.template }}-${{ inputs.extra_tag }}"
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./docker
file: ./docker/${{ matrix.template }}/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=gha,scope=${{ matrix.template }}
cache-to: type=gha,mode=max,scope=${{ matrix.template }}

2
.gitignore vendored
View File

@@ -137,8 +137,6 @@ tasks/
!src/i18n/locales/ja/tasks.json
!src/i18n/locales/ru/tasks.json
!src/i18n/locales/de/tasks.json
!src/i18n/locales/tr/tasks.json
!src/i18n/locales/it/tasks.json
# Git worktrees
.worktrees/

View File

@@ -3,68 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.30.0](https://github.com/siteboon/claudecodeui/compare/v1.29.5...v1.30.0) (2026-04-21)
### New Features
* **i18n:** add Italian language support ([#677](https://github.com/siteboon/claudecodeui/issues/677)) ([86b6545](https://github.com/siteboon/claudecodeui/commit/86b6545c3505475ac2de0cec75cc8f86ab22aceb))
* **i18n:** add Turkish (tr) language support ([#678](https://github.com/siteboon/claudecodeui/issues/678)) ([89b754d](https://github.com/siteboon/claudecodeui/commit/89b754d186b68f3df8aa439a2d535644406066f0)), closes [#384](https://github.com/siteboon/claudecodeui/issues/384) [#514](https://github.com/siteboon/claudecodeui/issues/514) [#525](https://github.com/siteboon/claudecodeui/issues/525) [#534](https://github.com/siteboon/claudecodeui/issues/534)
* introduce opus 4.7 ([#682](https://github.com/siteboon/claudecodeui/issues/682)) ([c5e55ad](https://github.com/siteboon/claudecodeui/commit/c5e55adc89d0316675f90a927aa40d115958ae9f))
### Bug Fixes
* iOS scrolling main chat area ([3969135](https://github.com/siteboon/claudecodeui/commit/3969135bd427fbf48f29bb3dbfedb47791ca78dc))
* migrate PlanDisplay raw params from native details to Collapsible primitive ([fc3504e](https://github.com/siteboon/claudecodeui/commit/fc3504eaed8ca7ed9214838d148ea385b8352c31))
* precise Claude SDK denial message detection in deriveToolStatus ([09dcea0](https://github.com/siteboon/claudecodeui/commit/09dcea05fbc8c208d931aa1f08618f0e8087392f))
* reduce size of permission mode button tap target and provider selector on mobile ([457ca0d](https://github.com/siteboon/claudecodeui/commit/457ca0daabcaa8397f4375ee8aa2671336b648ff))
* small mobile respnosive fixes ([25820ed](https://github.com/siteboon/claudecodeui/commit/25820ed995c1b813b1f9ed073097b08eb1d902ec))
* small mobile respnosive fixes ([c471b5d](https://github.com/siteboon/claudecodeui/commit/c471b5d3fa6ce1968adb4cf87a15ac0e18febd20))
### Refactoring
* add primitives, plan mode display, and new session model selector ([7763e60](https://github.com/siteboon/claudecodeui/commit/7763e60fb32e34742058c055c57664a503a34d1d))
* chat composer new design ([5758bee](https://github.com/siteboon/claudecodeui/commit/5758bee8a038ed50073dba882108617959dda82c))
* queue primitive, tool status badges, and tool display cleanup ([ec0ff97](https://github.com/siteboon/claudecodeui/commit/ec0ff974cba213a1100b2a071b8ba533e812fe82))
### Maintenance
* add docker sandbox action ([fa5a238](https://github.com/siteboon/claudecodeui/commit/fa5a23897c086bcacf1cf5d926c650f98a0f2222))
## [1.29.5](https://github.com/siteboon/claudecodeui/compare/v1.29.4...v1.29.5) (2026-04-16)
### Bug Fixes
* update node-pty to latest version ([6a13e17](https://github.com/siteboon/claudecodeui/commit/6a13e1773b145049ade512aa6e5cac21c2e5c4de))
## [1.29.4](https://github.com/siteboon/claudecodeui/compare/v1.29.3...v1.29.4) (2026-04-16)
### New Features
* deleting from sidebar will now ask whether to remove all data as well ([e9c7a50](https://github.com/siteboon/claudecodeui/commit/e9c7a5041c31a6f7b2032f06abe19c52d3d4cd8c))
### Bug Fixes
* pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set ([4c106a5](https://github.com/siteboon/claudecodeui/commit/4c106a5083d90989bbeedaefdbb68f5b3fa6fd58)), closes [#468](https://github.com/siteboon/claudecodeui/issues/468)
### Refactoring
* remove the sqlite3 dependency ([2895208](https://github.com/siteboon/claudecodeui/commit/289520814cf3ca36403056739ef22021f78c6033))
* **server:** extract URL detection and color utils from index.js ([#657](https://github.com/siteboon/claudecodeui/issues/657)) ([63e996b](https://github.com/siteboon/claudecodeui/commit/63e996bb77cfa97b1f55f6bdccc50161a75a3eee))
### Maintenance
* upgrade commit lint to 20.5.0 ([0948601](https://github.com/siteboon/claudecodeui/commit/09486016e67d97358c228ebc6eb4502ccb0012e4))
## [1.29.3](https://github.com/siteboon/claudecodeui/compare/v1.29.2...v1.29.3) (2026-04-15)
### Bug Fixes
* **version-upgrade-modal:** implement reload countdown and update UI messages ([#655](https://github.com/siteboon/claudecodeui/issues/655)) ([6413042](https://github.com/siteboon/claudecodeui/commit/641304242d7705b54aab65faa4a7673438c92c60))
### Maintenance
* remove unused route (migrated to providers already) ([31f28a2](https://github.com/siteboon/claudecodeui/commit/31f28a2c183f6ead50941027632d7ab64b7bb2d4))
## [1.29.2](https://github.com/siteboon/claudecodeui/compare/v1.29.1...v1.29.2) (2026-04-14)
### Bug Fixes

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><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
---

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><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b></i></div>
---

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><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
---

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.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
---

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><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
---

View File

@@ -1,252 +0,0 @@
<div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (Claude Code UI olarak da bilinir)</h1>
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a> ve <a href="https://geminicli.com/">Gemini-CLI</a> için masaüstü ve mobil arayüz.<br>Yerel ya da uzaktan kullanarak aktif projelerine ve oturumlarına her yerden erişebilirsin.</p>
</div>
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Dokümantasyon</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Sorun Bildir</a> · <a href="CONTRIBUTING.md">Katkıda Bulun</a>
</p>
<p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Hemen_Dene-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Toplulu%C4%9Fa%20Kat%C4%B1l-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord'a Katıl"></a>
<br><br>
<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> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
---
## Ekran Görüntüleri
<div align="center">
<table>
<tr>
<td align="center">
<h3>Masaüstü Görünümü</h3>
<img src="public/screenshots/desktop-main.png" alt="Masaüstü Arayüzü" width="400">
<br>
<em>Proje genel bakışı ve sohbeti gösteren ana arayüz</em>
</td>
<td align="center">
<h3>Mobil Deneyim</h3>
<img src="public/screenshots/mobile-chat.png" alt="Mobil Arayüz" width="250">
<br>
<em>Dokunma gezinmesiyle duyarlı mobil tasarım</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>CLI Seçimi</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Seçimi" width="400">
<br>
<em>Claude Code, Gemini, Cursor CLI ve Codex arasında seçim yap</em>
</td>
</tr>
</table>
</div>
## Özellikler
- **Duyarlı Tasarım** — Masaüstü, tablet ve mobilde sorunsuz çalışır; böylece ajanlarını telefondan da kullanabilirsin
- **Etkileşimli Sohbet Arayüzü** — Ajanlarla akıcı iletişim için dahili sohbet arayüzü
- **Entegre Shell Terminali** — Yerleşik shell özelliği üzerinden ajan CLI'larına doğrudan erişim
- **Dosya Gezgini** — Sözdizimi vurgulama ve canlı düzenleme ile etkileşimli dosya ağacı
- **Git Gezgini** — Değişikliklerini görüntüle, staging'e ekle ve commit'le. Dallar arası geçiş de yapabilirsin
- **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et
- **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için [`shared/modelConstants.js`](shared/modelConstants.js) dosyasına bak)
## Hızlı Başlangıç
### CloudCLI Cloud (Önerilen)
Başlamanın en hızlı yolu — yerel kurulum yok. Web, mobil uygulama, API veya favori IDE'nden erişilebilen, tam yönetilen, konteyner tabanlı bir geliştirme ortamına sahip ol.
**[CloudCLI Cloud ile başla](https://cloudcli.ai)**
### Kendin Barındır (Açık Kaynak)
#### npm
CloudCLI UI'yi **npx** ile anında dene (**Node.js** v22+ gerekir):
```
npx @cloudcli-ai/cloudcli
```
Veya düzenli kullanım için **genel olarak** kur:
```
npm install -g @cloudcli-ai/cloudcli
cloudcli
```
`http://localhost:3001` adresini aç — mevcut tüm oturumların otomatik olarak keşfedilir.
Tam yapılandırma seçenekleri, PM2, uzak sunucu kurulumu ve daha fazlası için **[dokümantasyonu ziyaret et →](https://cloudcli.ai/docs)**.
#### Docker Sandbox'lar (Deneysel)
Ajanları hipervizör seviyesinde izolasyonlu sandbox'larda çalıştır. Varsayılan olarak Claude Code başlar. [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/) gerekir.
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Claude Code, Codex ve Gemini CLI destekler. Kurulum ve gelişmiş seçenekler için [sandbox dokümantasyonuna](docker/) bak.
---
## Hangi seçenek sana uygun?
CloudCLI UI, CloudCLI Cloud'u güçlendiren açık kaynak arayüz katmanıdır. Kendi makinende barındırabilir, izolasyon için Docker sandbox'ta çalıştırabilir veya tam yönetilen ortam için CloudCLI Cloud kullanabilirsin.
| | Kendin Barındır (npm) | Kendin Barındır (Docker Sandbox) *(Deneysel)* | CloudCLI Cloud |
|---|---|---|---|
| **En iyi şunun için** | Kendi makinende yerel ajan oturumları | Web/mobil IDE ile izole ajanlar | Ajanlarını bulutta isteyen ekipler |
| **Nasıl erişilir** | `[yourip]:port` üzerinden tarayıcıda | `localhost:port` üzerinden tarayıcıda | Tarayıcı, herhangi bir IDE, REST API, n8n |
| **Kurulum** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | Kurulum gerekmez |
| **İzolasyon** | Kendi host'unda çalışır | Hipervizör seviyesi sandbox (microVM) | Tam bulut izolasyonu |
| **Makinenin açık kalması gerek** | Evet | Evet | Hayır |
| **Mobil erişim** | Ağındaki herhangi bir tarayıcı | Ağındaki herhangi bir tarayıcı | Herhangi bir cihaz, native uygulama yolda |
| **Desteklenen ajanlar** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
| **Dosya gezgini ve Git** | Evet | Evet | Evet |
| **MCP yapılandırması** | `~/.claude` ile senkron | UI üzerinden yönetilir | UI üzerinden yönetilir |
| **REST API** | Evet | Evet | Evet |
| **Ekip paylaşımı** | Hayır | Hayır | Evet |
| **Platform maliyeti** | Ücretsiz, açık kaynak | Ücretsiz, açık kaynak | Aylık 7 $'dan başlar |
> Tüm seçenekler kendi AI aboneliklerini (Claude, Cursor, vb.) kullanır — CloudCLI AI'ı değil, ortamı sağlar.
---
## Güvenlik ve Araç Yapılandırması
**🔒 Önemli Uyarı**: Tüm Claude Code araçları **varsayılan olarak devre dışıdır**. Bu, potansiyel olarak zararlı işlemlerin otomatik çalışmasını önler.
### Araçları Etkinleştirme
Claude Code'un tam işlevselliğinden yararlanmak için araçları manuel olarak etkinleştirmen gerekir:
1. **Araç Ayarlarını Aç** — Kenar çubuğundaki dişli simgesine tıkla
2. **Seçerek Etkinleştir** — Yalnızca ihtiyacın olan araçları
3. **Ayarları Uygula** — Tercihlerin yerel olarak kaydedilir
<div align="center">
![Araç Ayarları Modalı](public/screenshots/tools-modal.png)
*Araç Ayarları arayüzü — yalnızca ihtiyacın olanı etkinleştir*
</div>
**Önerilen yaklaşım**: Temel araçlarla başla ve gerektikçe daha fazlasını ekle. Bu ayarları sonra her zaman değiştirebilirsin.
---
## Eklentiler
CloudCLI, kendi frontend UI'sı ve isteğe bağlı Node.js arka ucu olan özel sekmeler eklemeni sağlayan bir eklenti sistemine sahiptir. Git depolarından eklentileri doğrudan **Ayarlar > Eklentiler**'den yükleyebilir veya kendi eklentini yazabilirsin.
### Mevcut Eklentiler
| Eklenti | Açıklama |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali |
### Kendi Eklentini Yaz
**[Plugin Starter Şablonu →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — kendi eklentini oluşturmak için bu repo'yu fork'la. Frontend render, canlı bağlam güncellemeleri ve arka uç sunucusuyla RPC iletişimi içeren çalışan bir örnek içerir.
**[Plugin Dokümantasyonu →](https://cloudcli.ai/docs/plugin-overview)** — plugin API'sı, manifest formatı, güvenlik modeli ve daha fazlası için tam rehber.
---
## Sık Sorulan Sorular
<details>
<summary>Bu Claude Code Remote Control'dan nasıl farklı?</summary>
Claude Code Remote Control, yerel terminalinde zaten çalışan bir oturuma mesaj göndermeni sağlar. Makinen açık kalmak zorunda, terminalin açık kalmak zorunda ve ağ bağlantısı olmadan yaklaşık 10 dakika sonra oturumlar zaman aşımına uğrar.
CloudCLI UI ve CloudCLI Cloud, Claude Code'un yanında değil içinde çalışır — MCP sunucuların, izinlerin, ayarların ve oturumların, Claude Code'un yerel olarak kullandığının birebir aynısıdır. Hiçbir şey çoğaltılmaz veya ayrı yönetilmez.
Pratikte bu ne demek:
- **Tek oturum değil, tüm oturumların** — CloudCLI UI, `~/.claude` klasöründeki her oturumu otomatik keşfeder. Remote Control yalnızca tek aktif oturumu Claude mobil uygulamasına açar.
- **Ayarların sana ait** — UI'da değiştirdiğin MCP sunucuları, araç izinleri ve proje yapılandırması doğrudan Claude Code yapılandırmana yazılır ve anında etkili olur; tersi de geçerli.
- **Daha fazla ajanla çalışır** — Sadece Claude Code değil; Cursor CLI, Codex ve Gemini CLI de.
- **Sadece sohbet penceresi değil, tam UI** — dosya gezgini, Git entegrasyonu, MCP yönetimi ve shell terminali hepsi yerleşik.
- **CloudCLI Cloud bulutta çalışır** — laptop'unu kapat, ajan çalışmaya devam eder. Beklemen gereken terminal yok, uyanık tutman gereken makine yok.
</details>
<details>
<summary>AI aboneliği için ayrıca ödeme yapmam gerekiyor mu?</summary>
Evet. CloudCLI AI'yi değil, ortamı sağlar. Kendi Claude, Cursor, Codex veya Gemini aboneliğini getirirsin. CloudCLI Cloud, barındırılan ortam için aylık 7 $'dan başlar — bunun üzerine eklenir.
</details>
<details>
<summary>CloudCLI UI'yi telefonumda kullanabilir miyim?</summary>
Evet. Kendin barındırdığında, sunucuyu makinende çalıştır ve ağındaki herhangi bir tarayıcıda `[yourip]:port` adresini aç. CloudCLI Cloud için, herhangi bir cihazdan aç — VPN yok, port yönlendirme yok, kurulum yok. Native bir uygulama da hazırlanıyor.
</details>
<details>
<summary>UI'da yaptığım değişiklikler yerel Claude Code kurulumumu etkiler mi?</summary>
Evet, kendin barındırdığında. CloudCLI UI, Claude Code'un yerel olarak kullandığı aynı `~/.claude` yapılandırmasından okur ve ona yazar. UI üzerinden eklediğin MCP sunucuları Claude Code'da anında görünür; tersi de geçerli.
</details>
---
## Topluluk ve Destek
- **[Dokümantasyon](https://cloudcli.ai/docs)** — kurulum, yapılandırma, özellikler ve sorun giderme
- **[Discord](https://discord.gg/buxwujPNRE)** — yardım al ve diğer kullanıcılarla tanış
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — hata raporları ve özellik istekleri
- **[Katkı Rehberi](CONTRIBUTING.md)** — projeye nasıl katkıda bulunulur
## Lisans
GNU Affero General Public License v3.0 veya sonrası (AGPL-3.0-or-later) — tam metin ve Bölüm 7 altındaki ek şartlar için [LICENSE](LICENSE) dosyasına bak.
Bu proje açık kaynaklıdır ve AGPL-3.0-or-later lisansı altında özgürce kullanılabilir, değiştirilebilir ve dağıtılabilir. Bu yazılımı değiştirir ve bir ağ servisi olarak çalıştırırsan, değiştirilmiş kaynak kodunu o servisin kullanıcılarına sunmak zorundasın.
CloudCLI UI — (https://cloudcli.ai).
## Teşekkürler
### Kullanılan Teknolojiler
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic'in resmi CLI'ı
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor'un resmi CLI'ı
- **[Codex](https://developers.openai.com/codex)** — OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI
- **[React](https://react.dev/)** — Kullanıcı arayüzü kütüphanesi
- **[Vite](https://vitejs.dev/)** — Hızlı derleme aracı ve geliştirme sunucusu
- **[Tailwind CSS](https://tailwindcss.com/)** — Utility-first CSS framework
- **[CodeMirror](https://codemirror.net/)** — Gelişmiş kod editörü
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(İsteğe Bağlı)* — AI destekli proje yönetimi ve görev planlama
### Sponsorlar
- [Siteboon — AI destekli web sitesi oluşturucu](https://siteboon.ai)
---
<div align="center">
<strong>Claude Code, Cursor ve Codex topluluğu için özenle yapıldı.</strong>
</div>

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><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a></i></div>
---

2078
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.30.0",
"version": "1.29.2",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",
@@ -65,7 +65,7 @@
"author": "CloudCLI UI Contributors",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@anthropic-ai/claude-agent-sdk": "^0.2.59",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
@@ -104,7 +104,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.1",
"node-fetch": "^2.7.0",
"node-pty": "^1.2.0-beta.12",
"node-pty": "^1.1.0-beta34",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
@@ -117,16 +117,17 @@
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2"
},
"devDependencies": {
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@eslint/js": "^9.39.3",
"@release-it/conventional-changelog": "^10.0.5",
"@types/better-sqlite3": "^7.6.13",
"@types/cross-spawn": "^6.0.6",
"@types/express": "^5.0.6",
"@types/node": "^22.19.7",

View File

@@ -25,7 +25,6 @@ import {
notifyUserIfEnabled
} from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createNormalizedMessage } from './shared/utils.js';
const activeSessions = new Map();
@@ -33,7 +32,7 @@ const pendingToolApprovals = new Map();
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);
function createRequestId() {
if (typeof crypto.randomUUID === 'function') {
@@ -149,16 +148,6 @@ function mapCliOptionsToSDK(options = {}) {
const sdkOptions = {};
// Forward all host env vars (e.g. ANTHROPIC_BASE_URL) to the subprocess.
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
sdkOptions.env = { ...process.env };
// Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to 'claude' on PATH.
// The SDK 0.2.113+ looks for a bundled native binary optional dep by default;
// this fallback ensures users who installed via the official installer still work
// even when npm prune --production has removed those optional deps.
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude';
// Map working directory
if (cwd) {
sdkOptions.cwd = cwd;
@@ -712,14 +701,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir);
// Check if Claude CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('claude');
const errorContent = !installed
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
: error.message;
// Send error to WebSocket
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
notifyRunFailed({
userId: ws?.userId || null,
provider: 'claude',
@@ -727,6 +710,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
sessionName: sessionSummary,
error
});
throw error;
}
}

View File

@@ -2,7 +2,6 @@ import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for better command execution
@@ -288,20 +287,14 @@ async function spawnCursor(command, options = {}, ws) {
});
// Handle process errors
cursorProcess.on('error', async (error) => {
cursorProcess.on('error', (error) => {
console.error('Cursor CLI process error:', error);
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
// Check if Cursor CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('cursor');
const errorContent = !installed
? 'Cursor CLI is not installed. Please install it from https://cursor.com'
: error.message;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
notifyTerminalState({ error });
settleOnce(() => reject(error));

View File

@@ -9,7 +9,6 @@ import os from 'os';
import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createNormalizedMessage } from './shared/utils.js';
let activeGeminiProcesses = new Map(); // Track active processes by session ID
@@ -381,15 +380,6 @@ async function spawnGemini(command, options = {}, ws) {
notifyTerminalState({ code });
resolve();
} else {
// code 127 = shell "command not found" — check installation
if (code === 127) {
const installed = await providerAuthService.isProviderInstalled('gemini');
if (!installed) {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
}
}
notifyTerminalState({
code,
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
@@ -399,19 +389,13 @@ async function spawnGemini(command, options = {}, ws) {
});
// Handle process errors
geminiProcess.on('error', async (error) => {
geminiProcess.on('error', (error) => {
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
// Check if Gemini CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('gemini');
const errorContent = !installed
? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'
: error.message;
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' }));
notifyTerminalState({ error });
reject(error);

View File

@@ -14,7 +14,25 @@ const __dirname = getModuleDir(import.meta.url);
const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
import { c } from './utils/colors.js';
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
ok: (text) => `${colors.green}${text}${colors.reset}`,
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
tip: (text) => `${colors.blue}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
@@ -208,7 +226,68 @@ const server = http.createServer(app);
const ptySessionsMap = new Map();
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js';
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
function stripAnsiSequences(value = '') {
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
}
function normalizeDetectedUrl(url) {
if (!url || typeof url !== 'string') return null;
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
if (!cleaned) return null;
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function extractUrlsFromText(value = '') {
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
// Handle wrapped terminal URLs split across lines by terminal width.
const wrappedMatches = [];
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
const lines = value.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
if (!startMatch) continue;
let combined = startMatch[0];
let j = i + 1;
while (j < lines.length) {
const continuation = lines[j].trim();
if (!continuation) break;
if (!continuationRegex.test(continuation)) break;
combined += continuation;
j++;
}
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
}
return Array.from(new Set([...directMatches, ...wrappedMatches]));
}
function shouldAutoOpenUrlFromOutput(value = '') {
const normalized = value.toLowerCase();
return (
normalized.includes('browser didn\'t open') ||
normalized.includes('open this url') ||
normalized.includes('continue in your browser') ||
normalized.includes('press enter to open') ||
normalized.includes('open_url:')
);
}
// Single WebSocket server that handles both paths
const wss = new WebSocketServer({
@@ -491,15 +570,12 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
}
});
// Delete project endpoint
// force=true to allow removal even when sessions exist
// deleteData=true to also delete session/memory files on disk (destructive)
// Delete project endpoint (force=true to delete with sessions)
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const force = req.query.force === 'true';
const deleteData = req.query.deleteData === 'true';
await deleteProject(projectName, force, deleteData);
await deleteProject(projectName, force);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });

View File

@@ -1,306 +0,0 @@
import { getSessionMessages } from '@/projects.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'claude';
type ClaudeToolResult = {
content: unknown;
isError: boolean;
subagentTools?: unknown;
toolUseResult?: unknown;
};
type ClaudeHistoryResult =
| AnyRecord[]
| {
messages?: AnyRecord[];
total?: number;
hasMore?: boolean;
};
const loadClaudeSessionMessages = getSessionMessages as unknown as (
projectName: string,
sessionId: string,
limit: number | null,
offset: number,
) => Promise<ClaudeHistoryResult>;
/**
* Claude writes internal command and system reminder entries into history.
* Those are useful for the CLI but should not appear in the user-facing chat.
*/
const INTERNAL_CONTENT_PREFIXES = [
'<command-name>',
'<command-message>',
'<command-args>',
'<local-command-stdout>',
'<system-reminder>',
'Caveat:',
'This session is being continued from a previous',
'[Request interrupted',
] as const;
function isInternalContent(content: string): boolean {
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
}
export class ClaudeSessionsProvider implements IProviderSessions {
/**
* Normalizes one Claude JSONL entry or live SDK stream event into the shared
* message shape consumed by REST and WebSocket clients.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (!raw) {
return [];
}
if (raw.type === 'content_block_delta' && raw.delta?.text) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
}
if (raw.type === 'content_block_stop') {
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
}
const messages: NormalizedMessage[] = [];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('claude');
if (raw.message?.role === 'user' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
const part = raw.message.content[partIndex];
if (part.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: `${baseId}_tr_${part.tool_use_id}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id,
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
}));
} else if (part.type === 'text') {
const text = part.text || '';
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
}
if (messages.length === 0) {
const textParts = raw.message.content
.filter((part: AnyRecord) => part.type === 'text')
.map((part: AnyRecord) => part.text)
.filter(Boolean)
.join('\n');
if (textParts && !isInternalContent(textParts)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: textParts,
}));
}
}
} else if (typeof raw.message.content === 'string') {
const text = raw.message.content;
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
return messages;
}
if (raw.type === 'thinking' && raw.message?.content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message.content,
}));
return messages;
}
if (raw.type === 'tool_use' && raw.toolName) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName,
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
}));
return messages;
}
if (raw.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: false,
}));
return messages;
}
if (raw.message?.role === 'assistant' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
let partIndex = 0;
for (const part of raw.message.content) {
if (part.type === 'text' && part.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: part.text,
}));
} else if (part.type === 'tool_use') {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id,
}));
} else if (part.type === 'thinking' && part.thinking) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.thinking,
}));
}
partIndex++;
}
} else if (typeof raw.message.content === 'string') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message.content,
}));
}
return messages;
}
return messages;
}
/**
* Loads Claude JSONL history for a project/session and returns normalized
* messages, preserving the existing pagination behavior from projects.js.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectName, limit = null, offset = 0 } = options;
if (!projectName) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
let result: ClaudeHistoryResult;
try {
result = await loadClaudeSessionMessages(projectName, sessionId, limit, offset);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const toolResultMap = new Map<string, ClaudeToolResult>();
for (const raw of rawMessages) {
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result' && part.tool_use_id) {
toolResultMap.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
});
}
}
}
}
const normalized: NormalizedMessage[] = [];
for (const raw of rawMessages) {
normalized.push(...this.normalizeMessage(raw, sessionId));
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (!toolResult) {
continue;
}
msg.toolResult = {
content: typeof toolResult.content === 'string'
? toolResult.content
: JSON.stringify(toolResult.content),
isError: toolResult.isError,
toolUseResult: toolResult.toolUseResult,
};
msg.subagentTools = toolResult.subagentTools;
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
};
}
}

View File

@@ -1,15 +1,321 @@
import { getSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js';
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'claude';
type RawProviderMessage = Record<string, any>;
type ClaudeToolResult = {
content: unknown;
isError: boolean;
subagentTools?: unknown;
toolUseResult?: unknown;
};
type ClaudeHistoryResult =
| RawProviderMessage[]
| {
messages?: RawProviderMessage[];
total?: number;
hasMore?: boolean;
};
const loadClaudeSessionMessages = getSessionMessages as unknown as (
projectName: string,
sessionId: string,
limit: number | null,
offset: number,
) => Promise<ClaudeHistoryResult>;
/**
* Claude writes internal command and system reminder entries into history.
* Those are useful for the CLI but should not appear in the user-facing chat.
*/
const INTERNAL_CONTENT_PREFIXES = [
'<command-name>',
'<command-message>',
'<command-args>',
'<local-command-stdout>',
'<system-reminder>',
'Caveat:',
'This session is being continued from a previous',
'[Request interrupted',
] as const;
function isInternalContent(content: string): boolean {
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
}
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class ClaudeProvider extends AbstractProvider {
readonly mcp = new ClaudeMcpProvider();
readonly auth: IProviderAuth = new ClaudeProviderAuth();
readonly sessions: IProviderSessions = new ClaudeSessionsProvider();
constructor() {
super('claude');
}
/**
* Normalizes one Claude JSONL entry or live SDK stream event into the shared
* message shape consumed by REST and WebSocket clients.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (!raw) {
return [];
}
if (raw.type === 'content_block_delta' && raw.delta?.text) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
}
if (raw.type === 'content_block_stop') {
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
}
const messages: NormalizedMessage[] = [];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('claude');
if (raw.message?.role === 'user' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: `${baseId}_tr_${part.tool_use_id}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id,
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
}));
} else if (part.type === 'text') {
const text = part.text || '';
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
}
if (messages.length === 0) {
const textParts = raw.message.content
.filter((part: RawProviderMessage) => part.type === 'text')
.map((part: RawProviderMessage) => part.text)
.filter(Boolean)
.join('\n');
if (textParts && !isInternalContent(textParts)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: textParts,
}));
}
}
} else if (typeof raw.message.content === 'string') {
const text = raw.message.content;
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
return messages;
}
if (raw.type === 'thinking' && raw.message?.content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message.content,
}));
return messages;
}
if (raw.type === 'tool_use' && raw.toolName) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName,
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
}));
return messages;
}
if (raw.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: false,
}));
return messages;
}
if (raw.message?.role === 'assistant' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
let partIndex = 0;
for (const part of raw.message.content) {
if (part.type === 'text' && part.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: part.text,
}));
} else if (part.type === 'tool_use') {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id,
}));
} else if (part.type === 'thinking' && part.thinking) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.thinking,
}));
}
partIndex++;
}
} else if (typeof raw.message.content === 'string') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message.content,
}));
}
return messages;
}
return messages;
}
/**
* Loads Claude JSONL history for a project/session and returns normalized
* messages, preserving the existing pagination behavior from projects.js.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectName, limit = null, offset = 0 } = options;
if (!projectName) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
let result: ClaudeHistoryResult;
try {
result = await loadClaudeSessionMessages(projectName, sessionId, limit, offset);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const toolResultMap = new Map<string, ClaudeToolResult>();
for (const raw of rawMessages) {
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result' && part.tool_use_id) {
toolResultMap.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
});
}
}
}
}
const normalized: NormalizedMessage[] = [];
for (const raw of rawMessages) {
normalized.push(...this.normalizeMessage(raw, sessionId));
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (!toolResult) {
continue;
}
msg.toolResult = {
content: typeof toolResult.content === 'string'
? toolResult.content
: JSON.stringify(toolResult.content),
isError: toolResult.isError,
toolUseResult: toolResult.toolUseResult,
};
msg.subagentTools = toolResult.subagentTools;
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
};
}
}

View File

@@ -1,319 +0,0 @@
import { getCodexSessionMessages } from '@/projects.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'codex';
type CodexHistoryResult =
| AnyRecord[]
| {
messages?: AnyRecord[];
total?: number;
hasMore?: boolean;
tokenUsage?: unknown;
};
const loadCodexSessionMessages = getCodexSessionMessages as unknown as (
sessionId: string,
limit: number | null,
offset: number,
) => Promise<CodexHistoryResult>;
export class CodexSessionsProvider implements IProviderSessions {
/**
* Normalizes a persisted Codex JSONL entry.
*
* Live Codex SDK events are transformed before they reach normalizeMessage(),
* while history entries already use a compact message/tool shape from projects.js.
*/
private normalizeHistoryEntry(raw: AnyRecord, sessionId: string | null): NormalizedMessage[] {
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
if (raw.message?.role === 'user') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content
.map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n')
: String(raw.message.content || '');
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content,
})];
}
if (raw.message?.role === 'assistant') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content
.map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n')
: '';
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content,
})];
}
if (raw.type === 'thinking' || raw.isReasoning) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
}
if (raw.type === 'tool_use' || raw.toolName) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName || 'Unknown',
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: Boolean(raw.isError),
})];
}
return [];
}
/**
* Normalizes either a Codex history entry or a transformed live SDK event.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (!raw) {
return [];
}
if (raw.message?.role) {
return this.normalizeHistoryEntry(raw, sessionId);
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
if (raw.type === 'item') {
switch (raw.itemType) {
case 'agent_message':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message?.content || '',
})];
case 'reasoning':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
case 'command_execution':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'Bash',
toolInput: { command: raw.command },
toolId: baseId,
output: raw.output,
exitCode: raw.exitCode,
status: raw.status,
})];
case 'file_change':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'FileChanges',
toolInput: raw.changes,
toolId: baseId,
status: raw.status,
})];
case 'mcp_tool_call':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.tool || 'MCP',
toolInput: raw.arguments,
toolId: baseId,
server: raw.server,
result: raw.result,
error: raw.error,
status: raw.status,
})];
case 'web_search':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'WebSearch',
toolInput: { query: raw.query },
toolId: baseId,
})];
case 'todo_list':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'TodoList',
toolInput: { items: raw.items },
toolId: baseId,
})];
case 'error':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.message?.content || 'Unknown error',
})];
default:
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.itemType || 'Unknown',
toolInput: raw.item || raw,
toolId: baseId,
})];
}
}
if (raw.type === 'turn_complete') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'complete',
})];
}
if (raw.type === 'turn_failed') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.error?.message || 'Turn failed',
})];
}
return [];
}
/**
* Loads Codex JSONL history and keeps token usage metadata when projects.js
* provides it.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
let result: CodexHistoryResult;
try {
result = await loadCodexSessionMessages(sessionId, limit, offset);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
const normalized: NormalizedMessage[] = [];
for (const raw of rawMessages) {
normalized.push(...this.normalizeHistoryEntry(raw, sessionId));
}
const toolResultMap = new Map<string, NormalizedMessage>();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (toolResult) {
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
}
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
tokenUsage,
};
}
}

View File

@@ -1,15 +1,335 @@
import { getCodexSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js';
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js';
import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'codex';
type RawProviderMessage = Record<string, any>;
type CodexHistoryResult =
| RawProviderMessage[]
| {
messages?: RawProviderMessage[];
total?: number;
hasMore?: boolean;
tokenUsage?: unknown;
};
const loadCodexSessionMessages = getCodexSessionMessages as unknown as (
sessionId: string,
limit: number | null,
offset: number,
) => Promise<CodexHistoryResult>;
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class CodexProvider extends AbstractProvider {
readonly mcp = new CodexMcpProvider();
readonly auth: IProviderAuth = new CodexProviderAuth();
readonly sessions: IProviderSessions = new CodexSessionsProvider();
constructor() {
super('codex');
}
/**
* Normalizes a persisted Codex JSONL entry.
*
* Live Codex SDK events are transformed before they reach normalizeMessage(),
* while history entries already use a compact message/tool shape from projects.js.
*/
private normalizeHistoryEntry(raw: RawProviderMessage, sessionId: string | null): NormalizedMessage[] {
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
if (raw.message?.role === 'user') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n')
: String(raw.message.content || '');
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content,
})];
}
if (raw.message?.role === 'assistant') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n')
: '';
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content,
})];
}
if (raw.type === 'thinking' || raw.isReasoning) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
}
if (raw.type === 'tool_use' || raw.toolName) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName || 'Unknown',
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: Boolean(raw.isError),
})];
}
return [];
}
/**
* Normalizes either a Codex history entry or a transformed live SDK event.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (!raw) {
return [];
}
if (raw.message?.role) {
return this.normalizeHistoryEntry(raw, sessionId);
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
if (raw.type === 'item') {
switch (raw.itemType) {
case 'agent_message':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message?.content || '',
})];
case 'reasoning':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
case 'command_execution':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'Bash',
toolInput: { command: raw.command },
toolId: baseId,
output: raw.output,
exitCode: raw.exitCode,
status: raw.status,
})];
case 'file_change':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'FileChanges',
toolInput: raw.changes,
toolId: baseId,
status: raw.status,
})];
case 'mcp_tool_call':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.tool || 'MCP',
toolInput: raw.arguments,
toolId: baseId,
server: raw.server,
result: raw.result,
error: raw.error,
status: raw.status,
})];
case 'web_search':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'WebSearch',
toolInput: { query: raw.query },
toolId: baseId,
})];
case 'todo_list':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'TodoList',
toolInput: { items: raw.items },
toolId: baseId,
})];
case 'error':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.message?.content || 'Unknown error',
})];
default:
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.itemType || 'Unknown',
toolInput: raw.item || raw,
toolId: baseId,
})];
}
}
if (raw.type === 'turn_complete') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'complete',
})];
}
if (raw.type === 'turn_failed') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.error?.message || 'Turn failed',
})];
}
return [];
}
/**
* Loads Codex JSONL history and keeps token usage metadata when projects.js
* provides it.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
let result: CodexHistoryResult;
try {
result = await loadCodexSessionMessages(sessionId, limit, offset);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
const normalized: NormalizedMessage[] = [];
for (const raw of rawMessages) {
normalized.push(...this.normalizeHistoryEntry(raw, sessionId));
}
const toolResultMap = new Map<string, NormalizedMessage>();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (toolResult) {
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
}
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
tokenUsage,
};
}
}

View File

@@ -15,7 +15,7 @@ import {
export class CursorMcpProvider extends McpProvider {
constructor() {
super('cursor', ['user', 'project'], ['stdio', 'http']);
super('cursor', ['user', 'project'], ['stdio', 'http', 'sse']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
@@ -57,7 +57,7 @@ export class CursorMcpProvider extends McpProvider {
}
if (!input.url?.trim()) {
throw new AppError('url is required for http MCP servers.', {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});

View File

@@ -1,421 +0,0 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'cursor';
type CursorDbBlob = {
rowid: number;
id: string;
data?: Buffer;
};
type CursorJsonBlob = CursorDbBlob & {
parsed: AnyRecord;
};
type CursorMessageBlob = {
id: string;
sequence: number;
rowid: number;
content: AnyRecord;
};
function sanitizeCursorSessionId(sessionId: string): string {
const normalized = sessionId.trim();
if (!normalized) {
throw new Error('Cursor session id is required.');
}
if (
normalized.includes('..')
|| normalized.includes(path.posix.sep)
|| normalized.includes(path.win32.sep)
|| normalized !== path.basename(normalized)
) {
throw new Error(`Invalid cursor session id "${sessionId}".`);
}
return normalized;
}
export class CursorSessionsProvider implements IProviderSessions {
/**
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation
* order. Cursor history is stored as content-addressed blobs rather than JSONL.
*/
private async loadCursorBlobs(sessionId: string, projectPath: string): Promise<CursorMessageBlob[]> {
// Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable
const { default: Database } = await import('better-sqlite3');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const safeSessionId = sanitizeCursorSessionId(sessionId);
const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db');
const resolvedBaseChatsPath = path.resolve(baseChatsPath);
const resolvedStoreDbPath = path.resolve(storeDbPath);
const relativeStorePath = path.relative(resolvedBaseChatsPath, resolvedStoreDbPath);
if (relativeStorePath.startsWith('..') || path.isAbsolute(relativeStorePath)) {
throw new Error(`Invalid cursor session path for "${sessionId}".`);
}
const db = new Database(resolvedStoreDbPath, { readonly: true, fileMustExist: true });
try {
const allBlobs = db.prepare<[], CursorDbBlob>('SELECT rowid, id, data FROM blobs').all();
const blobMap = new Map<string, CursorDbBlob>();
const parentRefs = new Map<string, string[]>();
const childRefs = new Map<string, string[]>();
const jsonBlobs: CursorJsonBlob[] = [];
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
if (blob.data && blob.data[0] === 0x7B) {
try {
const parsed = JSON.parse(blob.data.toString('utf8')) as AnyRecord;
jsonBlobs.push({ ...blob, parsed });
} catch {
// Cursor can include binary or partial blobs; only JSON blobs become messages.
}
}
}
for (const blob of allBlobs) {
if (!blob.data || blob.data[0] === 0x7B) {
continue;
}
const parents: string[] = [];
let i = 0;
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
for (const parentId of parents) {
if (!childRefs.has(parentId)) {
childRefs.set(parentId, []);
}
childRefs.get(parentId)?.push(blob.id);
}
}
}
const visited = new Set<string>();
const sorted: CursorDbBlob[] = [];
const visit = (nodeId: string): void => {
if (visited.has(nodeId)) {
return;
}
visited.add(nodeId);
for (const parentId of parentRefs.get(nodeId) || []) {
visit(parentId);
}
const blob = blobMap.get(nodeId);
if (blob) {
sorted.push(blob);
}
};
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) {
visit(blob.id);
}
}
for (const blob of allBlobs) {
visit(blob.id);
}
const messageOrder = new Map<string, number>();
let orderIndex = 0;
for (const blob of sorted) {
if (blob.data && blob.data[0] !== 0x7B) {
for (const jsonBlob of jsonBlobs) {
try {
const idBytes = Buffer.from(jsonBlob.id, 'hex');
if (blob.data.includes(idBytes) && !messageOrder.has(jsonBlob.id)) {
messageOrder.set(jsonBlob.id, orderIndex++);
}
} catch {
// Ignore malformed blob ids that cannot be decoded as hex.
}
}
}
}
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const aOrder = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const bOrder = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return aOrder !== bOrder ? aOrder - bOrder : a.rowid - b.rowid;
});
const messages: CursorMessageBlob[] = [];
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
const blob = sortedJsonBlobs[idx];
const parsed = blob.parsed;
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') {
continue;
}
messages.push({
id: blob.id,
sequence: idx + 1,
rowid: blob.rowid,
content: parsed,
});
}
return messages;
} finally {
db.close();
}
}
/**
* Normalizes live Cursor CLI NDJSON events. Persisted Cursor history is
* normalized from SQLite blobs in fetchHistory().
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (raw?.type === 'assistant' && raw.message?.content?.[0]?.text) {
return [createNormalizedMessage({
kind: 'stream_delta',
content: raw.message.content[0].text,
sessionId,
provider: PROVIDER,
})];
}
if (typeof rawMessage === 'string' && rawMessage.trim()) {
return [createNormalizedMessage({
kind: 'stream_delta',
content: rawMessage,
sessionId,
provider: PROVIDER,
})];
}
return [];
}
/**
* Fetches and paginates Cursor session history from its project-scoped store.db.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectPath = '', limit = null, offset = 0 } = options;
try {
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
const total = allNormalized.length;
if (limit !== null) {
const start = offset;
const page = limit === 0
? []
: allNormalized.slice(start, start + limit);
const hasMore = limit === 0
? start < total
: start + limit < total;
return {
messages: page,
total,
hasMore,
offset,
limit,
};
}
return {
messages: allNormalized,
total,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[CursorProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
}
/**
* Converts Cursor SQLite message blobs into normalized messages and attaches
* matching tool results to their tool_use entries.
*/
private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
const messages: NormalizedMessage[] = [];
const toolUseMap = new Map<string, NormalizedMessage>();
const baseTime = Date.now();
for (let i = 0; i < blobs.length; i++) {
const blob = blobs[i];
const content = blob.content;
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
const baseId = blob.id || generateMessageId('cursor');
try {
if (!content?.role || !content?.content) {
if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') {
continue;
}
const role = content.message.role === 'user' ? 'user' : 'assistant';
let text = '';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
if (text?.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
}
continue;
}
if (content.role === 'system') {
continue;
}
if (content.role === 'tool') {
const toolItems = Array.isArray(content.content) ? content.content : [];
for (const item of toolItems) {
if (item?.type !== 'tool-result') {
continue;
}
const toolCallId = item.toolCallId || content.id;
messages.push(createNormalizedMessage({
id: `${baseId}_tr`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: toolCallId,
content: item.result || '',
isError: false,
}));
}
continue;
}
const role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
const part = content.content[partIdx];
if (part?.type === 'text' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: part.text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
} else if (part?.type === 'reasoning' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.text,
}));
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
const rawToolName = part.toolName || part.name || 'Unknown Tool';
const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName;
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
const message = createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: part.args || part.input,
toolId,
});
messages.push(message);
toolUseMap.set(toolId, message);
}
}
} else if (typeof content.content === 'string' && content.content.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: content.content,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
} catch (error) {
console.warn('Error normalizing cursor blob:', error);
}
}
for (const msg of messages) {
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
const toolUse = toolUseMap.get(msg.toolId);
if (toolUse) {
toolUse.toolResult = {
content: msg.content,
isError: msg.isError,
};
}
}
}
messages.sort((a, b) => {
if (a.sequence !== undefined && b.sequence !== undefined) {
return a.sequence - b.sequence;
}
if (a.rowid !== undefined && b.rowid !== undefined) {
return a.rowid - b.rowid;
}
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
return messages;
}
}

View File

@@ -1,15 +1,403 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'cursor';
type RawProviderMessage = Record<string, any>;
type CursorDbBlob = {
rowid: number;
id: string;
data?: Buffer;
};
type CursorJsonBlob = CursorDbBlob & {
parsed: RawProviderMessage;
};
type CursorMessageBlob = {
id: string;
sequence: number;
rowid: number;
content: RawProviderMessage;
};
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class CursorProvider extends AbstractProvider {
readonly mcp = new CursorMcpProvider();
readonly auth: IProviderAuth = new CursorProviderAuth();
readonly sessions: IProviderSessions = new CursorSessionsProvider();
constructor() {
super('cursor');
}
/**
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation
* order. Cursor history is stored as content-addressed blobs rather than JSONL.
*/
private async loadCursorBlobs(sessionId: string, projectPath: string): Promise<CursorMessageBlob[]> {
const sqlite3Module = await import('sqlite3');
const sqlite3 = sqlite3Module.default;
const { open } = await import('sqlite');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY,
});
try {
const allBlobs = await db.all('SELECT rowid, id, data FROM blobs') as CursorDbBlob[];
const blobMap = new Map<string, CursorDbBlob>();
const parentRefs = new Map<string, string[]>();
const childRefs = new Map<string, string[]>();
const jsonBlobs: CursorJsonBlob[] = [];
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
if (blob.data && blob.data[0] === 0x7B) {
try {
const parsed = JSON.parse(blob.data.toString('utf8')) as RawProviderMessage;
jsonBlobs.push({ ...blob, parsed });
} catch {
// Cursor can include binary or partial blobs; only JSON blobs become messages.
}
} else if (blob.data) {
const parents: string[] = [];
let i = 0;
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
for (const parentId of parents) {
if (!childRefs.has(parentId)) {
childRefs.set(parentId, []);
}
childRefs.get(parentId)?.push(blob.id);
}
}
}
}
const visited = new Set<string>();
const sorted: CursorDbBlob[] = [];
const visit = (nodeId: string): void => {
if (visited.has(nodeId)) {
return;
}
visited.add(nodeId);
for (const parentId of parentRefs.get(nodeId) || []) {
visit(parentId);
}
const blob = blobMap.get(nodeId);
if (blob) {
sorted.push(blob);
}
};
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) {
visit(blob.id);
}
}
for (const blob of allBlobs) {
visit(blob.id);
}
const messageOrder = new Map<string, number>();
let orderIndex = 0;
for (const blob of sorted) {
if (blob.data && blob.data[0] !== 0x7B) {
for (const jsonBlob of jsonBlobs) {
try {
const idBytes = Buffer.from(jsonBlob.id, 'hex');
if (blob.data.includes(idBytes) && !messageOrder.has(jsonBlob.id)) {
messageOrder.set(jsonBlob.id, orderIndex++);
}
} catch {
// Ignore malformed blob ids that cannot be decoded as hex.
}
}
}
}
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const aOrder = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const bOrder = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return aOrder !== bOrder ? aOrder - bOrder : a.rowid - b.rowid;
});
const messages: CursorMessageBlob[] = [];
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
const blob = sortedJsonBlobs[idx];
const parsed = blob.parsed;
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') {
continue;
}
messages.push({
id: blob.id,
sequence: idx + 1,
rowid: blob.rowid,
content: parsed,
});
}
return messages;
} finally {
await db.close();
}
}
/**
* Normalizes live Cursor CLI NDJSON events. Persisted Cursor history is
* normalized from SQLite blobs in fetchHistory().
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (raw?.type === 'assistant' && raw.message?.content?.[0]?.text) {
return [createNormalizedMessage({
kind: 'stream_delta',
content: raw.message.content[0].text,
sessionId,
provider: PROVIDER,
})];
}
if (typeof rawMessage === 'string' && rawMessage.trim()) {
return [createNormalizedMessage({
kind: 'stream_delta',
content: rawMessage,
sessionId,
provider: PROVIDER,
})];
}
return [];
}
/**
* Fetches and paginates Cursor session history from its project-scoped store.db.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectPath = '', limit = null, offset = 0 } = options;
try {
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
if (limit !== null && limit > 0) {
const start = offset;
const page = allNormalized.slice(start, start + limit);
return {
messages: page,
total: allNormalized.length,
hasMore: start + limit < allNormalized.length,
offset,
limit,
};
}
return {
messages: allNormalized,
total: allNormalized.length,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[CursorProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
}
/**
* Converts Cursor SQLite message blobs into normalized messages and attaches
* matching tool results to their tool_use entries.
*/
private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
const messages: NormalizedMessage[] = [];
const toolUseMap = new Map<string, NormalizedMessage>();
const baseTime = Date.now();
for (let i = 0; i < blobs.length; i++) {
const blob = blobs[i];
const content = blob.content;
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
const baseId = blob.id || generateMessageId('cursor');
try {
if (!content?.role || !content?.content) {
if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') {
continue;
}
const role = content.message.role === 'user' ? 'user' : 'assistant';
let text = '';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
if (text?.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
}
continue;
}
if (content.role === 'system') {
continue;
}
if (content.role === 'tool') {
const toolItems = Array.isArray(content.content) ? content.content : [];
for (const item of toolItems) {
if (item?.type !== 'tool-result') {
continue;
}
const toolCallId = item.toolCallId || content.id;
messages.push(createNormalizedMessage({
id: `${baseId}_tr`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: toolCallId,
content: item.result || '',
isError: false,
}));
}
continue;
}
const role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
const part = content.content[partIdx];
if (part?.type === 'text' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: part.text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
} else if (part?.type === 'reasoning' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.text,
}));
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
const rawToolName = part.toolName || part.name || 'Unknown Tool';
const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName;
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
const message = createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: part.args || part.input,
toolId,
});
messages.push(message);
toolUseMap.set(toolId, message);
}
}
} else if (typeof content.content === 'string' && content.content.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: content.content,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
} catch (error) {
console.warn('Error normalizing cursor blob:', error);
}
}
for (const msg of messages) {
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
const toolUse = toolUseMap.get(msg.toolId);
if (toolUse) {
toolUse.toolResult = {
content: msg.content,
isError: msg.isError,
};
}
}
}
messages.sort((a, b) => {
if (a.sequence !== undefined && b.sequence !== undefined) {
return a.sequence - b.sequence;
}
if (a.rowid !== undefined && b.rowid !== undefined) {
return a.rowid - b.rowid;
}
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
return messages;
}
}

View File

@@ -1,227 +0,0 @@
import sessionManager from '@/sessionManager.js';
import { getGeminiCliSessionMessages } from '@/projects.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'gemini';
export class GeminiSessionsProvider implements IProviderSessions {
/**
* Normalizes live Gemini stream-json events into the shared message shape.
*
* Gemini history uses a different session file shape, so fetchHistory handles
* that separately after loading raw persisted messages.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (!raw) {
return [];
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
if (raw.type === 'message' && raw.role === 'assistant') {
const content = raw.content || '';
const messages: NormalizedMessage[] = [];
if (content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_delta',
content,
}));
}
if (raw.delta !== true) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
}));
}
return messages;
}
if (raw.type === 'tool_use') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.tool_name,
toolInput: raw.parameters || {},
toolId: raw.tool_id || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.tool_id || '',
content: raw.output === undefined ? '' : String(raw.output),
isError: raw.status === 'error',
})];
}
if (raw.type === 'result') {
const messages = [createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
})];
if (raw.stats?.total_tokens) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'status',
text: 'Complete',
tokens: raw.stats.total_tokens,
canInterrupt: false,
}));
}
return messages;
}
if (raw.type === 'error') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.error || raw.message || 'Unknown Gemini streaming error',
})];
}
return [];
}
/**
* Loads Gemini history from the in-memory session manager first, then falls
* back to Gemini CLI session files on disk.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
let rawMessages: AnyRecord[];
try {
rawMessages = sessionManager.getSessionMessages(sessionId) as AnyRecord[];
if (rawMessages.length === 0) {
rawMessages = await getGeminiCliSessionMessages(sessionId) as AnyRecord[];
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const normalized: NormalizedMessage[] = [];
for (let i = 0; i < rawMessages.length; i++) {
const raw = rawMessages[i];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
const role = raw.message?.role || raw.role;
const content = raw.message?.content || raw.content;
if (!role || !content) {
continue;
}
const normalizedRole = role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content)) {
for (let partIdx = 0; partIdx < content.length; partIdx++) {
const part = content[partIdx];
if (part.type === 'text' && part.text) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part.text,
}));
} else if (part.type === 'tool_use') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id || generateMessageId('gemini_tool'),
}));
} else if (part.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id || '',
content: part.content === undefined ? '' : String(part.content),
isError: Boolean(part.is_error),
}));
}
}
} else if (typeof content === 'string' && content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content,
}));
}
}
const toolResultMap = new Map<string, NormalizedMessage>();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (toolResult) {
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
}
}
}
const start = Math.max(0, offset);
const pageLimit = limit === null ? null : Math.max(0, limit);
const messages = pageLimit === null
? normalized.slice(start)
: normalized.slice(start, start + pageLimit);
return {
messages,
total: normalized.length,
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
offset: start,
limit: pageLimit,
};
}
}

View File

@@ -1,15 +1,235 @@
import sessionManager from '@/sessionManager.js';
import { getGeminiCliSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'gemini';
type RawProviderMessage = Record<string, any>;
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class GeminiProvider extends AbstractProvider {
readonly mcp = new GeminiMcpProvider();
readonly auth: IProviderAuth = new GeminiProviderAuth();
readonly sessions: IProviderSessions = new GeminiSessionsProvider();
constructor() {
super('gemini');
}
/**
* Normalizes live Gemini stream-json events into the shared message shape.
*
* Gemini history uses a different session file shape, so fetchHistory handles
* that separately after loading raw persisted messages.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (!raw) {
return [];
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
if (raw.type === 'message' && raw.role === 'assistant') {
const content = raw.content || '';
const messages: NormalizedMessage[] = [];
if (content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_delta',
content,
}));
}
if (raw.delta !== true) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
}));
}
return messages;
}
if (raw.type === 'tool_use') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.tool_name,
toolInput: raw.parameters || {},
toolId: raw.tool_id || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.tool_id || '',
content: raw.output === undefined ? '' : String(raw.output),
isError: raw.status === 'error',
})];
}
if (raw.type === 'result') {
const messages = [createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
})];
if (raw.stats?.total_tokens) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'status',
text: 'Complete',
tokens: raw.stats.total_tokens,
canInterrupt: false,
}));
}
return messages;
}
if (raw.type === 'error') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.error || raw.message || 'Unknown Gemini streaming error',
})];
}
return [];
}
/**
* Loads Gemini history from the in-memory session manager first, then falls
* back to Gemini CLI session files on disk.
*/
async fetchHistory(
sessionId: string,
_options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
let rawMessages: RawProviderMessage[];
try {
rawMessages = sessionManager.getSessionMessages(sessionId) as RawProviderMessage[];
if (rawMessages.length === 0) {
rawMessages = await getGeminiCliSessionMessages(sessionId) as RawProviderMessage[];
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const normalized: NormalizedMessage[] = [];
for (let i = 0; i < rawMessages.length; i++) {
const raw = rawMessages[i];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
const role = raw.message?.role || raw.role;
const content = raw.message?.content || raw.content;
if (!role || !content) {
continue;
}
const normalizedRole = role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content)) {
for (let partIdx = 0; partIdx < content.length; partIdx++) {
const part = content[partIdx];
if (part.type === 'text' && part.text) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part.text,
}));
} else if (part.type === 'tool_use') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id || generateMessageId('gemini_tool'),
}));
} else if (part.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id || '',
content: part.content === undefined ? '' : String(part.content),
isError: Boolean(part.is_error),
}));
}
}
} else if (typeof content === 'string' && content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content,
}));
}
}
const toolResultMap = new Map<string, NormalizedMessage>();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (toolResult) {
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
}
}
}
return {
messages: normalized,
total: normalized.length,
hasMore: false,
offset: 0,
limit: null,
};
}
}

View File

@@ -148,7 +148,7 @@ router.get(
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const status = await providerAuthService.getProviderAuthStatus(provider);
res.json(createApiSuccessResponse(status));
res.json(status);
}),
);

View File

@@ -1,5 +1,5 @@
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { LLMProvider, ProviderAuthStatus } from '@/shared/types.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
export const providerAuthService = {
/**
@@ -9,18 +9,4 @@ export const providerAuthService = {
const provider = providerRegistry.resolveProvider(providerName);
return provider.auth.getStatus();
},
/**
* Returns whether a provider runtime appears installed.
* Falls back to true if status lookup itself fails so callers preserve the
* original runtime error instead of replacing it with a status-check failure.
*/
async isProviderInstalled(providerName: LLMProvider): Promise<boolean> {
try {
const status = await this.getProviderAuthStatus(providerName);
return status.installed;
} catch {
return true;
}
},
};

View File

@@ -29,7 +29,7 @@ export const sessionsService = {
raw: unknown,
sessionId: string | null,
): NormalizedMessage[] {
return providerRegistry.resolveProvider(providerName).sessions.normalizeMessage(raw, sessionId);
return providerRegistry.resolveProvider(providerName).normalizeMessage(raw, sessionId);
},
/**
@@ -40,6 +40,6 @@ export const sessionsService = {
sessionId: string,
options?: FetchHistoryOptions,
): Promise<FetchHistoryResult> {
return providerRegistry.resolveProvider(providerName).sessions.fetchHistory(sessionId, options);
return providerRegistry.resolveProvider(providerName).fetchHistory(sessionId, options);
},
};

View File

@@ -1,5 +1,10 @@
import type { IProvider, IProviderAuth, IProviderMcp, IProviderSessions } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js';
import type { IProvider, IProviderAuth, IProviderMcp } from '@/shared/interfaces.js';
import type {
FetchHistoryOptions,
FetchHistoryResult,
LLMProvider,
NormalizedMessage,
} from '@/shared/types.js';
/**
* Shared provider base.
@@ -12,9 +17,15 @@ export abstract class AbstractProvider implements IProvider {
readonly id: LLMProvider;
abstract readonly mcp: IProviderMcp;
abstract readonly auth: IProviderAuth;
abstract readonly sessions: IProviderSessions;
protected constructor(id: LLMProvider) {
this.id = id;
}
abstract normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[];
abstract fetchHistory(
sessionId: string,
options?: FetchHistoryOptions,
): Promise<FetchHistoryResult>;
}

View File

@@ -16,7 +16,6 @@
import { Codex } from '@openai/codex-sdk';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createNormalizedMessage } from './shared/utils.js';
// Track active sessions
@@ -309,14 +308,7 @@ export async function queryCodex(command, options = {}, ws) {
if (!wasAborted) {
console.error('[Codex] Error:', error);
// Check if Codex SDK is available for a clearer error message
const installed = await providerAuthService.isProviderInstalled('codex');
const errorContent = !installed
? 'Codex CLI is not configured. Please set up authentication first.'
: error.message;
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: currentSessionId, provider: 'codex' }));
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' }));
if (!terminalFailure) {
notifyRunFailed({
userId: ws?.userId || null,

View File

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

View File

@@ -6,7 +6,7 @@ import { CURSOR_MODELS } from '../../shared/modelConstants.js';
const router = express.Router();
// GET /api/cursor/config - Read Cursor CLI configuration.
// GET /api/cursor/config - Read Cursor CLI configuration
router.get('/config', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
@@ -21,9 +21,10 @@ router.get('/config', async (req, res) => {
path: configPath,
});
} catch (error) {
// Config doesn't exist or is invalid, so return the UI default shape.
// Config doesn't exist or is invalid
console.log('Cursor config not found or invalid:', error.message);
// Return default config
res.json({
success: true,
config: {

View File

@@ -19,7 +19,9 @@ export interface IProvider {
readonly id: LLMProvider;
readonly mcp: IProviderMcp;
readonly auth: IProviderAuth;
readonly sessions: IProviderSessions;
normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[];
fetchHistory(sessionId: string, options?: FetchHistoryOptions): Promise<FetchHistoryResult>;
}
@@ -44,11 +46,3 @@ export interface IProviderMcp {
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }>;
}
/**
* Session/history contract for one provider.
*/
export interface IProviderSessions {
normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[];
fetchHistory(sessionId: string, options?: FetchHistoryOptions): Promise<FetchHistoryResult>;
}

View File

@@ -5,7 +5,14 @@ export type ApiSuccessShape<TData = unknown> = {
data: TData;
};
export type AnyRecord = Record<string, any>;
export type ApiErrorShape = {
success: false;
error: {
code: string;
message: string;
details?: unknown;
};
};
// ---------------------------------------------------------------------------------------------

View File

@@ -6,7 +6,7 @@ import path from 'node:path';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import type {
AnyRecord,
ApiErrorShape,
ApiSuccessShape,
AppErrorOptions,
NormalizedMessage,
@@ -30,6 +30,21 @@ export function createApiSuccessResponse<TData>(
};
}
export function createApiErrorResponse(
code: string,
message: string,
details?: unknown
): ApiErrorShape {
return {
success: false,
error: {
code,
message,
details,
}
};
}
export function asyncHandler(
handler: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
): RequestHandler {
@@ -90,12 +105,12 @@ export function createNormalizedMessage(fields: NormalizedMessageInput): Normali
* treat the returned value as a JSON-style object map without repeating the same
* defensive shape checks at every config read site.
*/
export const readObjectRecord = (value: any): AnyRecord | null => {
export const readObjectRecord = (value: unknown): Record<string, unknown> | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as AnyRecord;
return value as Record<string, unknown>;
};
/**

View File

@@ -4,13 +4,10 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
// baseUrl is the project root (one level above this config file) so that tsc-alias
// resolves @/ imports relative to the compiled output structure in dist-server/server/.
// With rootDir ".." tsc emits server files under dist-server/server/, so paths must
// include the "server/" prefix to match that layout.
"baseUrl": "..",
"baseUrl": ".",
"paths": {
"@/*": ["server/*"]
// In the backend config, "@" maps to the /server directory itself.
"@/*": ["*"]
},
// The backend is still mostly JavaScript today, so allowJs lets us add a real
// TypeScript build without forcing a large rename before the tooling is usable.

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ export const CLAUDE_MODELS = {
{ value: "sonnet", label: "Sonnet" },
{ value: "opus", label: "Opus" },
{ value: "haiku", label: "Haiku" },
{ value: "claude-opus-4-6", label: "Opus 4.6" },
{ value: "opusplan", label: "Opus Plan" },
{ value: "sonnet[1m]", label: "Sonnet [1M]" },
{ value: "opus[1m]", label: "Opus [1M]" },

View File

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

View File

@@ -737,7 +737,7 @@ export function useChatComposerState({
}
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(expanded);
@@ -824,7 +824,7 @@ export function useChatComposerState({
(event: FormEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget;
target.style.height = 'auto';
target.style.height = `${Math.max(22, target.scrollHeight)}px`;
target.style.height = `${target.scrollHeight}px`;
setCursorPosition(target.selectionStart);
syncInputOverlayScroll(target);

View File

@@ -12,7 +12,7 @@ import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText }
* that the existing UI components expect.
*
* Internal/system content (e.g. <system-reminder>, <command-name>) is already
* filtered server-side by the Claude provider module.
* filtered server-side by the Claude adapter (server/providers/utils.js).
*/
export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMessage[] {
const converted: ChatMessage[] = [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import PermissionContext from '../../../contexts/PermissionContext';
import { QuickSettingsPanel } from '../../quick-settings-panel';
import type { ChatInterfaceProps, Provider } from '../types/types';
import type { LLMProvider } from '../../../types/app';
@@ -11,7 +9,6 @@ import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/useChatComposerState';
import { useSessionStore } from '../../../stores/useSessionStore';
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
@@ -270,11 +267,6 @@ function ChatInterface({
};
}, [resetStreamingState]);
const permissionContextValue = useMemo(() => ({
pendingPermissionRequests,
handlePermissionDecision,
}), [pendingPermissionRequests, handlePermissionDecision]);
if (!selectedProject) {
const selectedProviderLabel =
provider === 'cursor'
@@ -300,7 +292,7 @@ function ChatInterface({
}
return (
<PermissionContext.Provider value={permissionContextValue}>
<>
<div className="flex h-full flex-col">
<ChatMessagesPane
scrollContainerRef={scrollContainerRef}
@@ -401,6 +393,7 @@ function ChatInterface({
onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput}
onInputFocusChange={handleInputFocusChange}
isInputFocused={isInputFocused}
placeholder={t('input.placeholder', {
provider:
provider === 'cursor'
@@ -417,7 +410,7 @@ function ChatInterface({
</div>
<QuickSettingsPanel />
</PermissionContext.Provider>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,11 +18,6 @@ type ProviderAuthStatusPayload = {
error?: string | null;
};
type ProviderAuthStatusApiResponse = {
success: boolean;
data: ProviderAuthStatusPayload;
};
const FALLBACK_STATUS_ERROR = 'Failed to check authentication status';
const FALLBACK_UNKNOWN_ERROR = 'Unknown error';
@@ -87,8 +82,8 @@ export function useProviderAuthStatus(
return;
}
const payload = (await response.json()) as ProviderAuthStatusApiResponse;
setProviderStatus(provider, toProviderAuthStatus(payload.data));
const payload = (await response.json()) as ProviderAuthStatusPayload;
setProviderStatus(provider, toProviderAuthStatus(payload));
} catch (caughtError) {
console.error(`Error checking ${provider} auth status:`, caughtError);
setProviderStatus(provider, {

View File

@@ -452,7 +452,7 @@ export function useSidebarController({
[getProjectSessions],
);
const confirmDeleteProject = useCallback(async (deleteData = false) => {
const confirmDeleteProject = useCallback(async () => {
if (!deleteConfirmation) {
return;
}
@@ -464,7 +464,7 @@ export function useSidebarController({
setDeletingProjects((prev) => new Set([...prev, project.name]));
try {
const response = await api.deleteProject(project.name, !isEmpty, deleteData);
const response = await api.deleteProject(project.name, !isEmpty);
if (response.ok) {
onProjectDelete?.(project.name);

View File

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

View File

@@ -1,19 +0,0 @@
import { createContext, useContext } from 'react';
import type { PendingPermissionRequest } from '../components/chat/types/types';
export interface PermissionContextValue {
pendingPermissionRequests: PendingPermissionRequest[];
handlePermissionDecision: (
requestIds: string | string[],
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void;
}
const PermissionContext = createContext<PermissionContextValue | null>(null);
export function usePermission(): PermissionContextValue | null {
return useContext(PermissionContext);
}
export default PermissionContext;

View File

@@ -67,23 +67,6 @@ import deCodeEditor from './locales/de/codeEditor.json';
// eslint-disable-next-line import-x/order
import deTasks from './locales/de/tasks.json';
import trCommon from './locales/tr/common.json';
import trSettings from './locales/tr/settings.json';
import trAuth from './locales/tr/auth.json';
import trSidebar from './locales/tr/sidebar.json';
import trChat from './locales/tr/chat.json';
import trCodeEditor from './locales/tr/codeEditor.json';
// eslint-disable-next-line import-x/order
import trTasks from './locales/tr/tasks.json';
import itCommon from './locales/it/common.json';
import itSettings from './locales/it/settings.json';
import itAuth from './locales/it/auth.json';
import itSidebar from './locales/it/sidebar.json';
import itChat from './locales/it/chat.json';
import itCodeEditor from './locales/it/codeEditor.json';
// eslint-disable-next-line import-x/order
import itTasks from './locales/it/tasks.json';
// Import supported languages configuration
import { languages } from './languages.js';
@@ -160,24 +143,6 @@ i18n
codeEditor: deCodeEditor,
tasks: deTasks,
},
tr: {
common: trCommon,
settings: trSettings,
auth: trAuth,
sidebar: trSidebar,
chat: trChat,
codeEditor: trCodeEditor,
tasks: trTasks,
},
it: {
common: itCommon,
settings: itSettings,
auth: itAuth,
sidebar: itSidebar,
chat: itChat,
codeEditor: itCodeEditor,
tasks: itTasks,
},
},
// Default language

View File

@@ -39,14 +39,6 @@ export const languages = [
label: 'German',
nativeName: 'Deutsch',
},
{
value: 'tr',
label: 'Turkish',
nativeName: 'Türkçe',
value: 'it',
label: 'Italian',
nativeName: 'Italiano',
},
];
/**

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "Projekte",
"newProject": "Neues Projekt",
"deleteProject": "Projekt entfernen",
"deleteProject": "Projekt löschen",
"renameProject": "Projekt umbenennen",
"noProjects": "Keine Projekte gefunden",
"loadingProjects": "Projekte werden geladen...",
@@ -40,7 +40,7 @@
"createProject": "Neues Projekt erstellen",
"refresh": "Projekte und Sitzungen aktualisieren (Strg+R)",
"renameProject": "Projekt umbenennen (F2)",
"deleteProject": "Projekt aus Seitenleiste entfernen (Entf)",
"deleteProject": "Leeres Projekt löschen (Entf)",
"addToFavorites": "Zu Favoriten hinzufügen",
"removeFromFavorites": "Aus Favoriten entfernen",
"editSessionName": "Sitzungsname manuell bearbeiten",
@@ -95,14 +95,14 @@
"deleteSuccess": "Erfolgreich gelöscht",
"errorOccurred": "Ein Fehler ist aufgetreten",
"deleteSessionConfirm": "Möchtest du diese Sitzung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteProjectConfirm": "Projekt aus der Seitenleiste entfernen? Deine Projektdateien, Erinnerungen und Sitzungsdaten werden nicht gelöscht.",
"deleteProjectConfirm": "Möchtest du dieses leere Projekt wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"enterProjectPath": "Bitte gib einen Projektpfad ein",
"deleteSessionFailed": "Sitzung konnte nicht gelöscht werden. Bitte erneut versuchen.",
"deleteSessionError": "Fehler beim Löschen der Sitzung. Bitte erneut versuchen.",
"renameSessionFailed": "Sitzung konnte nicht umbenannt werden. Bitte erneut versuchen.",
"renameSessionError": "Fehler beim Umbenennen der Sitzung. Bitte erneut versuchen.",
"deleteProjectFailed": "Projekt konnte nicht entfernt werden. Bitte erneut versuchen.",
"deleteProjectError": "Fehler beim Entfernen des Projekts. Bitte erneut versuchen.",
"deleteProjectFailed": "Projekt konnte nicht gelöscht werden. Bitte erneut versuchen.",
"deleteProjectError": "Fehler beim Löschen des Projekts. Bitte erneut versuchen.",
"createProjectFailed": "Projekt konnte nicht erstellt werden. Bitte erneut versuchen.",
"createProjectError": "Fehler beim Erstellen des Projekts. Bitte erneut versuchen."
},
@@ -122,14 +122,12 @@
"projectsScanned_other": "{{count}} Projekte durchsucht"
},
"deleteConfirmation": {
"deleteProject": "Projekt entfernen",
"deleteProject": "Projekt löschen",
"deleteSession": "Sitzung löschen",
"confirmDelete": "Was möchtest du mit",
"confirmDelete": "Möchtest du wirklich löschen",
"sessionCount_one": "Dieses Projekt enthält {{count}} Unterhaltung.",
"sessionCount_other": "Dieses Projekt enthält {{count}} Unterhaltungen.",
"removeFromSidebar": "Nur aus der Seitenleiste entfernen",
"deleteAllData": "Alle Daten dauerhaft löschen",
"allConversationsDeleted": "Das Projekt wird aus der Seitenleiste entfernt. Deine Dateien, Erinnerungen und Sitzungsdaten bleiben erhalten.",
"cannotUndo": "Du kannst das Projekt später erneut hinzufügen."
"allConversationsDeleted": "Alle Unterhaltungen werden dauerhaft gelöscht.",
"cannotUndo": "Diese Aktion kann nicht rückgängig gemacht werden."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "Projects",
"newProject": "New Project",
"deleteProject": "Remove Project",
"deleteProject": "Delete Project",
"renameProject": "Rename Project",
"noProjects": "No projects found",
"loadingProjects": "Loading projects...",
@@ -40,7 +40,7 @@
"createProject": "Create new project",
"refresh": "Refresh projects and sessions (Ctrl+R)",
"renameProject": "Rename project (F2)",
"deleteProject": "Remove project from sidebar (Delete)",
"deleteProject": "Delete empty project (Delete)",
"addToFavorites": "Add to favorites",
"removeFromFavorites": "Remove from favorites",
"editSessionName": "Manually edit session name",
@@ -95,14 +95,14 @@
"deleteSuccess": "Deleted successfully",
"errorOccurred": "An error occurred",
"deleteSessionConfirm": "Are you sure you want to delete this session? This action cannot be undone.",
"deleteProjectConfirm": "Remove this project from the sidebar? Your project files, memories, and session data will not be deleted.",
"deleteProjectConfirm": "Are you sure you want to delete this empty project? This action cannot be undone.",
"enterProjectPath": "Please enter a project path",
"deleteSessionFailed": "Failed to delete session. Please try again.",
"deleteSessionError": "Error deleting session. Please try again.",
"renameSessionFailed": "Failed to rename session. Please try again.",
"renameSessionError": "Error renaming session. Please try again.",
"deleteProjectFailed": "Failed to remove project. Please try again.",
"deleteProjectError": "Error removing project. Please try again.",
"deleteProjectFailed": "Failed to delete project. Please try again.",
"deleteProjectError": "Error deleting project. Please try again.",
"createProjectFailed": "Failed to create project. Please try again.",
"createProjectError": "Error creating project. Please try again."
},
@@ -122,14 +122,12 @@
"projectsScanned_other": "{{count}} projects scanned"
},
"deleteConfirmation": {
"deleteProject": "Remove Project",
"deleteProject": "Delete Project",
"deleteSession": "Delete Session",
"confirmDelete": "What would you like to do with",
"confirmDelete": "Are you sure you want to delete",
"sessionCount_one": "This project contains {{count}} conversation.",
"sessionCount_other": "This project contains {{count}} conversations.",
"removeFromSidebar": "Remove from sidebar only",
"deleteAllData": "Delete all data permanently",
"allConversationsDeleted": "The project will be removed from the sidebar. Your files, memories, and session data will be preserved.",
"cannotUndo": "You can re-add the project later."
"allConversationsDeleted": "All conversations will be permanently deleted.",
"cannotUndo": "This action cannot be undone."
}
}

View File

@@ -1,37 +0,0 @@
{
"login": {
"title": "Bentornato",
"description": "Accedi al tuo account CloudCLI self-hosted",
"username": "Nome utente",
"password": "Password",
"submit": "Accedi",
"loading": "Accesso in corso...",
"errors": {
"invalidCredentials": "Nome utente o password non validi",
"requiredFields": "Compila tutti i campi",
"networkError": "Errore di rete. Riprova."
},
"placeholders": {
"username": "Inserisci il tuo nome utente",
"password": "Inserisci la tua password"
}
},
"register": {
"title": "Crea account",
"username": "Nome utente",
"password": "Password",
"confirmPassword": "Conferma password",
"submit": "Crea account",
"loading": "Creazione account...",
"errors": {
"passwordMismatch": "Le password non corrispondono",
"usernameTaken": "Nome utente già in uso",
"weakPassword": "La password è troppo debole"
}
},
"logout": {
"title": "Disconnetti",
"confirm": "Sei sicuro di volerti disconnettere?",
"button": "Disconnetti"
}
}

View File

@@ -1,272 +0,0 @@
{
"codeBlock": {
"copy": "Copia",
"copied": "Copiato",
"copyCode": "Copia codice"
},
"copyMessage": {
"copy": "Copia messaggio",
"copied": "Messaggio copiato",
"selectFormat": "Seleziona formato copia",
"copyAsMarkdown": "Copia come markdown",
"copyAsText": "Copia come testo"
},
"messageTypes": {
"user": "U",
"error": "Errore",
"tool": "Strumento",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
"gemini": "Gemini"
},
"tools": {
"settings": "Impostazioni strumento",
"error": "Errore strumento",
"result": "Risultato strumento",
"viewParams": "Vedi parametri input",
"viewRawParams": "Vedi parametri grezzi",
"viewDiff": "Vedi differenze per",
"creatingFile": "Creazione nuovo file:",
"updatingTodo": "Aggiornamento lista attività",
"read": "Leggi",
"readFile": "Leggi file",
"updateTodo": "Aggiorna lista attività",
"readTodo": "Leggi lista attività",
"searchResults": "risultati"
},
"search": {
"found": "Trovati {{count}} {{type}}",
"file": "file",
"files": "file",
"pattern": "pattern:",
"in": "in:"
},
"fileOperations": {
"updated": "File aggiornato con successo",
"created": "File creato con successo",
"written": "File scritto con successo",
"diff": "Differenze",
"newFile": "Nuovo file",
"viewContent": "Vedi contenuto file",
"viewFullOutput": "Vedi output completo ({{count}} caratteri)",
"contentDisplayed": "Il contenuto del file è visualizzato nella vista differenze sopra"
},
"interactive": {
"title": "Prompt interattivo",
"waiting": "In attesa della tua risposta nella CLI",
"instruction": "Seleziona un'opzione nel terminale dove Claude è in esecuzione.",
"selectedOption": "✓ Claude ha selezionato l'opzione {{number}}",
"instructionDetail": "Nella CLI, selezioneresti questa opzione interattivamente usando i tasti freccia o digitando il numero."
},
"thinking": {
"title": "Sto pensando...",
"emoji": "💭 Sto pensando..."
},
"json": {
"response": "Risposta JSON"
},
"permissions": {
"grant": "Concedi permesso per {{tool}}",
"added": "Permesso aggiunto",
"addTo": "Aggiunge {{entry}} agli strumenti consentiti.",
"retry": "Permesso salvato. Riprova la richiesta per usare lo strumento.",
"error": "Impossibile aggiornare i permessi. Riprova.",
"openSettings": "Apri impostazioni"
},
"todo": {
"updated": "Lista attività aggiornata con successo",
"current": "Lista attività corrente"
},
"plan": {
"viewPlan": "📋 Vedi piano di implementazione",
"title": "Piano di implementazione"
},
"usageLimit": {
"resetAt": "Limite di utilizzo Claude raggiunto. Il tuo limite verrà ripristinato alle **{{time}} {{timezone}}** - {{date}}"
},
"codex": {
"permissionMode": "Modalità permessi",
"modes": {
"default": "Modalità predefinita",
"acceptEdits": "Accetta modifiche",
"bypassPermissions": "Ignora permessi",
"plan": "Modalità piano"
},
"descriptions": {
"default": "Solo i comandi attendibili (ls, cat, grep, git status, ecc.) vengono eseguiti automaticamente. Gli altri comandi vengono saltati. Può scrivere nell'area di lavoro.",
"acceptEdits": "Tutti i comandi vengono eseguiti automaticamente nell'area di lavoro. Modalità completamente automatica con esecuzione sandboxed.",
"bypassPermissions": "Accesso completo al sistema senza restrizioni. Tutti i comandi vengono eseguiti automaticamente con accesso completo a disco e rete. Usa con cautela.",
"plan": "Modalità pianificazione - nessun comando viene eseguito"
},
"technicalDetails": "Dettagli tecnici"
},
"gemini": {
"permissionMode": "Modalità permessi Gemini",
"description": "Controlla come Gemini CLI gestisce le approvazioni delle operazioni.",
"modes": {
"default": {
"title": "Standard (chiedi approvazione)",
"description": "Gemini chiederà l'approvazione prima di eseguire comandi, scrivere file e recuperare risorse web."
},
"autoEdit": {
"title": "Modifica automatica (salta approvazioni file)",
"description": "Gemini approverà automaticamente modifiche ai file e recupero web, ma chiederà conferma per i comandi shell."
},
"yolo": {
"title": "YOLO (ignora tutti i permessi)",
"description": "Gemini eseguirà tutte le operazioni senza chiedere approvazione. Usa con cautela."
}
}
},
"input": {
"placeholder": "Digita / per i comandi, @ per i file, o chiedi qualcosa a {{provider}}...",
"placeholderDefault": "Scrivi il tuo messaggio...",
"disabled": "Input disabilitato",
"attachFiles": "Allega file",
"attachImages": "Allega immagini",
"send": "Invia",
"stop": "Ferma",
"hintText": {
"ctrlEnter": "Ctrl+Invio per inviare • Shift+Invio per nuova riga • Tab per cambiare modalità • / per comandi",
"enter": "Invio per inviare • Shift+Invio per nuova riga • Tab per cambiare modalità • / per comandi"
},
"clickToChangeMode": "Clicca per cambiare modalità permessi (o premi Tab nell'input)",
"showAllCommands": "Mostra tutti i comandi",
"clearInput": "Cancella input",
"scrollToBottom": "Scorri in basso"
},
"thinkingMode": {
"selector": {
"title": "Modalità ragionamento",
"description": "Il ragionamento esteso dà a Claude più tempo per valutare le alternative",
"active": "Attivo",
"tip": "Modalità di ragionamento più elevate richiedono più tempo ma forniscono un'analisi più approfondita"
},
"modes": {
"none": {
"name": "Standard",
"description": "Risposta Claude normale",
"prefix": ""
},
"think": {
"name": "Pensa",
"description": "Ragionamento esteso base",
"prefix": "think"
},
"thinkHard": {
"name": "Pensa di più",
"description": "Valutazione più approfondita",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Pensa ancora",
"description": "Analisi profonda con alternative",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultrapensiero",
"description": "Budget massimo di ragionamento",
"prefix": "ultrathink"
}
},
"buttonTitle": "Modalità ragionamento: {{mode}}"
},
"providerSelection": {
"title": "Scegli il tuo assistente AI",
"description": "Seleziona un provider per iniziare una nuova conversazione",
"selectModel": "Seleziona modello",
"providerInfo": {
"anthropic": "di Anthropic",
"openai": "di OpenAI",
"cursorEditor": "Editor codice AI",
"google": "di Google"
},
"readyPrompt": {
"claude": "Pronto a usare Claude con {{model}}. Inizia a digitare il tuo messaggio qui sotto.",
"cursor": "Pronto a usare Cursor con {{model}}. Inizia a digitare il tuo messaggio qui sotto.",
"codex": "Pronto a usare Codex con {{model}}. Inizia a digitare il tuo messaggio qui sotto.",
"gemini": "Pronto a usare Gemini con {{model}}. Inizia a digitare il tuo messaggio qui sotto.",
"default": "Seleziona un provider sopra per iniziare"
}
},
"session": {
"continue": {
"title": "Continua la tua conversazione",
"description": "Fai domande sul tuo codice, richiedi modifiche o chiedi aiuto con le attività di sviluppo"
},
"loading": {
"olderMessages": "Caricamento messaggi precedenti...",
"sessionMessages": "Caricamento messaggi della sessione..."
},
"messages": {
"showingOf": "Visualizzati {{shown}} di {{total}} messaggi",
"scrollToLoad": "Scorri in alto per caricare altri",
"showingLast": "Visualizzati ultimi {{count}} messaggi ({{total}} totali)",
"loadEarlier": "Carica messaggi precedenti",
"loadAll": "Carica tutti i messaggi",
"loadingAll": "Caricamento di tutti i messaggi...",
"allLoaded": "Tutti i messaggi caricati",
"perfWarning": "Tutti i messaggi caricati — lo scorrimento potrebbe essere più lento. Clicca \"Scorri in basso\" per ripristinare le prestazioni."
}
},
"shell": {
"selectProject": {
"title": "Seleziona un progetto",
"description": "Scegli un progetto per aprire una shell interattiva in quella directory"
},
"status": {
"newSession": "Nuova sessione",
"initializing": "Inizializzazione...",
"restarting": "Riavvio..."
},
"actions": {
"disconnect": "Disconnetti",
"disconnectTitle": "Disconnetti dalla shell",
"restart": "Riavvia",
"restartTitle": "Riavvia shell (disconnetti prima)",
"connect": "Continua nella shell",
"connectTitle": "Connetti alla shell"
},
"loading": "Caricamento terminale...",
"connecting": "Connessione alla shell...",
"startSession": "Avvia una nuova sessione Claude",
"resumeSession": "Riprendi sessione: {{displayName}}...",
"runCommand": "Esegui {{command}} in {{projectName}}",
"startCli": "Avvio Claude CLI in {{projectName}}",
"defaultCommand": "comando"
},
"claudeStatus": {
"actions": {
"thinking": "Ragionamento",
"processing": "Elaborazione",
"analyzing": "Analisi",
"working": "In lavorazione",
"computing": "Calcolo",
"reasoning": "Ragionamento"
},
"state": {
"live": "Attivo",
"paused": "In pausa"
},
"elapsed": {
"seconds": "{{count}}s",
"minutesSeconds": "{{minutes}}m {{seconds}}s",
"label": "{{time}} trascorsi",
"startingNow": "Avvio in corso"
},
"controls": {
"stopGeneration": "Interrompi generazione",
"pressEscToStop": "Premi Esc in qualsiasi momento per interrompere"
},
"providers": {
"assistant": "Assistente"
}
},
"projectSelection": {
"startChatWithProvider": "Seleziona un progetto per iniziare a chattare con {{provider}}"
},
"tasks": {
"nextTaskPrompt": "Inizia l'attività successiva"
}
}

View File

@@ -1,36 +0,0 @@
{
"toolbar": {
"changes": "modifiche",
"previousChange": "Modifica precedente",
"nextChange": "Modifica successiva",
"hideDiff": "Nascondi evidenziazione differenze",
"showDiff": "Mostra evidenziazione differenze",
"settings": "Impostazioni editor",
"collapse": "Comprimi editor",
"expand": "Espandi editor a larghezza piena"
},
"loading": "Caricamento {{fileName}}...",
"header": {
"showingChanges": "Visualizzazione modifiche"
},
"actions": {
"download": "Scarica file",
"save": "Salva",
"saving": "Salvataggio...",
"saved": "Salvato!",
"exitFullscreen": "Esci dalla modalità schermo intero",
"fullscreen": "Schermo intero",
"close": "Chiudi",
"previewMarkdown": "Anteprima markdown",
"editMarkdown": "Modifica markdown"
},
"footer": {
"lines": "Righe:",
"characters": "Caratteri:",
"shortcuts": "Premi Ctrl+S per salvare • Esc per chiudere"
},
"binaryFile": {
"title": "File binario",
"message": "Il file \"{{fileName}}\" non può essere visualizzato nell'editor di testo perché è un file binario."
}
}

View File

@@ -1,268 +0,0 @@
{
"buttons": {
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"create": "Crea",
"edit": "Modifica",
"close": "Chiudi",
"confirm": "Conferma",
"submit": "Invia",
"retry": "Riprova",
"refresh": "Aggiorna",
"search": "Cerca",
"clear": "Cancella",
"copy": "Copia",
"download": "Scarica",
"upload": "Carica",
"browse": "Sfoglia"
},
"tabs": {
"chat": "Chat",
"shell": "Terminale",
"files": "File",
"git": "Controllo Versione",
"tasks": "Attività"
},
"status": {
"loading": "Caricamento...",
"success": "Completato",
"error": "Errore",
"failed": "Fallito",
"pending": "In attesa",
"completed": "Completato",
"inProgress": "In corso"
},
"messages": {
"savedSuccessfully": "Salvato con successo",
"deletedSuccessfully": "Eliminato con successo",
"updatedSuccessfully": "Aggiornato con successo",
"operationFailed": "Operazione fallita",
"networkError": "Errore di rete. Controlla la tua connessione.",
"unauthorized": "Non autorizzato. Effettua l'accesso.",
"notFound": "Non trovato",
"invalidInput": "Input non valido",
"requiredField": "Questo campo è obbligatorio",
"unknownError": "Si è verificato un errore sconosciuto"
},
"navigation": {
"settings": "Impostazioni",
"home": "Home",
"back": "Indietro",
"next": "Avanti",
"previous": "Precedente",
"logout": "Esci"
},
"common": {
"language": "Lingua",
"theme": "Tema",
"darkMode": "Modalità scura",
"lightMode": "Modalità chiara",
"name": "Nome",
"description": "Descrizione",
"enabled": "Abilitato",
"disabled": "Disabilitato",
"optional": "Opzionale",
"version": "Versione",
"select": "Seleziona",
"selectAll": "Seleziona tutto",
"deselectAll": "Deseleziona tutto"
},
"time": {
"justNow": "Adesso",
"minutesAgo": "{{count}} min fa",
"hoursAgo": "{{count}} ore fa",
"daysAgo": "{{count}} giorni fa",
"yesterday": "Ieri"
},
"fileOperations": {
"newFile": "Nuovo file",
"newFolder": "Nuova cartella",
"rename": "Rinomina",
"move": "Sposta",
"copyPath": "Copia percorso",
"openInEditor": "Apri nell'editor"
},
"mainContent": {
"loading": "Caricamento CloudCLI",
"settingUpWorkspace": "Preparazione dell'area di lavoro...",
"chooseProject": "Scegli il tuo progetto",
"selectProjectDescription": "Seleziona un progetto dalla barra laterale per iniziare a programmare con Claude. Ogni progetto contiene le tue sessioni di chat e la cronologia dei file.",
"tip": "Suggerimento",
"createProjectMobile": "Tocca il pulsante menu in alto per accedere ai progetti",
"createProjectDesktop": "Crea un nuovo progetto cliccando l'icona cartella nella barra laterale",
"newSession": "Nuova sessione",
"untitledSession": "Sessione senza titolo",
"projectFiles": "File del progetto"
},
"fileTree": {
"loading": "Caricamento file...",
"files": "File",
"simpleView": "Vista semplice",
"compactView": "Vista compatta",
"detailedView": "Vista dettagliata",
"searchPlaceholder": "Cerca file e cartelle...",
"clearSearch": "Cancella ricerca",
"name": "Nome",
"size": "Dimensione",
"modified": "Modificato",
"permissions": "Permessi",
"noFilesFound": "Nessun file trovato",
"checkProjectPath": "Verifica che il percorso del progetto sia accessibile",
"noMatchesFound": "Nessuna corrispondenza trovata",
"tryDifferentSearch": "Prova con un termine di ricerca diverso o cancella la ricerca",
"justNow": "adesso",
"minAgo": "{{count}} min fa",
"hoursAgo": "{{count}} ore fa",
"daysAgo": "{{count}} giorni fa",
"newFile": "Nuovo file (Cmd+N)",
"newFolder": "Nuova cartella (Cmd+Shift+N)",
"refresh": "Aggiorna",
"collapseAll": "Comprimi tutto",
"context": {
"rename": "Rinomina",
"delete": "Elimina",
"copyPath": "Copia percorso",
"download": "Scarica",
"newFile": "Nuovo file",
"newFolder": "Nuova cartella",
"refresh": "Aggiorna",
"menuLabel": "Menu contestuale file",
"loading": "Caricamento..."
}
},
"projectWizard": {
"title": "Crea nuovo progetto",
"steps": {
"type": "Tipo",
"configure": "Configura",
"confirm": "Conferma"
},
"step1": {
"question": "Hai già un'area di lavoro o vuoi crearne una nuova?",
"existing": {
"title": "Area di lavoro esistente",
"description": "Ho già un'area di lavoro sul mio server e devo solo aggiungerla alla lista dei progetti"
},
"new": {
"title": "Nuova area di lavoro",
"description": "Crea una nuova area di lavoro, opzionalmente clonando da un repository GitHub"
}
},
"step2": {
"existingPath": "Percorso area di lavoro",
"newPath": "Percorso area di lavoro",
"existingPlaceholder": "/percorso/area-di-lavoro/esistente",
"newPlaceholder": "/percorso/nuova/area-di-lavoro",
"existingHelp": "Percorso completo della directory dell'area di lavoro esistente",
"newHelp": "Percorso completo della directory dell'area di lavoro",
"githubUrl": "URL GitHub (opzionale)",
"githubPlaceholder": "https://github.com/utente/repository",
"githubHelp": "Opzionale: fornisci un URL GitHub per clonare un repository",
"githubAuth": "Autenticazione GitHub (opzionale)",
"githubAuthHelp": "Richiesta solo per repository privati. I repository pubblici possono essere clonati senza autenticazione.",
"loadingTokens": "Caricamento token salvati...",
"storedToken": "Token salvato",
"newToken": "Nuovo token",
"nonePublic": "Nessuno (pubblico)",
"selectToken": "Seleziona token",
"selectTokenPlaceholder": "-- Seleziona un token --",
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tokenHelp": "Questo token verrà utilizzato solo per questa operazione",
"publicRepoInfo": "I repository pubblici non richiedono autenticazione. Puoi saltare il token se stai clonando un repository pubblico.",
"noTokensHelp": "Nessun token salvato disponibile. Puoi aggiungere token in Impostazioni → Chiavi API per un riutilizzo più semplice.",
"optionalTokenPublic": "Token GitHub (opzionale per repository pubblici)",
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (lascia vuoto per repository pubblici)"
},
"step3": {
"reviewConfig": "Rivedi la tua configurazione",
"workspaceType": "Tipo area di lavoro:",
"existingWorkspace": "Area di lavoro esistente",
"newWorkspace": "Nuova area di lavoro",
"path": "Percorso:",
"cloneFrom": "Clona da:",
"authentication": "Autenticazione:",
"usingStoredToken": "Usando token salvato:",
"usingProvidedToken": "Usando token fornito",
"noAuthentication": "Nessuna autenticazione",
"sshKey": "Chiave SSH",
"existingInfo": "L'area di lavoro verrà aggiunta alla lista dei progetti e sarà disponibile per le sessioni Claude/Cursor.",
"newWithClone": "Il repository verrà clonato da questa cartella.",
"newEmpty": "L'area di lavoro verrà aggiunta alla lista dei progetti e sarà disponibile per le sessioni Claude/Cursor.",
"cloningRepository": "Clonazione repository..."
},
"buttons": {
"cancel": "Annulla",
"back": "Indietro",
"next": "Avanti",
"createProject": "Crea progetto",
"creating": "Creazione...",
"cloning": "Clonazione..."
},
"errors": {
"selectType": "Seleziona se hai un'area di lavoro esistente o vuoi crearne una nuova",
"providePath": "Fornisci un percorso per l'area di lavoro",
"failedToCreate": "Impossibile creare l'area di lavoro",
"failedToCreateFolder": "Impossibile creare la cartella"
}
},
"notifications": {
"genericTool": "uno strumento",
"codes": {
"generic": {
"info": {
"title": "Notifica"
}
},
"permission": {
"required": {
"title": "Azione richiesta",
"body": "{{toolName}} è in attesa della tua decisione."
}
},
"run": {
"stopped": {
"title": "Esecuzione interrotta",
"body": "Motivo: {{reason}}"
},
"failed": {
"title": "Esecuzione fallita"
}
},
"agent": {
"notification": {
"title": "Notifica agente"
}
}
}
},
"versionUpdate": {
"title": "Aggiornamento disponibile",
"newVersionReady": "Una nuova versione è pronta",
"currentVersion": "Versione attuale",
"latestVersion": "Ultima versione",
"whatsNew": "Novità:",
"viewFullRelease": "Vedi release completa",
"updateProgress": "Progresso aggiornamento:",
"manualUpgrade": "Aggiornamento manuale:",
"npmUpgradeCommand": "npm install -g @cloudcli-ai/cloudcli@latest",
"manualUpgradeHint": "Oppure clicca \"Aggiorna ora\" per eseguire l'aggiornamento automaticamente.",
"updateCompleted": "Aggiornamento completato con successo!",
"restartServer": "Riavvia il server per applicare le modifiche.",
"updateFailed": "Aggiornamento fallito",
"buttons": {
"close": "Chiudi",
"later": "Più tardi",
"copyCommand": "Copia comando",
"updateNow": "Aggiorna ora",
"updating": "Aggiornamento..."
},
"ariaLabels": {
"closeModal": "Chiudi finestra aggiornamento versione",
"showSidebar": "Mostra barra laterale",
"settings": "Impostazioni",
"updateAvailable": "Aggiornamento disponibile",
"closeSidebar": "Chiudi barra laterale"
}
}
}

View File

@@ -1,490 +0,0 @@
{
"title": "Impostazioni",
"tabs": {
"account": "Account",
"permissions": "Permessi",
"mcpServers": "Server MCP",
"appearance": "Aspetto"
},
"account": {
"title": "Account",
"language": "Lingua",
"languageLabel": "Lingua dell'interfaccia",
"languageDescription": "Scegli la lingua preferita per l'interfaccia",
"username": "Nome utente",
"email": "Email",
"profile": "Profilo",
"changePassword": "Cambia password"
},
"mcp": {
"title": "Server MCP",
"addServer": "Aggiungi server",
"editServer": "Modifica server",
"deleteServer": "Elimina server",
"serverName": "Nome server",
"serverType": "Tipo server",
"config": "Configurazione",
"testConnection": "Testa connessione",
"status": "Stato",
"connected": "Connesso",
"disconnected": "Disconnesso",
"scope": {
"label": "Ambito",
"user": "Utente",
"project": "Progetto"
}
},
"appearance": {
"title": "Aspetto",
"theme": "Tema",
"codeEditor": "Editor codice",
"editorTheme": "Tema editor",
"wordWrap": "A capo automatico",
"showMinimap": "Mostra minimappa",
"lineNumbers": "Numeri di riga",
"fontSize": "Dimensione carattere"
},
"actions": {
"saveChanges": "Salva modifiche",
"resetToDefaults": "Ripristina predefiniti",
"cancelChanges": "Annulla modifiche"
},
"quickSettings": {
"title": "Impostazioni rapide",
"sections": {
"appearance": "Aspetto",
"toolDisplay": "Visualizzazione strumenti",
"viewOptions": "Opzioni visualizzazione",
"inputSettings": "Impostazioni input"
},
"darkMode": "Modalità scura",
"autoExpandTools": "Espandi strumenti automaticamente",
"showRawParameters": "Mostra parametri grezzi",
"showThinking": "Mostra ragionamento",
"autoScrollToBottom": "Scorrimento automatico in basso",
"sendByCtrlEnter": "Invia con Ctrl+Invio",
"sendByCtrlEnterDescription": "Se abilitato, premere Ctrl+Invio invierà il messaggio invece di Invio. Utile per gli utenti IME per evitare invii accidentali.",
"dragHandle": {
"dragging": "Trascinamento maniglia",
"closePanel": "Chiudi pannello impostazioni",
"openPanel": "Apri pannello impostazioni",
"draggingStatus": "Trascinamento...",
"toggleAndMove": "Clicca per attivare/disattivare, trascina per spostare"
}
},
"terminalShortcuts": {
"title": "Scorciatoie terminale",
"sectionKeys": "Tasti",
"sectionNavigation": "Navigazione",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "Freccia su",
"arrowDown": "Freccia giù",
"scrollDown": "Scorri giù",
"handle": {
"closePanel": "Chiudi pannello scorciatoie",
"openPanel": "Apri pannello scorciatoie"
}
},
"mainTabs": {
"label": "Impostazioni",
"agents": "Agenti",
"appearance": "Aspetto",
"git": "Git",
"apiTokens": "API e Token",
"tasks": "Attività",
"notifications": "Notifiche",
"plugins": "Plugin",
"about": "Informazioni"
},
"notifications": {
"title": "Notifiche",
"description": "Controlla quali notifiche ricevere.",
"webPush": {
"title": "Notifiche push web",
"enable": "Abilita notifiche push",
"disable": "Disabilita notifiche push",
"enabled": "Le notifiche push sono abilitate",
"loading": "Aggiornamento...",
"unsupported": "Le notifiche push non sono supportate in questo browser.",
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
},
"events": {
"title": "Tipi di evento",
"actionRequired": "Azione richiesta",
"stop": "Esecuzione interrotta",
"error": "Esecuzione fallita"
}
},
"appearanceSettings": {
"darkMode": {
"label": "Modalità scura",
"description": "Alterna tra tema chiaro e scuro"
},
"projectSorting": {
"label": "Ordinamento progetti",
"description": "Come vengono ordinati i progetti nella barra laterale",
"alphabetical": "Alfabetico",
"recentActivity": "Attività recente"
},
"codeEditor": {
"title": "Editor codice",
"theme": {
"label": "Tema editor",
"description": "Tema predefinito per l'editor di codice"
},
"wordWrap": {
"label": "A capo automatico",
"description": "Abilita il ritorno a capo automatico nell'editor"
},
"showMinimap": {
"label": "Mostra minimappa",
"description": "Visualizza una minimappa per facilitare la navigazione nella vista differenze"
},
"lineNumbers": {
"label": "Mostra numeri di riga",
"description": "Visualizza i numeri di riga nell'editor"
},
"fontSize": {
"label": "Dimensione carattere",
"description": "Dimensione del carattere dell'editor in pixel"
}
}
},
"mcpForm": {
"title": {
"add": "Aggiungi server MCP",
"edit": "Modifica server MCP"
},
"importMode": {
"form": "Input modulo",
"json": "Importa JSON"
},
"scope": {
"label": "Ambito",
"userGlobal": "Utente (globale)",
"projectLocal": "Progetto (locale)",
"userDescription": "Ambito utente: disponibile in tutti i progetti sulla tua macchina",
"projectDescription": "Ambito locale: disponibile solo nel progetto selezionato",
"cannotChange": "L'ambito non può essere modificato quando si modifica un server esistente"
},
"fields": {
"serverName": "Nome server",
"transportType": "Tipo di trasporto",
"command": "Comando",
"arguments": "Argomenti (uno per riga)",
"jsonConfig": "Configurazione JSON",
"url": "URL",
"envVars": "Variabili d'ambiente (CHIAVE=valore, una per riga)",
"headers": "Header (CHIAVE=valore, uno per riga)",
"selectProject": "Seleziona un progetto..."
},
"placeholders": {
"serverName": "mio-server"
},
"validation": {
"missingType": "Campo obbligatorio mancante: type",
"stdioRequiresCommand": "Il tipo stdio richiede un campo command",
"httpRequiresUrl": "Il tipo {{type}} richiede un campo url",
"invalidJson": "Formato JSON non valido",
"jsonHelp": "Incolla la configurazione del server MCP in formato JSON. Esempi di formato:",
"jsonExampleStdio": "• stdio: {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
"jsonExampleHttp": "• http/sse: {\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}"
},
"configDetails": "Dettagli configurazione (da {{configFile}})",
"projectPath": "Percorso: {{path}}",
"actions": {
"cancel": "Annulla",
"saving": "Salvataggio...",
"addServer": "Aggiungi server",
"updateServer": "Aggiorna server"
}
},
"saveStatus": {
"success": "Impostazioni salvate con successo!",
"error": "Impossibile salvare le impostazioni",
"saving": "Salvataggio..."
},
"footerActions": {
"save": "Salva impostazioni",
"cancel": "Annulla"
},
"git": {
"title": "Configurazione Git",
"description": "Configura la tua identità git per i commit. Queste impostazioni verranno applicate globalmente tramite git config --global",
"name": {
"label": "Nome Git",
"help": "Il tuo nome per i commit git"
},
"email": {
"label": "Email Git",
"help": "La tua email per i commit git"
},
"actions": {
"save": "Salva configurazione",
"saving": "Salvataggio..."
},
"status": {
"success": "Salvato con successo"
}
},
"apiKeys": {
"title": "Chiavi API",
"description": "Genera chiavi API per accedere all'API esterna da altre applicazioni.",
"newKey": {
"alertTitle": "⚠️ Salva la tua chiave API",
"alertMessage": "Questa è l'unica volta che vedrai questa chiave. Conservala in modo sicuro.",
"iveSavedIt": "L'ho salvata"
},
"form": {
"placeholder": "Nome chiave API (es. Server produzione)",
"createButton": "Crea",
"cancelButton": "Annulla"
},
"newButton": "Nuova chiave API",
"empty": "Nessuna chiave API creata.",
"list": {
"created": "Creata:",
"lastUsed": "Ultimo utilizzo:"
},
"confirmDelete": "Sei sicuro di voler eliminare questa chiave API?",
"status": {
"active": "Attiva",
"inactive": "Inattiva"
},
"github": {
"title": "Token GitHub",
"description": "Aggiungi token di accesso personale GitHub per clonare repository privati tramite l'API esterna.",
"descriptionAlt": "Aggiungi token di accesso personale GitHub per clonare repository privati. Puoi anche passare i token direttamente nelle richieste API senza salvarli.",
"addButton": "Aggiungi token",
"form": {
"namePlaceholder": "Nome token (es. Repository personali)",
"tokenPlaceholder": "Token di accesso personale GitHub (ghp_...)",
"descriptionPlaceholder": "Descrizione (opzionale)",
"addButton": "Aggiungi token",
"cancelButton": "Annulla",
"howToCreate": "Come creare un token di accesso personale GitHub →"
},
"empty": "Nessun token GitHub aggiunto.",
"added": "Aggiunto:",
"confirmDelete": "Sei sicuro di voler eliminare questo token GitHub?"
},
"apiDocsLink": "Documentazione API",
"documentation": {
"title": "Documentazione API esterna",
"description": "Scopri come usare l'API esterna per avviare sessioni Claude/Cursor dalle tue applicazioni.",
"viewLink": "Vedi documentazione API →"
},
"loading": "Caricamento...",
"version": {
"updateAvailable": "Aggiornamento disponibile: v{{version}}"
}
},
"tasks": {
"checking": "Verifica installazione TaskMaster...",
"notInstalled": {
"title": "TaskMaster AI CLI non installato",
"description": "TaskMaster CLI è necessario per usare le funzionalità di gestione attività. Installalo per iniziare:",
"installCommand": "npm install -g task-master-ai",
"viewOnGitHub": "Vedi su GitHub",
"afterInstallation": "Dopo l'installazione:",
"steps": {
"restart": "Riavvia questa applicazione",
"autoAvailable": "Le funzionalità TaskMaster saranno automaticamente disponibili",
"initCommand": "Usa task-master init nella directory del tuo progetto"
}
},
"settings": {
"enableLabel": "Abilita integrazione TaskMaster",
"enableDescription": "Mostra attività TaskMaster, banner e indicatori nella barra laterale nell'interfaccia"
}
},
"agents": {
"authStatus": {
"checking": "Verifica...",
"connected": "Connesso",
"notConnected": "Non connesso",
"disconnected": "Disconnesso",
"checkingAuth": "Verifica stato autenticazione...",
"loggedInAs": "Connesso come {{email}}",
"authenticatedUser": "utente autenticato"
},
"account": {
"claude": {
"description": "Assistente AI Anthropic Claude"
},
"cursor": {
"description": "Editor di codice potenziato da AI Cursor"
},
"codex": {
"description": "Assistente AI OpenAI Codex"
},
"gemini": {
"description": "Assistente AI Google Gemini"
}
},
"connectionStatus": "Stato connessione",
"login": {
"title": "Accedi",
"reAuthenticate": "Ri-autenticati",
"description": "Accedi al tuo account {{agent}} per abilitare le funzionalità AI",
"reAuthDescription": "Accedi con un account diverso o aggiorna le credenziali",
"button": "Accedi",
"reLoginButton": "Ri-accedi"
},
"error": "Errore: {{error}}"
},
"permissions": {
"title": "Impostazioni permessi",
"skipPermissions": {
"label": "Salta richieste di permesso (usa con cautela)",
"claudeDescription": "Equivalente al flag --dangerously-skip-permissions",
"cursorDescription": "Equivalente al flag -f in Cursor CLI"
},
"allowedTools": {
"title": "Strumenti consentiti",
"description": "Strumenti automaticamente consentiti senza richiedere permesso",
"placeholder": "es. \"Bash(git log:*)\" o \"Write\"",
"quickAdd": "Aggiunta rapida strumenti comuni:",
"empty": "Nessuno strumento consentito configurato"
},
"blockedTools": {
"title": "Strumenti bloccati",
"description": "Strumenti automaticamente bloccati senza richiedere permesso",
"placeholder": "es. \"Bash(rm:*)\"",
"empty": "Nessuno strumento bloccato configurato"
},
"allowedCommands": {
"title": "Comandi shell consentiti",
"description": "Comandi shell automaticamente consentiti senza richiedere permesso",
"placeholder": "es. \"Shell(ls)\" o \"Shell(git status)\"",
"quickAdd": "Aggiunta rapida comandi comuni:",
"empty": "Nessun comando consentito configurato"
},
"blockedCommands": {
"title": "Comandi shell bloccati",
"description": "Comandi shell automaticamente bloccati",
"placeholder": "es. \"Shell(rm -rf)\" o \"Shell(sudo)\"",
"empty": "Nessun comando bloccato configurato"
},
"toolExamples": {
"title": "Esempi pattern strumenti:",
"bashGitLog": "- Consenti tutti i comandi git log",
"bashGitDiff": "- Consenti tutti i comandi git diff",
"write": "- Consenti tutti gli utilizzi dello strumento Write",
"bashRm": "- Blocca tutti i comandi rm (pericoloso)"
},
"shellExamples": {
"title": "Esempi comandi shell:",
"ls": "- Consenti comando ls",
"gitStatus": "- Consenti git status",
"npmInstall": "- Consenti npm install",
"rmRf": "- Blocca eliminazione ricorsiva"
},
"codex": {
"permissionMode": "Modalità permessi",
"description": "Controlla come Codex gestisce le modifiche ai file e l'esecuzione dei comandi",
"modes": {
"default": {
"title": "Predefinito",
"description": "Solo i comandi attendibili (ls, cat, grep, git status, ecc.) vengono eseguiti automaticamente. Gli altri comandi vengono saltati. Può scrivere nell'area di lavoro."
},
"acceptEdits": {
"title": "Accetta modifiche",
"description": "Tutti i comandi vengono eseguiti automaticamente nell'area di lavoro. Modalità completamente automatica con esecuzione sandboxed."
},
"bypassPermissions": {
"title": "Ignora permessi",
"description": "Accesso completo al sistema senza restrizioni. Tutti i comandi vengono eseguiti automaticamente con accesso completo a disco e rete. Usa con cautela."
}
},
"technicalDetails": "Dettagli tecnici",
"technicalInfo": {
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted. Comandi attendibili: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (senza -exec), ecc.",
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never. Tutti i comandi vengono auto-eseguiti nella directory del progetto.",
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never. Accesso completo al sistema, usa solo in ambienti attendibili.",
"overrideNote": "Puoi sovrascrivere questa impostazione per sessione usando il pulsante modalità nell'interfaccia chat."
}
},
"actions": {
"add": "Aggiungi"
}
},
"mcpServers": {
"title": "Server MCP",
"description": {
"claude": "I server Model Context Protocol forniscono strumenti e fonti dati aggiuntive a Claude",
"cursor": "I server Model Context Protocol forniscono strumenti e fonti dati aggiuntive a Cursor",
"codex": "I server Model Context Protocol forniscono strumenti e fonti dati aggiuntive a Codex"
},
"addButton": "Aggiungi server MCP",
"empty": "Nessun server MCP configurato",
"serverType": "Tipo",
"scope": {
"local": "locale",
"user": "utente"
},
"config": {
"command": "Comando",
"url": "URL",
"args": "Argomenti",
"environment": "Ambiente"
},
"tools": {
"title": "Strumenti",
"count": "({{count}}):",
"more": "+{{count}} altri"
},
"actions": {
"edit": "Modifica server",
"delete": "Elimina server"
},
"help": {
"title": "Informazioni su Codex MCP",
"description": "Codex supporta server MCP basati su stdio. Puoi aggiungere server che estendono le capacità di Codex con strumenti e risorse aggiuntive."
}
},
"pluginSettings": {
"title": "Plugin",
"description": "Estendi l'interfaccia con plugin personalizzati. Installa da git o inserisci una cartella in ~/.claude-code-ui/plugins/",
"installPlaceholder": "https://github.com/utente/mio-plugin",
"installButton": "Installa",
"installing": "Installazione…",
"securityWarning": "Installa solo plugin di cui hai verificato il codice sorgente o di autori di cui ti fidi.",
"scanningPlugins": "Scansione plugin…",
"noPluginsInstalled": "Nessun plugin installato",
"pullLatest": "Aggiorna da git",
"noGitRemote": "Nessun remote git — aggiornamento non disponibile",
"uninstallPlugin": "Disinstalla plugin",
"confirmUninstall": "Clicca di nuovo per confermare",
"confirmUninstallMessage": "Rimuovere {{name}}? Questa azione non può essere annullata.",
"cancel": "Annulla",
"remove": "Rimuovi",
"updateFailed": "Aggiornamento fallito",
"installFailed": "Installazione fallita",
"uninstallFailed": "Disinstallazione fallita",
"toggleFailed": "Attivazione/disattivazione fallita",
"starterPluginLabel": "Plugin iniziale",
"starter": "Iniziale",
"docs": "Documentazione",
"starterPlugin": {
"name": "Statistiche progetto",
"badge": "iniziale",
"description": "Conteggio file, righe di codice, ripartizione per tipo di file e attività recente per il tuo progetto.",
"install": "Installa"
},
"terminalPlugin": {
"name": "Terminale",
"badge": "ufficiale",
"description": "Terminale integrato con accesso completo alla shell direttamente nell'interfaccia.",
"install": "Installa"
},
"morePlugins": "Altri",
"enable": "Abilita",
"disable": "Disabilita",
"installAriaLabel": "URL repository git del plugin",
"tab": "scheda",
"runningStatus": "in esecuzione"
}
}

View File

@@ -1,135 +0,0 @@
{
"projects": {
"title": "Progetti",
"newProject": "Nuovo progetto",
"deleteProject": "Rimuovi progetto",
"renameProject": "Rinomina progetto",
"noProjects": "Nessun progetto trovato",
"loadingProjects": "Caricamento progetti...",
"searchPlaceholder": "Cerca progetti...",
"projectNamePlaceholder": "Nome progetto",
"starred": "Preferiti",
"all": "Tutti",
"untitledSession": "Sessione senza titolo",
"newSession": "Nuova sessione",
"codexSession": "Sessione Codex",
"fetchingProjects": "Recupero dei tuoi progetti e sessioni Claude",
"projects": "progetti",
"noMatchingProjects": "Nessun progetto corrispondente",
"tryDifferentSearch": "Prova a modificare il termine di ricerca",
"runClaudeCli": "Esegui Claude CLI in una directory di progetto per iniziare"
},
"app": {
"title": "CloudCLI",
"subtitle": "Interfaccia assistente di programmazione AI"
},
"sessions": {
"title": "Sessioni",
"newSession": "Nuova sessione",
"deleteSession": "Elimina sessione",
"renameSession": "Rinomina sessione",
"noSessions": "Nessuna sessione",
"loadingSessions": "Caricamento sessioni...",
"unnamed": "Senza nome",
"loading": "Caricamento...",
"showMore": "Mostra più sessioni"
},
"tooltips": {
"viewEnvironments": "Visualizza ambienti",
"hideSidebar": "Nascondi barra laterale",
"createProject": "Crea nuovo progetto",
"refresh": "Aggiorna progetti e sessioni (Ctrl+R)",
"renameProject": "Rinomina progetto (F2)",
"deleteProject": "Rimuovi progetto dalla barra laterale (Canc)",
"addToFavorites": "Aggiungi ai preferiti",
"removeFromFavorites": "Rimuovi dai preferiti",
"editSessionName": "Modifica manualmente il nome della sessione",
"deleteSession": "Elimina questa sessione permanentemente",
"save": "Salva",
"cancel": "Annulla",
"clearSearch": "Cancella ricerca"
},
"navigation": {
"chat": "Chat",
"files": "File",
"git": "Git",
"terminal": "Terminale",
"tasks": "Attività"
},
"actions": {
"refresh": "Aggiorna",
"settings": "Impostazioni",
"collapseAll": "Comprimi tutto",
"expandAll": "Espandi tutto",
"cancel": "Annulla",
"save": "Salva",
"delete": "Elimina",
"rename": "Rinomina",
"joinCommunity": "Unisciti alla community",
"reportIssue": "Segnala problema",
"starOnGithub": "Metti stella su GitHub"
},
"branding": {
"openSource": "Open Source"
},
"status": {
"active": "Attivo",
"inactive": "Inattivo",
"thinking": "Sto pensando...",
"error": "Errore",
"aborted": "Interrotto",
"unknown": "Sconosciuto"
},
"time": {
"justNow": "Adesso",
"oneMinuteAgo": "1 min fa",
"minutesAgo": "{{count}} min fa",
"oneHourAgo": "1 ora fa",
"hoursAgo": "{{count}} ore fa",
"oneDayAgo": "1 giorno fa",
"daysAgo": "{{count}} giorni fa"
},
"messages": {
"deleteConfirm": "Sei sicuro di voler eliminare questo elemento?",
"renameSuccess": "Rinominato con successo",
"deleteSuccess": "Eliminato con successo",
"errorOccurred": "Si è verificato un errore",
"deleteSessionConfirm": "Sei sicuro di voler eliminare questa sessione? Questa azione non può essere annullata.",
"deleteProjectConfirm": "Rimuovere questo progetto dalla barra laterale? I file del progetto, le memorie e i dati delle sessioni non verranno eliminati.",
"enterProjectPath": "Inserisci un percorso di progetto",
"deleteSessionFailed": "Impossibile eliminare la sessione. Riprova.",
"deleteSessionError": "Errore durante l'eliminazione della sessione. Riprova.",
"renameSessionFailed": "Impossibile rinominare la sessione. Riprova.",
"renameSessionError": "Errore durante la rinomina della sessione. Riprova.",
"deleteProjectFailed": "Impossibile rimuovere il progetto. Riprova.",
"deleteProjectError": "Errore durante la rimozione del progetto. Riprova.",
"createProjectFailed": "Impossibile creare il progetto. Riprova.",
"createProjectError": "Errore durante la creazione del progetto. Riprova."
},
"version": {
"updateAvailable": "Aggiornamento disponibile"
},
"search": {
"modeProjects": "Progetti",
"modeConversations": "Conversazioni",
"conversationsPlaceholder": "Cerca nelle conversazioni...",
"searching": "Ricerca in corso...",
"noResults": "Nessun risultato trovato",
"tryDifferentQuery": "Prova con una ricerca diversa",
"matches_one": "{{count}} corrispondenza",
"matches_other": "{{count}} corrispondenze",
"projectsScanned_one": "{{count}} progetto analizzato",
"projectsScanned_other": "{{count}} progetti analizzati"
},
"deleteConfirmation": {
"deleteProject": "Rimuovi progetto",
"deleteSession": "Elimina sessione",
"confirmDelete": "Cosa vuoi fare con",
"sessionCount_one": "Questo progetto contiene {{count}} conversazione.",
"sessionCount_other": "Questo progetto contiene {{count}} conversazioni.",
"removeFromSidebar": "Rimuovi solo dalla barra laterale",
"deleteAllData": "Elimina tutti i dati permanentemente",
"allConversationsDeleted": "Il progetto verrà rimosso dalla barra laterale. I tuoi file, memorie e dati delle sessioni verranno preservati.",
"cannotUndo": "Puoi riaggiungerlo in seguito."
}
}

View File

@@ -1,142 +0,0 @@
{
"notConfigured": {
"title": "TaskMaster AI non è configurato",
"description": "TaskMaster aiuta a suddividere progetti complessi in attività gestibili con assistenza AI",
"whatIsTitle": "🎯 Cos'è TaskMaster?",
"features": {
"aiPowered": "Gestione attività AI: suddividi progetti complessi in sotto-attività gestibili",
"prdTemplates": "Template PRD: genera attività da documenti di requisiti del prodotto",
"dependencyTracking": "Tracciamento dipendenze: comprendi le relazioni tra attività e l'ordine di esecuzione",
"progressVisualization": "Visualizzazione progresso: board Kanban e analisi dettagliata delle attività",
"cliIntegration": "Integrazione CLI: usa i comandi taskmaster per flussi di lavoro avanzati"
},
"initializeButton": "Inizializza TaskMaster AI"
},
"gettingStarted": {
"title": "Inizia con TaskMaster",
"subtitle": "TaskMaster è inizializzato! Ecco cosa fare dopo:",
"steps": {
"createPRD": {
"title": "Crea un documento di requisiti del prodotto (PRD)",
"description": "Discuti la tua idea di progetto e crea un PRD che descriva cosa vuoi costruire.",
"addButton": "Aggiungi PRD",
"existingPRDs": "PRD esistenti:"
},
"generateTasks": {
"title": "Genera attività dal PRD",
"description": "Una volta che hai un PRD, chiedi al tuo assistente AI di analizzarlo e TaskMaster lo suddividerà automaticamente in attività gestibili con dettagli di implementazione."
},
"analyzeTasks": {
"title": "Analizza ed espandi le attività",
"description": "Chiedi al tuo assistente AI di analizzare la complessità delle attività ed espanderle in sotto-attività dettagliate per un'implementazione più semplice."
},
"startBuilding": {
"title": "Inizia a costruire",
"description": "Chiedi al tuo assistente AI di iniziare a lavorare sulle attività, aggiornare il loro stato e aggiungere nuove attività man mano che il tuo progetto evolve."
}
},
"tip": "💡 Suggerimento: inizia con un PRD per ottenere il massimo dalla generazione di attività AI di TaskMaster"
},
"setupModal": {
"title": "Configurazione TaskMaster",
"subtitle": "CLI interattiva per {{projectName}}",
"willStart": "L'inizializzazione di TaskMaster partirà automaticamente",
"completed": "Configurazione TaskMaster completata! Ora puoi chiudere questa finestra.",
"closeButton": "Chiudi",
"closeContinueButton": "Chiudi e continua"
},
"helpGuide": {
"title": "Inizia con TaskMaster",
"subtitle": "La tua guida per una gestione produttiva delle attività",
"examples": {
"parsePRD": "💬 Esempio:\n\"Ho appena inizializzato un nuovo progetto con Claude Task Master. Ho un PRD in .taskmaster/docs/prd.txt. Puoi aiutarmi ad analizzarlo e configurare le attività iniziali?\"",
"expandTask": "💬 Esempio:\n\"L'attività 5 sembra complessa. Puoi suddividerla in sotto-attività?\"",
"addTask": "💬 Esempio:\n\"Per favore aggiungi una nuova attività per implementare il caricamento delle immagini profilo utente usando Cloudinary, ricerca l'approccio migliore.\""
},
"moreExamples": "Vedi altri esempi e pattern di utilizzo →",
"proTips": {
"title": "💡 Suggerimenti pro",
"search": "Usa la barra di ricerca per trovare rapidamente attività specifiche",
"views": "Passa tra le viste Kanban, Lista e Griglia usando i selettori di vista",
"filters": "Usa i filtri per concentrarti su stati o priorità specifiche delle attività",
"details": "Clicca su qualsiasi attività per vedere informazioni dettagliate e gestire le sotto-attività"
},
"learnMore": {
"title": "📚 Per saperne di più",
"description": "TaskMaster AI è un sistema avanzato di gestione attività pensato per sviluppatori. Trova documentazione, esempi e contribuisci al progetto.",
"githubButton": "Vedi su GitHub"
}
},
"search": {
"placeholder": "Cerca attività..."
},
"filters": {
"button": "Filtri",
"status": "Stato",
"priority": "Priorità",
"sortBy": "Ordina per",
"allStatuses": "Tutti gli stati",
"allPriorities": "Tutte le priorità",
"showing": "Visualizzate {{filtered}} di {{total}} attività",
"clearFilters": "Cancella filtri"
},
"sort": {
"id": "ID",
"status": "Stato",
"priority": "Priorità",
"idAsc": "ID (crescente)",
"idDesc": "ID (decrescente)",
"titleAsc": "Titolo (A-Z)",
"titleDesc": "Titolo (Z-A)",
"statusAsc": "Stato (in attesa prima)",
"statusDesc": "Stato (completati prima)",
"priorityAsc": "Priorità (alta prima)",
"priorityDesc": "Priorità (bassa prima)"
},
"views": {
"kanban": "Vista Kanban",
"list": "Vista lista",
"grid": "Vista griglia"
},
"kanban": {
"pending": "📋 Da fare",
"inProgress": "🚀 In corso",
"done": "✅ Completate",
"blocked": "🚫 Bloccate",
"deferred": "⏳ Rimandate",
"cancelled": "❌ Annullate",
"noTasksYet": "Nessuna attività",
"tasksWillAppear": "Le attività appariranno qui",
"moveTasksHere": "Sposta le attività qui quando iniziate",
"completedTasksHere": "Le attività completate appariranno qui",
"statusTasksHere": "Le attività con questo stato appariranno qui"
},
"buttons": {
"help": "Guida introduttiva TaskMaster",
"prds": "PRD",
"addPRD": "Aggiungi PRD",
"addTask": "Aggiungi attività",
"createNewPRD": "Crea nuovo PRD",
"prdsAvailable": "{{count}} PRD disponibili"
},
"prd": {
"modified": "Modificato: {{date}}"
},
"statuses": {
"pending": "In attesa",
"in-progress": "In corso",
"done": "Completata",
"blocked": "Bloccata",
"deferred": "Rimandata",
"cancelled": "Annullata"
},
"priorities": {
"high": "Alta",
"medium": "Media",
"low": "Bassa"
},
"noMatchingTasks": {
"title": "Nessuna attività corrisponde ai filtri",
"description": "Prova a modificare la ricerca o i criteri di filtro."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "プロジェクト",
"newProject": "新規プロジェクト",
"deleteProject": "プロジェクトを除",
"deleteProject": "プロジェクトを除",
"renameProject": "プロジェクト名を変更",
"noProjects": "プロジェクトが見つかりません",
"loadingProjects": "プロジェクトを読み込んでいます...",
@@ -40,7 +40,7 @@
"createProject": "新しいプロジェクトを作成",
"refresh": "プロジェクトとセッションを更新 (Ctrl+R)",
"renameProject": "プロジェクト名を変更 (F2)",
"deleteProject": "サイドバーからプロジェクトを除 (Delete)",
"deleteProject": "空のプロジェクトを除 (Delete)",
"addToFavorites": "お気に入りに追加",
"removeFromFavorites": "お気に入りから削除",
"editSessionName": "セッション名を手動で編集",
@@ -94,14 +94,14 @@
"deleteSuccess": "削除しました",
"errorOccurred": "エラーが発生しました",
"deleteSessionConfirm": "このセッションを削除してもよろしいですか?この操作は取り消せません。",
"deleteProjectConfirm": "サイドバーからこのプロジェクトを除去しますか?プロジェクトファイル、メモリ、セッションデータは削除されません。",
"deleteProjectConfirm": "この空のプロジェクトを削除してもよろしいですか?この操作は取り消せません。",
"enterProjectPath": "プロジェクトのパスを入力してください",
"deleteSessionFailed": "セッションの削除に失敗しました。もう一度お試しください。",
"deleteSessionError": "セッションの削除でエラーが発生しました。もう一度お試しください。",
"renameSessionFailed": "セッション名の変更に失敗しました。もう一度お試しください。",
"renameSessionError": "セッション名の変更でエラーが発生しました。もう一度お試しください。",
"deleteProjectFailed": "プロジェクトの除に失敗しました。もう一度お試しください。",
"deleteProjectError": "プロジェクトの除でエラーが発生しました。もう一度お試しください。",
"deleteProjectFailed": "プロジェクトの除に失敗しました。もう一度お試しください。",
"deleteProjectError": "プロジェクトの除でエラーが発生しました。もう一度お試しください。",
"createProjectFailed": "プロジェクトの作成に失敗しました。もう一度お試しください。",
"createProjectError": "プロジェクトの作成でエラーが発生しました。もう一度お試しください。"
},
@@ -109,13 +109,11 @@
"updateAvailable": "アップデートあり"
},
"deleteConfirmation": {
"deleteProject": "プロジェクトを除",
"deleteProject": "プロジェクトを除",
"deleteSession": "セッションを削除",
"confirmDelete": "このプロジェクトをどうしますか",
"confirmDelete": "本当に削除しますか",
"sessionCount": "このプロジェクトには{{count}}件の会話があります。",
"removeFromSidebar": "サイドバーからのみ除去",
"deleteAllData": "すべてのデータを完全に削除",
"allConversationsDeleted": "プロジェクトはサイドバーから除去されます。ファイル、メモリ、セッションデータは保持されます。",
"cannotUndo": "後からプロジェクトを再追加できます。"
"allConversationsDeleted": "すべての会話が完全に削除されます。",
"cannotUndo": "この操作は取り消せません。"
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "프로젝트",
"newProject": "새 프로젝트",
"deleteProject": "프로젝트 제",
"deleteProject": "프로젝트 제",
"renameProject": "프로젝트 이름 변경",
"noProjects": "프로젝트가 없습니다",
"loadingProjects": "프로젝트 로딩 중...",
@@ -40,7 +40,7 @@
"createProject": "새 프로젝트 생성",
"refresh": "프로젝트 및 세션 새로고침 (Ctrl+R)",
"renameProject": "프로젝트 이름 변경 (F2)",
"deleteProject": "사이드바에서 프로젝트 제 (Delete)",
"deleteProject": " 프로젝트 제 (Delete)",
"addToFavorites": "즐겨찾기에 추가",
"removeFromFavorites": "즐겨찾기에서 제거",
"editSessionName": "세션 이름 직접 편집",
@@ -94,14 +94,14 @@
"deleteSuccess": "삭제되었습니다",
"errorOccurred": "오류가 발생했습니다",
"deleteSessionConfirm": "이 세션을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"deleteProjectConfirm": "사이드바에서 이 프로젝트를 제하시겠습니까? 프로젝트 파일, 메모리 및 세션 데이터는 삭제되지 않습니다.",
"deleteProjectConfirm": "이 프로젝트를 제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"enterProjectPath": "프로젝트 경로를 입력해주세요",
"deleteSessionFailed": "세션 삭제 실패. 다시 시도해주세요.",
"deleteSessionError": "세션 삭제 오류. 다시 시도해주세요.",
"renameSessionFailed": "세션 이름 변경 실패. 다시 시도해주세요.",
"renameSessionError": "세션 이름 변경 오류. 다시 시도해주세요.",
"deleteProjectFailed": "프로젝트 제 실패. 다시 시도해주세요.",
"deleteProjectError": "프로젝트 제 오류. 다시 시도해주세요.",
"deleteProjectFailed": "프로젝트 제 실패. 다시 시도해주세요.",
"deleteProjectError": "프로젝트 제 오류. 다시 시도해주세요.",
"createProjectFailed": "프로젝트 생성 실패. 다시 시도해주세요.",
"createProjectError": "프로젝트 생성 오류. 다시 시도해주세요."
},
@@ -109,14 +109,12 @@
"updateAvailable": "업데이트 가능"
},
"deleteConfirmation": {
"deleteProject": "프로젝트 제",
"deleteProject": "프로젝트 제",
"deleteSession": "세션 삭제",
"confirmDelete": "이 프로젝트를 어떻게 하시겠습니까:",
"confirmDelete": "정말 삭제하시겠습니까",
"sessionCount_one": "이 프로젝트에는 {{count}}개의 대화가 있습니다.",
"sessionCount_other": "이 프로젝트에는 {{count}}개의 대화가 있습니다.",
"removeFromSidebar": "사이드바에서만 제거",
"deleteAllData": "모든 데이터 영구 삭제",
"allConversationsDeleted": "프로젝트가 사이드바에서 제거됩니다. 파일, 메모리 및 세션 데이터는 보존됩니다.",
"cannotUndo": "나중에 프로젝트를 다시 추가할 수 있습니다."
"allConversationsDeleted": "모든 대화가 영구적으로 삭제됩니다.",
"cannotUndo": "이 작업은 취소할 수 없습니다."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "Проекты",
"newProject": "Новый проект",
"deleteProject": "Убрать проект",
"deleteProject": "Удалить проект",
"renameProject": "Переименовать проект",
"noProjects": "Проекты не найдены",
"loadingProjects": "Загрузка проектов...",
@@ -40,7 +40,7 @@
"createProject": "Создать новый проект",
"refresh": "Обновить проекты и сеансы (Ctrl+R)",
"renameProject": "Переименовать проект (F2)",
"deleteProject": "Убрать проект из боковой панели (Delete)",
"deleteProject": "Удалить пустой проект (Delete)",
"addToFavorites": "Добавить в избранное",
"removeFromFavorites": "Удалить из избранного",
"editSessionName": "Вручную редактировать имя сеанса",
@@ -95,14 +95,14 @@
"deleteSuccess": "Успешно удалено",
"errorOccurred": "Произошла ошибка",
"deleteSessionConfirm": "Вы уверены, что хотите удалить этот сеанс? Это действие нельзя отменить.",
"deleteProjectConfirm": "Убрать этот проект из боковой панели? Файлы проекта, воспоминания и данные сеансов не будут удалены.",
"deleteProjectConfirm": "Вы уверены, что хотите удалить этот пустой проект? Это действие нельзя отменить.",
"enterProjectPath": "Пожалуйста, введите путь к проекту",
"deleteSessionFailed": "Не удалось удалить сеанс. Попробуйте снова.",
"deleteSessionError": "Ошибка при удалении сеанса. Попробуйте снова.",
"renameSessionFailed": "Не удалось переименовать сеанс. Попробуйте снова.",
"renameSessionError": "Ошибка при переименовании сеанса. Попробуйте снова.",
"deleteProjectFailed": "Не удалось убрать проект. Попробуйте снова.",
"deleteProjectError": "Ошибка при удалении проекта из списка. Попробуйте снова.",
"deleteProjectFailed": "Не удалось удалить проект. Попробуйте снова.",
"deleteProjectError": "Ошибка при удалении проекта. Попробуйте снова.",
"createProjectFailed": "Не удалось создать проект. Попробуйте снова.",
"createProjectError": "Ошибка при создании проекта. Попробуйте снова."
},
@@ -126,16 +126,14 @@
"projectsScanned_other": "{{count}} проектов просканировано"
},
"deleteConfirmation": {
"deleteProject": "Убрать проект",
"deleteProject": "Удалить проект",
"deleteSession": "Удалить сеанс",
"confirmDelete": "Что вы хотите сделать с",
"confirmDelete": "Вы уверены, что хотите удалить",
"sessionCount_one": "Этот проект содержит {{count}} разговор.",
"sessionCount_few": "Этот проект содержит {{count}} разговора.",
"sessionCount_many": "Этот проект содержит {{count}} разговоров.",
"sessionCount_other": "Этот проект содержит {{count}} разговоров.",
"removeFromSidebar": "Убрать только из боковой панели",
"deleteAllData": "Удалить все данные навсегда",
"allConversationsDeleted": "Проект будет убран из боковой панели. Ваши файлы, воспоминания и данные сеансов сохранятся.",
"cannotUndo": "Вы сможете добавить проект позже."
"allConversationsDeleted": "Все разговоры будут удалены навсегда.",
"cannotUndo": "Это действие нельзя отменить."
}
}

View File

@@ -1,37 +0,0 @@
{
"login": {
"title": "Tekrar Hoş Geldin",
"description": "Kendi CloudCLI hesabına giriş yap",
"username": "Kullanıcı Adı",
"password": "Şifre",
"submit": "Giriş Yap",
"loading": "Giriş yapılıyor...",
"errors": {
"invalidCredentials": "Kullanıcı adı veya şifre hatalı",
"requiredFields": "Lütfen tüm alanları doldur",
"networkError": "Ağ hatası. Lütfen tekrar dene."
},
"placeholders": {
"username": "Kullanıcı adını gir",
"password": "Şifreni gir"
}
},
"register": {
"title": "Hesap Oluştur",
"username": "Kullanıcı Adı",
"password": "Şifre",
"confirmPassword": "Şifreyi Onayla",
"submit": "Hesabı Oluştur",
"loading": "Hesap oluşturuluyor...",
"errors": {
"passwordMismatch": "Şifreler eşleşmiyor",
"usernameTaken": "Bu kullanıcı adı zaten alınmış",
"weakPassword": "Şifre çok zayıf"
}
},
"logout": {
"title": ıkış Yap",
"confirm": ıkış yapmak istediğinden emin misin?",
"button": ıkış Yap"
}
}

View File

@@ -1,272 +0,0 @@
{
"codeBlock": {
"copy": "Kopyala",
"copied": "Kopyalandı",
"copyCode": "Kodu kopyala"
},
"copyMessage": {
"copy": "Mesajı kopyala",
"copied": "Mesaj kopyalandı",
"selectFormat": "Kopyalama biçimini seç",
"copyAsMarkdown": "Markdown olarak kopyala",
"copyAsText": "Metin olarak kopyala"
},
"messageTypes": {
"user": "S",
"error": "Hata",
"tool": "Araç",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
"gemini": "Gemini"
},
"tools": {
"settings": "Araç Ayarları",
"error": "Araç Hatası",
"result": "Araç Sonucu",
"viewParams": "Girdi parametrelerini göster",
"viewRawParams": "Ham parametreleri göster",
"viewDiff": "Düzenleme diff'ini göster:",
"creatingFile": "Yeni dosya oluşturuluyor:",
"updatingTodo": "Yapılacaklar Listesi güncelleniyor",
"read": "Okundu",
"readFile": "Dosyayı oku",
"updateTodo": "Yapılacaklar listesini güncelle",
"readTodo": "Yapılacaklar listesini oku",
"searchResults": "sonuç"
},
"search": {
"found": "{{count}} {{type}} bulundu",
"file": "dosya",
"files": "dosya",
"pattern": "desen:",
"in": "şurada:"
},
"fileOperations": {
"updated": "Dosya başarıyla güncellendi",
"created": "Dosya başarıyla oluşturuldu",
"written": "Dosya başarıyla yazıldı",
"diff": "Diff",
"newFile": "Yeni Dosya",
"viewContent": "Dosya içeriğini göster",
"viewFullOutput": "Tam çıktıyı göster ({{count}} karakter)",
"contentDisplayed": "Dosya içeriği yukarıdaki diff görünümünde gösteriliyor"
},
"interactive": {
"title": "Etkileşimli Prompt",
"waiting": "CLI'da yanıtın bekleniyor",
"instruction": "Lütfen Claude'un çalıştığı terminalde bir seçenek seç.",
"selectedOption": "✓ Claude {{number}} numaralı seçeneği seçti",
"instructionDetail": "CLI'da bu seçeneği ok tuşları veya numara girerek interaktif olarak seçebilirsin."
},
"thinking": {
"title": "Düşünüyor...",
"emoji": "💭 Düşünüyor..."
},
"json": {
"response": "JSON Yanıtı"
},
"permissions": {
"grant": "{{tool}} için izin ver",
"added": "İzin eklendi",
"addTo": "{{entry}} İzin Verilen Araçlar listesine ekleniyor.",
"retry": "İzin kaydedildi. Aracı kullanmak için isteği tekrar dene.",
"error": "İzinler güncellenemedi. Lütfen tekrar dene.",
"openSettings": "Ayarları aç"
},
"todo": {
"updated": "Yapılacaklar listesi başarıyla güncellendi",
"current": "Mevcut Yapılacaklar Listesi"
},
"plan": {
"viewPlan": "📋 Uygulama planını göster",
"title": "Uygulama Planı"
},
"usageLimit": {
"resetAt": "Claude kullanım limitin doldu. Limitin **{{time}} {{timezone}}** — {{date}} tarihinde sıfırlanacak"
},
"codex": {
"permissionMode": "İzin Modu",
"modes": {
"default": "Varsayılan Mod",
"acceptEdits": "Düzenlemeleri Kabul Et",
"bypassPermissions": "İzinleri Atla",
"plan": "Plan Modu"
},
"descriptions": {
"default": "Sadece güvenilir komutlar (ls, cat, grep, git status, vb.) otomatik çalışır. Diğer komutlar atlanır. Çalışma alanına yazabilir.",
"acceptEdits": "Tüm komutlar çalışma alanı içinde otomatik çalışır. Sandbox'lu çalıştırma ile tam otomatik mod.",
"bypassPermissions": "Kısıtlama olmadan tam sistem erişimi. Tüm komutlar tam disk ve ağ erişimiyle otomatik çalışır. Dikkatli kullan.",
"plan": "Planlama modu — hiçbir komut çalıştırılmaz"
},
"technicalDetails": "Teknik ayrıntılar"
},
"gemini": {
"permissionMode": "Gemini İzin Modu",
"description": "Gemini CLI'ın işlem onaylarını nasıl yönettiğini kontrol et.",
"modes": {
"default": {
"title": "Standart (Onay İste)",
"description": "Gemini, komut çalıştırmadan, dosya yazmadan ve web kaynağı getirmeden önce onay ister."
},
"autoEdit": {
"title": "Otomatik Düzenleme (Dosya Onaylarını Atla)",
"description": "Gemini dosya düzenlemelerini ve web getirmelerini otomatik onaylar, ama shell komutları için yine de onay ister."
},
"yolo": {
"title": "YOLO (Tüm İzinleri Atla)",
"description": "Gemini tüm işlemleri onay almadan çalıştırır. Dikkatli kullan."
}
}
},
"input": {
"placeholder": "Komutlar için /, dosyalar için @ yaz ya da {{provider}}'a her şeyi sor...",
"placeholderDefault": "Mesajını yaz...",
"disabled": "Girdi devre dışı",
"attachFiles": "Dosya ekle",
"attachImages": "Resim ekle",
"send": "Gönder",
"stop": "Durdur",
"hintText": {
"ctrlEnter": "Göndermek için Ctrl+Enter • Yeni satır için Shift+Enter • Mod değiştirmek için Tab • Slash komutları için /",
"enter": "Göndermek için Enter • Yeni satır için Shift+Enter • Mod değiştirmek için Tab • Slash komutları için /"
},
"clickToChangeMode": "İzin modunu değiştirmek için tıkla (veya girdide Tab tuşuna bas)",
"showAllCommands": "Tüm komutları göster",
"clearInput": "Girdiyi temizle",
"scrollToBottom": "En alta git"
},
"thinkingMode": {
"selector": {
"title": "Düşünme Modu",
"description": "Uzatılmış düşünme, Claude'a alternatifleri değerlendirmek için daha fazla zaman verir",
"active": "Aktif",
"tip": "Daha yüksek düşünme modları daha fazla zaman alır ama daha kapsamlı analiz sağlar"
},
"modes": {
"none": {
"name": "Standart",
"description": "Normal Claude yanıtı",
"prefix": ""
},
"think": {
"name": "Düşün",
"description": "Temel uzatılmış düşünme",
"prefix": "think"
},
"thinkHard": {
"name": "Daha Fazla Düşün",
"description": "Daha kapsamlı değerlendirme",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Derin Düşün",
"description": "Alternatiflerle derin analiz",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultra Düşün",
"description": "Maksimum düşünme bütçesi",
"prefix": "ultrathink"
}
},
"buttonTitle": "Düşünme modu: {{mode}}"
},
"providerSelection": {
"title": "AI Asistanını Seç",
"description": "Yeni bir konuşma başlatmak için bir sağlayıcı seç",
"selectModel": "Model Seç",
"providerInfo": {
"anthropic": "Anthropic tarafından",
"openai": "OpenAI tarafından",
"cursorEditor": "AI Kod Editörü",
"google": "Google tarafından"
},
"readyPrompt": {
"claude": "Claude'u {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.",
"cursor": "Cursor'ı {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.",
"codex": "Codex'i {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.",
"gemini": "Gemini'yi {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.",
"default": "Başlamak için yukarıdan bir sağlayıcı seç"
}
},
"session": {
"continue": {
"title": "Konuşmana devam et",
"description": "Kodun hakkında soru sor, değişiklik iste veya geliştirme görevlerinde yardım al"
},
"loading": {
"olderMessages": "Eski mesajlar yükleniyor...",
"sessionMessages": "Oturum mesajları yükleniyor..."
},
"messages": {
"showingOf": "{{total}} mesajdan {{shown}} tanesi gösteriliyor",
"scrollToLoad": "Daha fazlasını yüklemek için yukarı kaydır",
"showingLast": "Son {{count}} mesaj gösteriliyor ({{total}} toplam)",
"loadEarlier": "Önceki mesajları yükle",
"loadAll": "Tüm mesajları yükle",
"loadingAll": "Tüm mesajlar yükleniyor...",
"allLoaded": "Tüm mesajlar yüklendi",
"perfWarning": "Tüm mesajlar yüklendi — kaydırma yavaşlayabilir. Performansı geri getirmek için \"En alta git\"e tıkla."
}
},
"shell": {
"selectProject": {
"title": "Proje Seç",
"description": "O dizinde etkileşimli shell açmak için bir proje seç"
},
"status": {
"newSession": "Yeni Oturum",
"initializing": "Başlatılıyor...",
"restarting": "Yeniden başlatılıyor..."
},
"actions": {
"disconnect": "Bağlantıyı Kes",
"disconnectTitle": "Shell bağlantısını kes",
"restart": "Yeniden Başlat",
"restartTitle": "Shell'i yeniden başlat (önce bağlantıyı kes)",
"connect": "Shell'de Devam Et",
"connectTitle": "Shell'e bağlan"
},
"loading": "Terminal yükleniyor...",
"connecting": "Shell'e bağlanılıyor...",
"startSession": "Yeni bir Claude oturumu başlat",
"resumeSession": "Oturuma devam et: {{displayName}}...",
"runCommand": "{{projectName}} içinde {{command}} çalıştır",
"startCli": "{{projectName}} içinde Claude CLI başlatılıyor",
"defaultCommand": "komut"
},
"claudeStatus": {
"actions": {
"thinking": "Düşünüyor",
"processing": "İşliyor",
"analyzing": "Analiz ediyor",
"working": "Çalışıyor",
"computing": "Hesaplıyor",
"reasoning": "Mantık yürütüyor"
},
"state": {
"live": "Canlı",
"paused": "Duraklatıldı"
},
"elapsed": {
"seconds": "{{count}}s",
"minutesSeconds": "{{minutes}}d {{seconds}}s",
"label": "{{time}} geçti",
"startingNow": "Şimdi başlıyor"
},
"controls": {
"stopGeneration": "Üretmeyi Durdur",
"pressEscToStop": "Durdurmak için istediğin zaman Esc'ye bas"
},
"providers": {
"assistant": "Asistan"
}
},
"projectSelection": {
"startChatWithProvider": "{{provider}} ile sohbet etmeye başlamak için bir proje seç"
},
"tasks": {
"nextTaskPrompt": "Sonraki görevi başlat"
}
}

View File

@@ -1,36 +0,0 @@
{
"toolbar": {
"changes": "değişiklik",
"previousChange": "Önceki değişiklik",
"nextChange": "Sonraki değişiklik",
"hideDiff": "Diff vurgusunu gizle",
"showDiff": "Diff vurgusunu göster",
"settings": "Editör Ayarları",
"collapse": "Editörü daralt",
"expand": "Editörü tüm genişliğe aç"
},
"loading": "{{fileName}} yükleniyor...",
"header": {
"showingChanges": "Değişiklikler gösteriliyor"
},
"actions": {
"download": "Dosyayı indir",
"save": "Kaydet",
"saving": "Kaydediliyor...",
"saved": "Kaydedildi!",
"exitFullscreen": "Tam ekrandan çık",
"fullscreen": "Tam ekran",
"close": "Kapat",
"previewMarkdown": "Markdown önizle",
"editMarkdown": "Markdown düzenle"
},
"footer": {
"lines": "Satır:",
"characters": "Karakter:",
"shortcuts": "Kaydetmek için Ctrl+S • Kapatmak için Esc"
},
"binaryFile": {
"title": "Binary Dosya",
"message": "\"{{fileName}}\" dosyası binary olduğu için metin editöründe gösterilemez."
}
}

View File

@@ -1,268 +0,0 @@
{
"buttons": {
"save": "Kaydet",
"cancel": "İptal",
"delete": "Sil",
"create": "Oluştur",
"edit": "Düzenle",
"close": "Kapat",
"confirm": "Onayla",
"submit": "Gönder",
"retry": "Tekrar Dene",
"refresh": "Yenile",
"search": "Ara",
"clear": "Temizle",
"copy": "Kopyala",
"download": "İndir",
"upload": "Yükle",
"browse": "Gözat"
},
"tabs": {
"chat": "Sohbet",
"shell": "Shell",
"files": "Dosyalar",
"git": "Kaynak Kontrolü",
"tasks": "Görevler"
},
"status": {
"loading": "Yükleniyor...",
"success": "Başarılı",
"error": "Hata",
"failed": "Başarısız",
"pending": "Beklemede",
"completed": "Tamamlandı",
"inProgress": "Sürüyor"
},
"messages": {
"savedSuccessfully": "Başarıyla kaydedildi",
"deletedSuccessfully": "Başarıyla silindi",
"updatedSuccessfully": "Başarıyla güncellendi",
"operationFailed": "İşlem başarısız",
"networkError": "Ağ hatası. Lütfen bağlantını kontrol et.",
"unauthorized": "Yetkisiz erişim. Lütfen giriş yap.",
"notFound": "Bulunamadı",
"invalidInput": "Geçersiz girdi",
"requiredField": "Bu alan zorunlu",
"unknownError": "Bilinmeyen bir hata oluştu"
},
"navigation": {
"settings": "Ayarlar",
"home": "Ana Sayfa",
"back": "Geri",
"next": "İleri",
"previous": "Önceki",
"logout": ıkış Yap"
},
"common": {
"language": "Dil",
"theme": "Tema",
"darkMode": "Koyu Mod",
"lightMode": "Açık Mod",
"name": "İsim",
"description": "Açıklama",
"enabled": "Etkin",
"disabled": "Devre Dışı",
"optional": "İsteğe Bağlı",
"version": "Sürüm",
"select": "Seç",
"selectAll": "Tümünü Seç",
"deselectAll": "Tümünün Seçimini Kaldır"
},
"time": {
"justNow": "Az önce",
"minutesAgo": "{{count}} dakika önce",
"hoursAgo": "{{count}} saat önce",
"daysAgo": "{{count}} gün önce",
"yesterday": "Dün"
},
"fileOperations": {
"newFile": "Yeni Dosya",
"newFolder": "Yeni Klasör",
"rename": "Yeniden Adlandır",
"move": "Taşı",
"copyPath": "Yolu Kopyala",
"openInEditor": "Editörde Aç"
},
"mainContent": {
"loading": "CloudCLI Yükleniyor",
"settingUpWorkspace": "Çalışma alanın hazırlanıyor...",
"chooseProject": "Projeni Seç",
"selectProjectDescription": "Claude ile kodlamaya başlamak için kenar çubuğundan bir proje seç. Her proje kendi sohbet oturumlarını ve dosya geçmişini içerir.",
"tip": "İpucu",
"createProjectMobile": "Projelere erişmek için yukarıdaki menü düğmesine dokun",
"createProjectDesktop": "Kenar çubuğundaki klasör simgesine tıklayarak yeni bir proje oluştur",
"newSession": "Yeni Oturum",
"untitledSession": "Adsız Oturum",
"projectFiles": "Proje Dosyaları"
},
"fileTree": {
"loading": "Dosyalar yükleniyor...",
"files": "Dosyalar",
"simpleView": "Basit görünüm",
"compactView": "Kompakt görünüm",
"detailedView": "Detaylı görünüm",
"searchPlaceholder": "Dosya ve klasörlerde ara...",
"clearSearch": "Aramayı temizle",
"name": "İsim",
"size": "Boyut",
"modified": "Değiştirilme",
"permissions": "İzinler",
"noFilesFound": "Dosya bulunamadı",
"checkProjectPath": "Proje yolunun erişilebilir olduğunu kontrol et",
"noMatchesFound": "Eşleşme bulunamadı",
"tryDifferentSearch": "Farklı bir arama terimi dene veya aramayı temizle",
"justNow": "az önce",
"minAgo": "{{count}} dakika önce",
"hoursAgo": "{{count}} saat önce",
"daysAgo": "{{count}} gün önce",
"newFile": "Yeni Dosya (Cmd+N)",
"newFolder": "Yeni Klasör (Cmd+Shift+N)",
"refresh": "Yenile",
"collapseAll": "Tümünü Daralt",
"context": {
"rename": "Yeniden Adlandır",
"delete": "Sil",
"copyPath": "Yolu Kopyala",
"download": "İndir",
"newFile": "Yeni Dosya",
"newFolder": "Yeni Klasör",
"refresh": "Yenile",
"menuLabel": "Dosya bağlam menüsü",
"loading": "Yükleniyor..."
}
},
"projectWizard": {
"title": "Yeni Proje Oluştur",
"steps": {
"type": "Tür",
"configure": "Yapılandır",
"confirm": "Onayla"
},
"step1": {
"question": "Zaten bir çalışma alanın var mı, yoksa yeni bir tane mi oluşturmak istersin?",
"existing": {
"title": "Mevcut Çalışma Alanı",
"description": "Sunucumda zaten bir çalışma alanım var, sadece proje listesine eklemek istiyorum"
},
"new": {
"title": "Yeni Çalışma Alanı",
"description": "Yeni bir çalışma alanı oluştur, istersen bir GitHub deposundan klonla"
}
},
"step2": {
"existingPath": "Çalışma Alanı Yolu",
"newPath": "Çalışma Alanı Yolu",
"existingPlaceholder": "/mevcut/calisma-alani/yolu",
"newPlaceholder": "/yeni/calisma-alani/yolu",
"existingHelp": "Mevcut çalışma alanı dizinine giden tam yol",
"newHelp": "Çalışma alanı dizinine giden tam yol",
"githubUrl": "GitHub URL'si (İsteğe Bağlı)",
"githubPlaceholder": "https://github.com/kullanici/depo",
"githubHelp": "İsteğe bağlı: bir depoyu klonlamak için GitHub URL'si gir",
"githubAuth": "GitHub Kimlik Doğrulama (İsteğe Bağlı)",
"githubAuthHelp": "Yalnızca özel depolar için gereklidir. Genel depolar kimlik doğrulama olmadan klonlanabilir.",
"loadingTokens": "Kayıtlı token'lar yükleniyor...",
"storedToken": "Kayıtlı Token",
"newToken": "Yeni Token",
"nonePublic": "Yok (Genel)",
"selectToken": "Token Seç",
"selectTokenPlaceholder": "-- Bir token seç --",
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tokenHelp": "Bu token sadece bu işlem için kullanılacak",
"publicRepoInfo": "Genel depolar kimlik doğrulama gerektirmez. Genel bir depo klonluyorsan token girmeyi atlayabilirsin.",
"noTokensHelp": "Kayıtlı token yok. Kolay tekrar kullanım için Ayarlar → API Anahtarları bölümünden token ekleyebilirsin.",
"optionalTokenPublic": "GitHub Token (Genel Depolar için İsteğe Bağlı)",
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (genel depolar için boş bırak)"
},
"step3": {
"reviewConfig": "Yapılandırmanı Gözden Geçir",
"workspaceType": "Çalışma Alanı Türü:",
"existingWorkspace": "Mevcut Çalışma Alanı",
"newWorkspace": "Yeni Çalışma Alanı",
"path": "Yol:",
"cloneFrom": "Şuradan Klonla:",
"authentication": "Kimlik Doğrulama:",
"usingStoredToken": "Kayıtlı token kullanılıyor:",
"usingProvidedToken": "Girilen token kullanılıyor",
"noAuthentication": "Kimlik doğrulama yok",
"sshKey": "SSH Anahtarı",
"existingInfo": "Çalışma alanı proje listene eklenecek ve Claude/Cursor oturumları için kullanılabilir olacak.",
"newWithClone": "Depo bu klasöre klonlanacak.",
"newEmpty": "Çalışma alanı proje listene eklenecek ve Claude/Cursor oturumları için kullanılabilir olacak.",
"cloningRepository": "Depo klonlanıyor..."
},
"buttons": {
"cancel": "İptal",
"back": "Geri",
"next": "İleri",
"createProject": "Projeyi Oluştur",
"creating": "Oluşturuluyor...",
"cloning": "Klonlanıyor..."
},
"errors": {
"selectType": "Lütfen mevcut çalışma alanın olduğunu mu yoksa yeni oluşturmak mı istediğini seç",
"providePath": "Lütfen bir çalışma alanı yolu gir",
"failedToCreate": "Çalışma alanı oluşturulamadı",
"failedToCreateFolder": "Klasör oluşturulamadı"
}
},
"notifications": {
"genericTool": "bir araç",
"codes": {
"generic": {
"info": {
"title": "Bildirim"
}
},
"permission": {
"required": {
"title": "Aksiyon Gerekli",
"body": "{{toolName}} kararını bekliyor."
}
},
"run": {
"stopped": {
"title": "Çalıştırma Durduruldu",
"body": "Sebep: {{reason}}"
},
"failed": {
"title": "Çalıştırma Başarısız"
}
},
"agent": {
"notification": {
"title": "Ajan Bildirimi"
}
}
}
},
"versionUpdate": {
"title": "Güncelleme Mevcut",
"newVersionReady": "Yeni bir sürüm hazır",
"currentVersion": "Mevcut Sürüm",
"latestVersion": "Son Sürüm",
"whatsNew": "Yenilikler:",
"viewFullRelease": "Tam sürüm notlarını gör",
"updateProgress": "Güncelleme İlerlemesi:",
"manualUpgrade": "Manuel yükseltme:",
"npmUpgradeCommand": "npm install -g @cloudcli-ai/cloudcli@latest",
"manualUpgradeHint": "Veya güncellemeyi otomatik çalıştırmak için \"Şimdi Güncelle\"ye tıkla.",
"updateCompleted": "Güncelleme başarıyla tamamlandı!",
"restartServer": "Değişikliklerin uygulanması için sunucuyu yeniden başlat.",
"updateFailed": "Güncelleme başarısız",
"buttons": {
"close": "Kapat",
"later": "Daha Sonra",
"copyCommand": "Komutu Kopyala",
"updateNow": "Şimdi Güncelle",
"updating": "Güncelleniyor..."
},
"ariaLabels": {
"closeModal": "Sürüm yükseltme modalını kapat",
"showSidebar": "Kenar çubuğunu göster",
"settings": "Ayarlar",
"updateAvailable": "Güncelleme mevcut",
"closeSidebar": "Kenar çubuğunu kapat"
}
}
}

View File

@@ -1,490 +0,0 @@
{
"title": "Ayarlar",
"tabs": {
"account": "Hesap",
"permissions": "İzinler",
"mcpServers": "MCP Sunucuları",
"appearance": "Görünüm"
},
"account": {
"title": "Hesap",
"language": "Dil",
"languageLabel": "Görüntüleme Dili",
"languageDescription": "Arayüz için tercih ettiğin dili seç",
"username": "Kullanıcı Adı",
"email": "E-posta",
"profile": "Profil",
"changePassword": "Şifreyi Değiştir"
},
"mcp": {
"title": "MCP Sunucuları",
"addServer": "Sunucu Ekle",
"editServer": "Sunucuyu Düzenle",
"deleteServer": "Sunucuyu Sil",
"serverName": "Sunucu Adı",
"serverType": "Sunucu Türü",
"config": "Yapılandırma",
"testConnection": "Bağlantıyı Test Et",
"status": "Durum",
"connected": "Bağlı",
"disconnected": "Bağlantı kesildi",
"scope": {
"label": "Kapsam",
"user": "Kullanıcı",
"project": "Proje"
}
},
"appearance": {
"title": "Görünüm",
"theme": "Tema",
"codeEditor": "Kod Editörü",
"editorTheme": "Editör Teması",
"wordWrap": "Kelime Kaydırma",
"showMinimap": "Minimap'i Göster",
"lineNumbers": "Satır Numaraları",
"fontSize": "Yazı Tipi Boyutu"
},
"actions": {
"saveChanges": "Değişiklikleri Kaydet",
"resetToDefaults": "Varsayılanlara Döndür",
"cancelChanges": "Değişiklikleri İptal Et"
},
"quickSettings": {
"title": "Hızlı Ayarlar",
"sections": {
"appearance": "Görünüm",
"toolDisplay": "Araç Gösterimi",
"viewOptions": "Görünüm Seçenekleri",
"inputSettings": "Girdi Ayarları"
},
"darkMode": "Koyu Mod",
"autoExpandTools": "Araçları otomatik genişlet",
"showRawParameters": "Ham parametreleri göster",
"showThinking": "Düşünmeyi göster",
"autoScrollToBottom": "Otomatik en alta kaydır",
"sendByCtrlEnter": "Ctrl+Enter ile gönder",
"sendByCtrlEnterDescription": "Etkinleştirildiğinde, Ctrl+Enter'a basmak yalnız Enter yerine mesajı gönderir. IME (girdi metot düzenleyici) kullananlar için yanlışlıkla göndermeyi önler.",
"dragHandle": {
"dragging": "Tutamaç sürükleniyor",
"closePanel": "Ayarlar panelini kapat",
"openPanel": "Ayarlar panelini aç",
"draggingStatus": "Sürükleniyor...",
"toggleAndMove": "Açıp kapamak için tıkla, taşımak için sürükle"
}
},
"terminalShortcuts": {
"title": "Terminal Kısayolları",
"sectionKeys": "Tuşlar",
"sectionNavigation": "Gezinme",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "Yukarı Ok",
"arrowDown": "Aşağı Ok",
"scrollDown": "Aşağı Kaydır",
"handle": {
"closePanel": "Kısayol panelini kapat",
"openPanel": "Kısayol panelini aç"
}
},
"mainTabs": {
"label": "Ayarlar",
"agents": "Ajanlar",
"appearance": "Görünüm",
"git": "Git",
"apiTokens": "API ve Token'lar",
"tasks": "Görevler",
"notifications": "Bildirimler",
"plugins": "Eklentiler",
"about": "Hakkında"
},
"notifications": {
"title": "Bildirimler",
"description": "Hangi bildirim etkinliklerini alacağını kontrol et.",
"webPush": {
"title": "Web Push Bildirimleri",
"enable": "Push Bildirimlerini Etkinleştir",
"disable": "Push Bildirimlerini Devre Dışı Bırak",
"enabled": "Push bildirimleri etkin",
"loading": "Güncelleniyor...",
"unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.",
"denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver."
},
"events": {
"title": "Etkinlik Türleri",
"actionRequired": "Aksiyon gerekli",
"stop": "Çalıştırma durduruldu",
"error": "Çalıştırma başarısız"
}
},
"appearanceSettings": {
"darkMode": {
"label": "Koyu Mod",
"description": "Açık ve koyu temalar arasında geçiş yap"
},
"projectSorting": {
"label": "Proje Sıralama",
"description": "Projelerin kenar çubuğunda nasıl sıralanacağı",
"alphabetical": "Alfabetik",
"recentActivity": "Son Etkinlik"
},
"codeEditor": {
"title": "Kod Editörü",
"theme": {
"label": "Editör Teması",
"description": "Kod editörü için varsayılan tema"
},
"wordWrap": {
"label": "Kelime Kaydırma",
"description": "Editörde kelime kaydırmayı varsayılan olarak etkinleştir"
},
"showMinimap": {
"label": "Minimap'i Göster",
"description": "Diff görünümünde kolay gezinme için minimap göster"
},
"lineNumbers": {
"label": "Satır Numaralarını Göster",
"description": "Editörde satır numaralarını göster"
},
"fontSize": {
"label": "Yazı Tipi Boyutu",
"description": "Editör yazı tipi boyutu (piksel)"
}
}
},
"mcpForm": {
"title": {
"add": "MCP Sunucusu Ekle",
"edit": "MCP Sunucusunu Düzenle"
},
"importMode": {
"form": "Form Girdisi",
"json": "JSON İçe Aktar"
},
"scope": {
"label": "Kapsam",
"userGlobal": "Kullanıcı (Genel)",
"projectLocal": "Proje (Yerel)",
"userDescription": "Kullanıcı kapsamı: Makinendeki tüm projelerde kullanılabilir",
"projectDescription": "Yerel kapsam: Yalnızca seçili projede kullanılabilir",
"cannotChange": "Mevcut bir sunucu düzenlenirken kapsam değiştirilemez"
},
"fields": {
"serverName": "Sunucu Adı",
"transportType": "Taşıma Türü",
"command": "Komut",
"arguments": "Argümanlar (satır başına bir tane)",
"jsonConfig": "JSON Yapılandırması",
"url": "URL",
"envVars": "Ortam Değişkenleri (KEY=değer, satır başına bir tane)",
"headers": "Başlıklar (KEY=değer, satır başına bir tane)",
"selectProject": "Bir proje seç..."
},
"placeholders": {
"serverName": "benim-sunucum"
},
"validation": {
"missingType": "Zorunlu alan eksik: type",
"stdioRequiresCommand": "stdio türü command alanı gerektirir",
"httpRequiresUrl": "{{type}} türü url alanı gerektirir",
"invalidJson": "Geçersiz JSON formatı",
"jsonHelp": "MCP sunucu yapılandırmanı JSON formatında yapıştır. Örnek formatlar:",
"jsonExampleStdio": "• stdio: {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
"jsonExampleHttp": "• http/sse: {\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}"
},
"configDetails": "Yapılandırma Detayları ({{configFile}} dosyasından)",
"projectPath": "Yol: {{path}}",
"actions": {
"cancel": "İptal",
"saving": "Kaydediliyor...",
"addServer": "Sunucu Ekle",
"updateServer": "Sunucuyu Güncelle"
}
},
"saveStatus": {
"success": "Ayarlar başarıyla kaydedildi!",
"error": "Ayarlar kaydedilemedi",
"saving": "Kaydediliyor..."
},
"footerActions": {
"save": "Ayarları Kaydet",
"cancel": "İptal"
},
"git": {
"title": "Git Yapılandırması",
"description": "Commit'ler için git kimliğini yapılandır. Bu ayarlar git config --global ile genel olarak uygulanacak",
"name": {
"label": "Git Adı",
"help": "Git commit'leri için adın"
},
"email": {
"label": "Git E-postası",
"help": "Git commit'leri için e-postan"
},
"actions": {
"save": "Yapılandırmayı Kaydet",
"saving": "Kaydediliyor..."
},
"status": {
"success": "Başarıyla kaydedildi"
}
},
"apiKeys": {
"title": "API Anahtarları",
"description": "Diğer uygulamalardan harici API'ye erişmek için API anahtarları üret.",
"newKey": {
"alertTitle": "⚠️ API Anahtarını Kaydet",
"alertMessage": "Bu anahtarı yalnızca bu sefer göreceksin. Güvenli bir yerde sakla.",
"iveSavedIt": "Kaydettim"
},
"form": {
"placeholder": "API Anahtar Adı (ör. Production Sunucu)",
"createButton": "Oluştur",
"cancelButton": "İptal"
},
"newButton": "Yeni API Anahtarı",
"empty": "Henüz API anahtarı oluşturulmamış.",
"list": {
"created": "Oluşturuldu:",
"lastUsed": "Son kullanım:"
},
"confirmDelete": "Bu API anahtarını silmek istediğinden emin misin?",
"status": {
"active": "Aktif",
"inactive": "Pasif"
},
"github": {
"title": "GitHub Token'ları",
"description": "Harici API üzerinden özel depoları klonlamak için GitHub Kişisel Erişim Token'ları ekle.",
"descriptionAlt": "Özel depoları klonlamak için GitHub Kişisel Erişim Token'ları ekle. Token'ları saklamadan API isteklerinde doğrudan da geçebilirsin.",
"addButton": "Token Ekle",
"form": {
"namePlaceholder": "Token Adı (ör. Kişisel Depolar)",
"tokenPlaceholder": "GitHub Kişisel Erişim Token'ı (ghp_...)",
"descriptionPlaceholder": "Açıklama (isteğe bağlı)",
"addButton": "Token Ekle",
"cancelButton": "İptal",
"howToCreate": "GitHub Kişisel Erişim Token'ı nasıl oluşturulur →"
},
"empty": "Henüz GitHub token'ı eklenmemiş.",
"added": "Eklendi:",
"confirmDelete": "Bu GitHub token'ını silmek istediğinden emin misin?"
},
"apiDocsLink": "API Dokümantasyonu",
"documentation": {
"title": "Harici API Dokümantasyonu",
"description": "Uygulamalarından Claude/Cursor oturumları tetiklemek için harici API'nin nasıl kullanılacağını öğren.",
"viewLink": "API Dokümantasyonunu Görüntüle →"
},
"loading": "Yükleniyor...",
"version": {
"updateAvailable": "Güncelleme mevcut: v{{version}}"
}
},
"tasks": {
"checking": "TaskMaster kurulumu kontrol ediliyor...",
"notInstalled": {
"title": "TaskMaster AI CLI Kurulu Değil",
"description": "Görev yönetim özelliklerini kullanmak için TaskMaster CLI gereklidir. Başlamak için kur:",
"installCommand": "npm install -g task-master-ai",
"viewOnGitHub": "GitHub'da Görüntüle",
"afterInstallation": "Kurulumdan sonra:",
"steps": {
"restart": "Bu uygulamayı yeniden başlat",
"autoAvailable": "TaskMaster özellikleri otomatik olarak kullanılabilir hale gelecek",
"initCommand": "Proje dizininde task-master init komutunu kullan"
}
},
"settings": {
"enableLabel": "TaskMaster Entegrasyonunu Etkinleştir",
"enableDescription": "TaskMaster görevlerini, banner'larını ve kenar çubuğu göstergelerini arayüz genelinde göster"
}
},
"agents": {
"authStatus": {
"checking": "Kontrol ediliyor...",
"connected": "Bağlı",
"notConnected": "Bağlı değil",
"disconnected": "Bağlantı kesildi",
"checkingAuth": "Kimlik doğrulama durumu kontrol ediliyor...",
"loggedInAs": "{{email}} olarak giriş yapıldı",
"authenticatedUser": "kimliği doğrulanmış kullanıcı"
},
"account": {
"claude": {
"description": "Anthropic Claude AI asistanı"
},
"cursor": {
"description": "Cursor AI destekli kod editörü"
},
"codex": {
"description": "OpenAI Codex AI asistanı"
},
"gemini": {
"description": "Google Gemini AI asistanı"
}
},
"connectionStatus": "Bağlantı Durumu",
"login": {
"title": "Giriş Yap",
"reAuthenticate": "Yeniden Kimlik Doğrula",
"description": "AI özelliklerini etkinleştirmek için {{agent}} hesabına giriş yap",
"reAuthDescription": "Farklı bir hesapla giriş yap veya kimlik bilgilerini yenile",
"button": "Giriş Yap",
"reLoginButton": "Tekrar Giriş Yap"
},
"error": "Hata: {{error}}"
},
"permissions": {
"title": "İzin Ayarları",
"skipPermissions": {
"label": "İzin istemlerini atla (dikkatli kullan)",
"claudeDescription": "--dangerously-skip-permissions bayrağının eşdeğeri",
"cursorDescription": "Cursor CLI'daki -f bayrağının eşdeğeri"
},
"allowedTools": {
"title": "İzin Verilen Araçlar",
"description": "İzin istemeden otomatik olarak izin verilen araçlar",
"placeholder": "ör. \"Bash(git log:*)\" veya \"Write\"",
"quickAdd": "Yaygın araçları hızlı ekle:",
"empty": "İzin verilen araç yapılandırılmamış"
},
"blockedTools": {
"title": "Engellenen Araçlar",
"description": "İzin istemeden otomatik olarak engellenen araçlar",
"placeholder": "ör. \"Bash(rm:*)\"",
"empty": "Engellenen araç yapılandırılmamış"
},
"allowedCommands": {
"title": "İzin Verilen Shell Komutları",
"description": "İzin istemeden otomatik olarak izin verilen shell komutları",
"placeholder": "ör. \"Shell(ls)\" veya \"Shell(git status)\"",
"quickAdd": "Yaygın komutları hızlı ekle:",
"empty": "İzin verilen komut yapılandırılmamış"
},
"blockedCommands": {
"title": "Engellenen Shell Komutları",
"description": "Otomatik olarak engellenen shell komutları",
"placeholder": "ör. \"Shell(rm -rf)\" veya \"Shell(sudo)\"",
"empty": "Engellenen komut yapılandırılmamış"
},
"toolExamples": {
"title": "Araç Desen Örnekleri:",
"bashGitLog": "- Tüm git log komutlarına izin ver",
"bashGitDiff": "- Tüm git diff komutlarına izin ver",
"write": "- Tüm Write aracı kullanımına izin ver",
"bashRm": "- Tüm rm komutlarını engelle (tehlikeli)"
},
"shellExamples": {
"title": "Shell Komut Örnekleri:",
"ls": "- ls komutuna izin ver",
"gitStatus": "- git status'a izin ver",
"npmInstall": "- npm install'a izin ver",
"rmRf": "- Özyinelemeli silmeyi engelle"
},
"codex": {
"permissionMode": "İzin Modu",
"description": "Codex'in dosya değişiklikleri ve komut çalıştırmayı nasıl ele aldığını kontrol eder",
"modes": {
"default": {
"title": "Varsayılan",
"description": "Sadece güvenilir komutlar (ls, cat, grep, git status, vb.) otomatik çalışır. Diğer komutlar atlanır. Çalışma alanına yazabilir."
},
"acceptEdits": {
"title": "Düzenlemeleri Kabul Et",
"description": "Tüm komutlar çalışma alanı içinde otomatik çalışır. Sandbox'lu çalıştırma ile tam otomatik mod."
},
"bypassPermissions": {
"title": "İzinleri Atla",
"description": "Kısıtlama olmadan tam sistem erişimi. Tüm komutlar tam disk ve ağ erişimiyle otomatik çalışır. Dikkatli kullan."
}
},
"technicalDetails": "Teknik ayrıntılar",
"technicalInfo": {
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted. Güvenilir komutlar: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (-exec olmadan), vb.",
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never. Tüm komutlar proje dizini içinde otomatik çalışır.",
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never. Tam sistem erişimi, yalnızca güvenilir ortamlarda kullan.",
"overrideNote": "Sohbet arayüzündeki mod düğmesini kullanarak bunu oturum başına geçersiz kılabilirsin."
}
},
"actions": {
"add": "Ekle"
}
},
"mcpServers": {
"title": "MCP Sunucuları",
"description": {
"claude": "Model Context Protocol sunucuları Claude'a ek araçlar ve veri kaynakları sağlar",
"cursor": "Model Context Protocol sunucuları Cursor'a ek araçlar ve veri kaynakları sağlar",
"codex": "Model Context Protocol sunucuları Codex'e ek araçlar ve veri kaynakları sağlar"
},
"addButton": "MCP Sunucusu Ekle",
"empty": "Yapılandırılmış MCP sunucusu yok",
"serverType": "Tür",
"scope": {
"local": "yerel",
"user": "kullanıcı"
},
"config": {
"command": "Komut",
"url": "URL",
"args": "Argümanlar",
"environment": "Ortam"
},
"tools": {
"title": "Araçlar",
"count": "({{count}}):",
"more": "+{{count}} tane daha"
},
"actions": {
"edit": "Sunucuyu düzenle",
"delete": "Sunucuyu sil"
},
"help": {
"title": "Codex MCP Hakkında",
"description": "Codex stdio tabanlı MCP sunucularını destekler. Codex'in yeteneklerini ek araçlar ve kaynaklarla genişleten sunucular ekleyebilirsin."
}
},
"pluginSettings": {
"title": "Eklentiler",
"description": "Arayüzü özel eklentilerle genişlet. Git'ten yükle veya ~/.claude-code-ui/plugins/ klasörüne bir dizin bırak.",
"installPlaceholder": "https://github.com/kullanici/benim-eklentim",
"installButton": "Yükle",
"installing": "Yükleniyor…",
"securityWarning": "Yalnızca kaynak kodunu incelediğin veya güvendiğin geliştiricilerin eklentilerini yükle.",
"scanningPlugins": "Eklentiler taranıyor…",
"noPluginsInstalled": "Yüklü eklenti yok",
"pullLatest": "Git'ten en güncelini çek",
"noGitRemote": "Git uzak sunucusu yok — güncelleme kullanılamıyor",
"uninstallPlugin": "Eklentiyi kaldır",
"confirmUninstall": "Onaylamak için tekrar tıkla",
"confirmUninstallMessage": "{{name}} kaldırılsın mı? Bu işlem geri alınamaz.",
"cancel": "İptal",
"remove": "Kaldır",
"updateFailed": "Güncelleme başarısız",
"installFailed": "Yükleme başarısız",
"uninstallFailed": "Kaldırma başarısız",
"toggleFailed": "Açıp kapama başarısız",
"starterPluginLabel": "Başlangıç Eklentisi",
"starter": "Başlangıç",
"docs": "Dokümanlar",
"starterPlugin": {
"name": "Proje İstatistikleri",
"badge": "başlangıç",
"description": "Projen için dosya sayıları, kod satırları, dosya türü dağılımı ve son etkinlik.",
"install": "Yükle"
},
"terminalPlugin": {
"name": "Terminal",
"badge": "resmi",
"description": "Arayüzün içinde tam shell erişimiyle entegre terminal.",
"install": "Yükle"
},
"morePlugins": "Daha Fazla",
"enable": "Etkinleştir",
"disable": "Devre Dışı Bırak",
"installAriaLabel": "Eklenti git deposu URL'si",
"tab": "sekme",
"runningStatus": "çalışıyor"
}
}

View File

@@ -1,135 +0,0 @@
{
"projects": {
"title": "Projeler",
"newProject": "Yeni Proje",
"deleteProject": "Projeyi Kaldır",
"renameProject": "Projeyi Yeniden Adlandır",
"noProjects": "Proje bulunamadı",
"loadingProjects": "Projeler yükleniyor...",
"searchPlaceholder": "Projelerde ara...",
"projectNamePlaceholder": "Proje adı",
"starred": "Yıldızlı",
"all": "Tümü",
"untitledSession": "Adsız Oturum",
"newSession": "Yeni Oturum",
"codexSession": "Codex Oturumu",
"fetchingProjects": "Claude projelerin ve oturumların getiriliyor",
"projects": "proje",
"noMatchingProjects": "Eşleşen proje yok",
"tryDifferentSearch": "Arama terimini değiştirmeyi dene",
"runClaudeCli": "Başlamak için bir proje dizininde Claude CLI çalıştır"
},
"app": {
"title": "CloudCLI",
"subtitle": "AI kodlama asistanı arayüzü"
},
"sessions": {
"title": "Oturumlar",
"newSession": "Yeni Oturum",
"deleteSession": "Oturumu Sil",
"renameSession": "Oturumu Yeniden Adlandır",
"noSessions": "Henüz oturum yok",
"loadingSessions": "Oturumlar yükleniyor...",
"unnamed": "Adsız",
"loading": "Yükleniyor...",
"showMore": "Daha fazla oturum göster"
},
"tooltips": {
"viewEnvironments": "Ortamları Görüntüle",
"hideSidebar": "Kenar çubuğunu gizle",
"createProject": "Yeni proje oluştur",
"refresh": "Projeleri ve oturumları yenile (Ctrl+R)",
"renameProject": "Projeyi yeniden adlandır (F2)",
"deleteProject": "Projeyi kenar çubuğundan kaldır (Delete)",
"addToFavorites": "Favorilere ekle",
"removeFromFavorites": "Favorilerden çıkar",
"editSessionName": "Oturum adını elle düzenle",
"deleteSession": "Bu oturumu kalıcı olarak sil",
"save": "Kaydet",
"cancel": "İptal",
"clearSearch": "Aramayı temizle"
},
"navigation": {
"chat": "Sohbet",
"files": "Dosyalar",
"git": "Git",
"terminal": "Terminal",
"tasks": "Görevler"
},
"actions": {
"refresh": "Yenile",
"settings": "Ayarlar",
"collapseAll": "Tümünü Daralt",
"expandAll": "Tümünü Genişlet",
"cancel": "İptal",
"save": "Kaydet",
"delete": "Sil",
"rename": "Yeniden Adlandır",
"joinCommunity": "Topluluğa Katıl",
"reportIssue": "Sorun Bildir",
"starOnGithub": "GitHub'da Yıldızla"
},
"branding": {
"openSource": "Açık Kaynak"
},
"status": {
"active": "Aktif",
"inactive": "Pasif",
"thinking": "Düşünüyor...",
"error": "Hata",
"aborted": "Durduruldu",
"unknown": "Bilinmiyor"
},
"time": {
"justNow": "Az önce",
"oneMinuteAgo": "1 dakika önce",
"minutesAgo": "{{count}} dakika önce",
"oneHourAgo": "1 saat önce",
"hoursAgo": "{{count}} saat önce",
"oneDayAgo": "1 gün önce",
"daysAgo": "{{count}} gün önce"
},
"messages": {
"deleteConfirm": "Bunu silmek istediğinden emin misin?",
"renameSuccess": "Yeniden adlandırma başarılı",
"deleteSuccess": "Silme başarılı",
"errorOccurred": "Bir hata oluştu",
"deleteSessionConfirm": "Bu oturumu silmek istediğinden emin misin? Bu işlem geri alınamaz.",
"deleteProjectConfirm": "Bu proje kenar çubuğundan kaldırılsın mı? Proje dosyaların, bellek verilerin ve oturum verilerin silinmeyecek.",
"enterProjectPath": "Lütfen bir proje yolu gir",
"deleteSessionFailed": "Oturum silinemedi. Lütfen tekrar dene.",
"deleteSessionError": "Oturum silinirken hata oluştu. Lütfen tekrar dene.",
"renameSessionFailed": "Oturum yeniden adlandırılamadı. Lütfen tekrar dene.",
"renameSessionError": "Oturum yeniden adlandırılırken hata oluştu. Lütfen tekrar dene.",
"deleteProjectFailed": "Proje kaldırılamadı. Lütfen tekrar dene.",
"deleteProjectError": "Proje kaldırılırken hata oluştu. Lütfen tekrar dene.",
"createProjectFailed": "Proje oluşturulamadı. Lütfen tekrar dene.",
"createProjectError": "Proje oluşturulurken hata oluştu. Lütfen tekrar dene."
},
"version": {
"updateAvailable": "Güncelleme mevcut"
},
"search": {
"modeProjects": "Projeler",
"modeConversations": "Konuşmalar",
"conversationsPlaceholder": "Konuşmalarda ara...",
"searching": "Aranıyor...",
"noResults": "Sonuç bulunamadı",
"tryDifferentQuery": "Farklı bir arama sorgusu dene",
"matches_one": "{{count}} eşleşme",
"matches_other": "{{count}} eşleşme",
"projectsScanned_one": "{{count}} proje tarandı",
"projectsScanned_other": "{{count}} proje tarandı"
},
"deleteConfirmation": {
"deleteProject": "Projeyi Kaldır",
"deleteSession": "Oturumu Sil",
"confirmDelete": "Ne yapmak istersin:",
"sessionCount_one": "Bu proje {{count}} konuşma içeriyor.",
"sessionCount_other": "Bu proje {{count}} konuşma içeriyor.",
"removeFromSidebar": "Yalnızca kenar çubuğundan kaldır",
"deleteAllData": "Tüm veriyi kalıcı olarak sil",
"allConversationsDeleted": "Proje kenar çubuğundan kaldırılacak. Dosyaların, bellek verilerin ve oturum verilerin korunacak.",
"cannotUndo": "Projeyi sonra tekrar ekleyebilirsin."
}
}

View File

@@ -1,142 +0,0 @@
{
"notConfigured": {
"title": "TaskMaster AI yapılandırılmamış",
"description": "TaskMaster, karmaşık projeleri AI destekli yardımla yönetilebilir görevlere böler",
"whatIsTitle": "🎯 TaskMaster nedir?",
"features": {
"aiPowered": "AI Destekli Görev Yönetimi: Karmaşık projeleri yönetilebilir alt görevlere böl",
"prdTemplates": "PRD Şablonları: Ürün Gereksinim Belgelerinden görev üret",
"dependencyTracking": "Bağımlılık Takibi: Görev ilişkilerini ve çalıştırma sırasını anla",
"progressVisualization": "İlerleme Görselleştirme: Kanban panoları ve detaylı görev analizleri",
"cliIntegration": "CLI Entegrasyonu: İleri seviye iş akışları için taskmaster komutlarını kullan"
},
"initializeButton": "TaskMaster AI'yi Başlat"
},
"gettingStarted": {
"title": "TaskMaster'a Başlarken",
"subtitle": "TaskMaster hazır! Sıradaki adımların:",
"steps": {
"createPRD": {
"title": "Ürün Gereksinim Belgesi (PRD) oluştur",
"description": "Proje fikrini konuş ve ne inşa etmek istediğini anlatan bir PRD yaz.",
"addButton": "PRD Ekle",
"existingPRDs": "Mevcut PRD'ler:"
},
"generateTasks": {
"title": "PRD'den Görev Üret",
"description": "PRD'n hazır olduğunda AI asistanına ayrıştırmasını söyle; TaskMaster bunu otomatik olarak uygulama detaylarıyla yönetilebilir görevlere bölecek."
},
"analyzeTasks": {
"title": "Görevleri Analiz Et ve Genişlet",
"description": "AI asistanına görev karmaşıklığını analiz etmesini ve uygulamayı kolaylaştırmak için detaylı alt görevlere ayırmasını söyle."
},
"startBuilding": {
"title": "İnşaya Başla",
"description": "AI asistanına görevler üzerinde çalışmaya başlamasını, durumlarını güncellemesini ve proje geliştikçe yeni görevler eklemesini söyle."
}
},
"tip": "💡 İpucu: TaskMaster'ın AI destekli görev üretiminden en iyi şekilde faydalanmak için bir PRD ile başla"
},
"setupModal": {
"title": "TaskMaster Kurulumu",
"subtitle": "{{projectName}} için interaktif CLI",
"willStart": "TaskMaster başlatma otomatik olarak başlayacak",
"completed": "TaskMaster kurulumu tamamlandı! Bu pencereyi kapatabilirsin.",
"closeButton": "Kapat",
"closeContinueButton": "Kapat ve Devam Et"
},
"helpGuide": {
"title": "TaskMaster'a Başlarken",
"subtitle": "Verimli görev yönetimi için rehberin",
"examples": {
"parsePRD": "💬 Örnek:\n\"Claude Task Master ile yeni bir proje başlattım. .taskmaster/docs/prd.txt altında bir PRD'm var. Bunu ayrıştırıp ilk görevleri kurmama yardım eder misin?\"",
"expandTask": "💬 Örnek:\n\"Görev 5 karmaşık görünüyor. Bunu alt görevlere bölebilir misin?\"",
"addTask": "💬 Örnek:\n\"Lütfen Cloudinary kullanarak kullanıcı profil resmi yükleme özelliği için yeni bir görev ekle, en iyi yaklaşımı araştır.\""
},
"moreExamples": "Daha fazla örnek ve kullanım deseni →",
"proTips": {
"title": "💡 Pro İpuçları",
"search": "Belirli görevleri hızlıca bulmak için arama çubuğunu kullan",
"views": "Kanban, Liste ve Izgara görünümleri arasında geçiş yapmak için görünüm düğmelerini kullan",
"filters": "Belirli görev durumlarına veya önceliklere odaklanmak için filtreleri kullan",
"details": "Detaylı bilgi görmek ve alt görevleri yönetmek için herhangi bir göreve tıkla"
},
"learnMore": {
"title": "📚 Daha Fazla Öğren",
"description": "TaskMaster AI, geliştiriciler için inşa edilmiş ileri seviye bir görev yönetim sistemidir. Dokümantasyona, örneklere bak ve projeye katkıda bulun.",
"githubButton": "GitHub'da Görüntüle"
}
},
"search": {
"placeholder": "Görevlerde ara..."
},
"filters": {
"button": "Filtreler",
"status": "Durum",
"priority": "Öncelik",
"sortBy": "Sıralama",
"allStatuses": "Tüm Durumlar",
"allPriorities": "Tüm Öncelikler",
"showing": "{{total}} görevin {{filtered}} tanesi gösteriliyor",
"clearFilters": "Filtreleri Temizle"
},
"sort": {
"id": "ID",
"status": "Durum",
"priority": "Öncelik",
"idAsc": "ID (Artan)",
"idDesc": "ID (Azalan)",
"titleAsc": "Başlık (A-Z)",
"titleDesc": "Başlık (Z-A)",
"statusAsc": "Durum (Önce Bekleyen)",
"statusDesc": "Durum (Önce Tamamlanan)",
"priorityAsc": "Öncelik (Önce Yüksek)",
"priorityDesc": "Öncelik (Önce Düşük)"
},
"views": {
"kanban": "Kanban görünümü",
"list": "Liste görünümü",
"grid": "Izgara görünümü"
},
"kanban": {
"pending": "📋 Yapılacak",
"inProgress": "🚀 Sürüyor",
"done": "✅ Tamamlandı",
"blocked": "🚫 Engellendi",
"deferred": "⏳ Ertelendi",
"cancelled": "❌ İptal Edildi",
"noTasksYet": "Henüz görev yok",
"tasksWillAppear": "Görevler burada görünecek",
"moveTasksHere": "Başlayan görevleri buraya taşı",
"completedTasksHere": "Tamamlanan görevler burada görünür",
"statusTasksHere": "Bu durumdaki görevler burada görünecek"
},
"buttons": {
"help": "TaskMaster Başlangıç Rehberi",
"prds": "PRD'ler",
"addPRD": "PRD Ekle",
"addTask": "Görev Ekle",
"createNewPRD": "Yeni PRD Oluştur",
"prdsAvailable": "{{count}} PRD mevcut"
},
"prd": {
"modified": "Değişiklik: {{date}}"
},
"statuses": {
"pending": "Beklemede",
"in-progress": "Sürüyor",
"done": "Tamamlandı",
"blocked": "Engellendi",
"deferred": "Ertelendi",
"cancelled": "İptal Edildi"
},
"priorities": {
"high": "Yüksek",
"medium": "Orta",
"low": "Düşük"
},
"noMatchingTasks": {
"title": "Filtrelerine uygun görev yok",
"description": "Arama veya filtre kriterlerini değiştirmeyi dene."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "项目",
"newProject": "新建项目",
"deleteProject": "除项目",
"deleteProject": "除项目",
"renameProject": "重命名项目",
"noProjects": "未找到项目",
"loadingProjects": "加载项目中...",
@@ -40,7 +40,7 @@
"createProject": "创建新项目",
"refresh": "刷新项目和会话 (Ctrl+R)",
"renameProject": "重命名项目 (F2)",
"deleteProject": "从侧边栏移除项目 (Delete)",
"deleteProject": "删除空项目 (Delete)",
"addToFavorites": "添加到收藏",
"removeFromFavorites": "从收藏移除",
"editSessionName": "手动编辑会话名称",
@@ -95,14 +95,14 @@
"deleteSuccess": "删除成功",
"errorOccurred": "发生错误",
"deleteSessionConfirm": "确定要删除此会话吗?此操作无法撤销。",
"deleteProjectConfirm": "从侧边栏移除此项目?您的项目文件、记忆和会话数据不会被删除。",
"deleteProjectConfirm": "确定要删除此项目吗?此操作无法撤销。",
"enterProjectPath": "请输入项目路径",
"deleteSessionFailed": "删除会话失败,请重试。",
"deleteSessionError": "删除会话时出错,请重试。",
"renameSessionFailed": "重命名会话失败,请重试。",
"renameSessionError": "重命名会话时出错,请重试。",
"deleteProjectFailed": "除项目失败,请重试。",
"deleteProjectError": "除项目时出错,请重试。",
"deleteProjectFailed": "除项目失败,请重试。",
"deleteProjectError": "除项目时出错,请重试。",
"createProjectFailed": "创建项目失败,请重试。",
"createProjectError": "创建项目时出错,请重试。"
},
@@ -122,14 +122,12 @@
"projectsScanned_other": "{{count}} 个项目已扫描"
},
"deleteConfirmation": {
"deleteProject": "除项目",
"deleteProject": "除项目",
"deleteSession": "删除会话",
"confirmDelete": "您想如何处理",
"confirmDelete": "您确定要删除",
"sessionCount_one": "此项目包含 {{count}} 个对话。",
"sessionCount_other": "此项目包含 {{count}} 个对话。",
"removeFromSidebar": "仅从侧边栏移除",
"deleteAllData": "永久删除所有数据",
"allConversationsDeleted": "项目将从侧边栏中移除。您的文件、记忆和会话数据将会保留。",
"cannotUndo": "您可以稍后重新添加此项目。"
"allConversationsDeleted": "所有对话将被永久删除。",
"cannotUndo": "此操作无法撤销。"
}
}

View File

@@ -1,64 +0,0 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../../lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
}
);
type AlertProps = React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>;
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
data-slot="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
);
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="alert-title"
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
{...props}
/>
)
);
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className
)}
{...props}
/>
)
);
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription, alertVariants };

View File

@@ -1,6 +1,5 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../../lib/utils';
const badgeVariants = cva(

View File

@@ -1,11 +1,10 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../../lib/utils';
// Keep visual variants centralized so all button usages stay consistent.
const buttonVariants = cva(
'inline-flex touch-manipulation items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium touch-manipulation transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {

View File

@@ -1,78 +0,0 @@
import * as React from 'react';
import { cn } from '../../../lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-xl border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-4', className)}
{...props}
/>
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-4 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-4 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
/**
* Use inside a CardHeader with `className="flex flex-row items-start justify-between"`.
* Positions an action (button/icon) at the trailing edge of the header.
*/
const CardAction = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('ml-auto shrink-0', className)}
{...props}
/>
)
);
CardAction.displayName = 'CardAction';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction };

View File

@@ -1,103 +0,0 @@
import * as React from 'react';
import { cn } from '../../../lib/utils';
interface CollapsibleContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const CollapsibleContext = React.createContext<CollapsibleContextValue | null>(null);
function useCollapsible() {
const ctx = React.useContext(CollapsibleContext);
if (!ctx) throw new Error('Collapsible components must be used within <Collapsible>');
return ctx;
}
interface CollapsibleProps extends React.HTMLAttributes<HTMLDivElement> {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(
({ defaultOpen = false, open: controlledOpen, onOpenChange: controlledOnOpenChange, className, children, ...props }, ref) => {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const onOpenChange = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
controlledOnOpenChange?.(next);
},
[isControlled, controlledOnOpenChange]
);
const value = React.useMemo(() => ({ open, onOpenChange }), [open, onOpenChange]);
return (
<CollapsibleContext.Provider value={value}>
<div ref={ref} data-state={open ? 'open' : 'closed'} className={className} {...props}>
{children}
</div>
</CollapsibleContext.Provider>
);
}
);
Collapsible.displayName = 'Collapsible';
const CollapsibleTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ onClick, children, className, ...props }, ref) => {
const { open, onOpenChange } = useCollapsible();
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
onOpenChange(!open);
onClick?.(e);
},
[open, onOpenChange, onClick]
);
return (
<button
ref={ref}
type="button"
aria-expanded={open}
data-state={open ? 'open' : 'closed'}
onClick={handleClick}
className={className}
{...props}
>
{children}
</button>
);
}
);
CollapsibleTrigger.displayName = 'CollapsibleTrigger';
const CollapsibleContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { open } = useCollapsible();
return (
<div
ref={ref}
data-state={open ? 'open' : 'closed'}
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
className
)}
{...props}
>
<div className="overflow-hidden">
{children}
</div>
</div>
);
}
);
CollapsibleContent.displayName = 'CollapsibleContent';
export { Collapsible, CollapsibleTrigger, CollapsibleContent, useCollapsible };

View File

@@ -1,320 +0,0 @@
import * as React from 'react';
import { Search } from 'lucide-react';
import { cn } from '../../../lib/utils';
/*
* Lightweight command palette — inspired by cmdk but no external deps.
*
* Architecture:
* - Command owns the search string and a flat list of registered item values.
* - Items register via context on mount and deregister on unmount.
* - Filtering, active index, and keyboard nav happen centrally in Command.
* - Items read their "is visible" / "is active" state from context.
*/
interface ItemEntry {
id: string;
value: string; // searchable text (lowercase)
onSelect: () => void;
element: HTMLElement | null;
}
interface CommandContextValue {
search: string;
setSearch: (value: string) => void;
/** Set of visible item IDs after filtering (derived state, not a ref). */
visibleIds: Set<string>;
activeId: string | null;
setActiveId: (id: string | null) => void;
register: (entry: ItemEntry) => void;
unregister: (id: string) => void;
updateEntry: (id: string, patch: Partial<Pick<ItemEntry, 'value' | 'onSelect' | 'element'>>) => void;
}
const CommandContext = React.createContext<CommandContextValue | null>(null);
function useCommand() {
const ctx = React.useContext(CommandContext);
if (!ctx) throw new Error('Command components must be used within <Command>');
return ctx;
}
/* ─── Command (root) ─────────────────────────────────────────────── */
type CommandProps = React.HTMLAttributes<HTMLDivElement>;
const Command = React.forwardRef<HTMLDivElement, CommandProps>(
({ className, children, ...props }, ref) => {
const [search, setSearch] = React.useState('');
const entriesRef = React.useRef<Map<string, ItemEntry>>(new Map());
// Bump this counter whenever the entry set changes so derived state recalculates
const [revision, setRevision] = React.useState(0);
const register = React.useCallback((entry: ItemEntry) => {
entriesRef.current.set(entry.id, entry);
setRevision(r => r + 1);
}, []);
const unregister = React.useCallback((id: string) => {
entriesRef.current.delete(id);
setRevision(r => r + 1);
}, []);
const updateEntry = React.useCallback((id: string, patch: Partial<Pick<ItemEntry, 'value' | 'onSelect' | 'element'>>) => {
const existing = entriesRef.current.get(id);
if (existing) {
Object.assign(existing, patch);
}
}, []);
// Derive visible IDs from search + entries
const visibleIds = React.useMemo(() => {
const lowerSearch = search.toLowerCase();
const ids = new Set<string>();
for (const [id, entry] of entriesRef.current) {
if (!lowerSearch || entry.value.includes(lowerSearch)) {
ids.add(id);
}
}
return ids;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, revision]);
// Ordered list of visible entries (preserves DOM order via insertion order)
const visibleEntries = React.useMemo(() => {
const result: ItemEntry[] = [];
for (const [, entry] of entriesRef.current) {
if (visibleIds.has(entry.id)) result.push(entry);
}
return result;
}, [visibleIds]);
// Active item tracking
const [activeId, setActiveId] = React.useState<string | null>(null);
// Reset active to first visible item when search or visible set changes
React.useEffect(() => {
setActiveId(visibleEntries.length > 0 ? visibleEntries[0].id : null);
}, [visibleEntries]);
const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
e.preventDefault();
} else {
return;
}
const entries = visibleEntries;
if (entries.length === 0) return;
if (e.key === 'Enter') {
const active = entries.find(entry => entry.id === activeId);
active?.onSelect();
return;
}
const currentIndex = entries.findIndex(entry => entry.id === activeId);
let nextIndex: number;
if (e.key === 'ArrowDown') {
nextIndex = currentIndex < entries.length - 1 ? currentIndex + 1 : 0;
} else {
nextIndex = currentIndex > 0 ? currentIndex - 1 : entries.length - 1;
}
const nextId = entries[nextIndex].id;
setActiveId(nextId);
// Scroll the active item into view
const nextEntry = entries[nextIndex];
nextEntry.element?.scrollIntoView({ block: 'nearest' });
}, [visibleEntries, activeId]);
const value = React.useMemo<CommandContextValue>(
() => ({ search, setSearch, visibleIds, activeId, setActiveId, register, unregister, updateEntry }),
[search, visibleIds, activeId, register, unregister, updateEntry]
);
return (
<CommandContext.Provider value={value}>
<div
ref={ref}
role="combobox"
aria-expanded="true"
aria-haspopup="listbox"
className={cn('flex flex-col', className)}
onKeyDown={handleKeyDown}
{...props}
>
{children}
</div>
</CommandContext.Provider>
);
}
);
Command.displayName = 'Command';
/* ─── CommandInput ───────────────────────────────────────────────── */
type CommandInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'type'>;
const CommandInput = React.forwardRef<HTMLInputElement, CommandInputProps>(
({ className, placeholder = 'Search...', ...props }, ref) => {
const { search, setSearch } = useCommand();
return (
<div className="flex items-center border-b px-3" role="presentation">
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<input
ref={ref}
type="text"
role="searchbox"
aria-autocomplete="list"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={placeholder}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none',
'placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
);
}
);
CommandInput.displayName = 'CommandInput';
/* ─── CommandList ────────────────────────────────────────────────── */
const CommandList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
role="listbox"
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
)
);
CommandList.displayName = 'CommandList';
/* ─── CommandEmpty ───────────────────────────────────────────────── */
const CommandEmpty = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { search, visibleIds } = useCommand();
// Only show when there's a search term and zero matches
if (!search || visibleIds.size > 0) return null;
return (
<div ref={ref} className={cn('py-6 text-center text-sm text-muted-foreground', className)} {...props} />
);
}
);
CommandEmpty.displayName = 'CommandEmpty';
/* ─── CommandGroup ───────────────────────────────────────────────── */
interface CommandGroupProps extends React.HTMLAttributes<HTMLDivElement> {
heading?: React.ReactNode;
}
const CommandGroup = React.forwardRef<HTMLDivElement, CommandGroupProps>(
({ className, heading, children, ...props }, ref) => (
<div ref={ref} className={cn('overflow-hidden p-1', className)} role="group" aria-label={typeof heading === 'string' ? heading : undefined} {...props}>
{heading && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground" role="presentation">
{heading}
</div>
)}
{children}
</div>
)
);
CommandGroup.displayName = 'CommandGroup';
/* ─── CommandItem ────────────────────────────────────────────────── */
interface CommandItemProps extends React.HTMLAttributes<HTMLDivElement> {
value?: string;
onSelect?: () => void;
disabled?: boolean;
}
const CommandItem = React.forwardRef<HTMLDivElement, CommandItemProps>(
({ className, value, onSelect, disabled, children, ...props }, ref) => {
const { visibleIds, activeId, setActiveId, register, unregister, updateEntry } = useCommand();
const stableId = React.useId();
const elementRef = React.useRef<HTMLElement | null>(null);
const searchableText = value || (typeof children === 'string' ? children : '');
// Register on mount, unregister on unmount
React.useEffect(() => {
register({
id: stableId,
value: searchableText.toLowerCase(),
onSelect: onSelect || (() => {}),
element: elementRef.current,
});
return () => unregister(stableId);
// Only re-register when the identity changes, not onSelect
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stableId, searchableText, register, unregister]);
// Keep onSelect up-to-date without re-registering
React.useEffect(() => {
updateEntry(stableId, { onSelect: onSelect || (() => {}) });
}, [stableId, onSelect, updateEntry]);
// Keep element ref up-to-date
const setRef = React.useCallback((node: HTMLDivElement | null) => {
elementRef.current = node;
updateEntry(stableId, { element: node });
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}, [stableId, updateEntry, ref]);
// Hidden by filter
if (!visibleIds.has(stableId)) return null;
const isActive = activeId === stableId;
return (
<div
ref={setRef}
role="option"
aria-selected={isActive}
aria-disabled={disabled || undefined}
data-active={isActive || undefined}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none',
isActive && 'bg-accent text-accent-foreground',
disabled && 'pointer-events-none opacity-50',
className
)}
onPointerMove={() => { if (!disabled && activeId !== stableId) setActiveId(stableId); }}
onClick={() => !disabled && onSelect?.()}
{...props}
>
{children}
</div>
);
}
);
CommandItem.displayName = 'CommandItem';
/* ─── CommandSeparator ───────────────────────────────────────────── */
const CommandSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} />
)
);
CommandSeparator.displayName = 'CommandSeparator';
export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator };

View File

@@ -1,139 +0,0 @@
import * as React from 'react';
import { cn } from '../../../lib/utils';
import { Alert } from './Alert';
import { Button } from './Button';
/* ─── Context ────────────────────────────────────────────────────── */
type ApprovalState = 'pending' | 'approved' | 'rejected' | undefined;
interface ConfirmationContextValue {
approval: ApprovalState;
}
const ConfirmationContext = React.createContext<ConfirmationContextValue | null>(null);
const useConfirmation = () => {
const context = React.useContext(ConfirmationContext);
if (!context) {
throw new Error('Confirmation components must be used within Confirmation');
}
return context;
};
/* ─── Confirmation (root) ────────────────────────────────────────── */
export interface ConfirmationProps extends React.HTMLAttributes<HTMLDivElement> {
approval?: ApprovalState;
}
export const Confirmation: React.FC<ConfirmationProps> = ({
className,
approval = 'pending',
children,
...props
}) => {
const contextValue = React.useMemo(() => ({ approval }), [approval]);
return (
<ConfirmationContext.Provider value={contextValue}>
<Alert className={cn('flex flex-col gap-2', className)} {...props}>
{children}
</Alert>
</ConfirmationContext.Provider>
);
};
Confirmation.displayName = 'Confirmation';
/* ─── ConfirmationTitle ──────────────────────────────────────────── */
export type ConfirmationTitleProps = React.HTMLAttributes<HTMLDivElement>;
export const ConfirmationTitle: React.FC<ConfirmationTitleProps> = ({
className,
...props
}) => (
<div
data-slot="confirmation-title"
className={cn('text-muted-foreground inline text-sm', className)}
{...props}
/>
);
ConfirmationTitle.displayName = 'ConfirmationTitle';
/* ─── ConfirmationRequest — visible only when pending ────────────── */
export interface ConfirmationRequestProps {
children?: React.ReactNode;
}
export const ConfirmationRequest: React.FC<ConfirmationRequestProps> = ({ children }) => {
const { approval } = useConfirmation();
if (approval !== 'pending') return null;
return <>{children}</>;
};
ConfirmationRequest.displayName = 'ConfirmationRequest';
/* ─── ConfirmationAccepted — visible only when approved ──────────── */
export interface ConfirmationAcceptedProps {
children?: React.ReactNode;
}
export const ConfirmationAccepted: React.FC<ConfirmationAcceptedProps> = ({ children }) => {
const { approval } = useConfirmation();
if (approval !== 'approved') return null;
return <>{children}</>;
};
ConfirmationAccepted.displayName = 'ConfirmationAccepted';
/* ─── ConfirmationRejected — visible only when rejected ──────────── */
export interface ConfirmationRejectedProps {
children?: React.ReactNode;
}
export const ConfirmationRejected: React.FC<ConfirmationRejectedProps> = ({ children }) => {
const { approval } = useConfirmation();
if (approval !== 'rejected') return null;
return <>{children}</>;
};
ConfirmationRejected.displayName = 'ConfirmationRejected';
/* ─── ConfirmationActions — visible only when pending ────────────── */
export type ConfirmationActionsProps = React.HTMLAttributes<HTMLDivElement>;
export const ConfirmationActions: React.FC<ConfirmationActionsProps> = ({
className,
...props
}) => {
const { approval } = useConfirmation();
if (approval !== 'pending') return null;
return (
<div
data-slot="confirmation-actions"
className={cn('flex items-center justify-end gap-2 self-end', className)}
{...props}
/>
);
};
ConfirmationActions.displayName = 'ConfirmationActions';
/* ─── ConfirmationAction — styled button ─────────────────────────── */
export type ConfirmationActionProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'default' | 'outline' | 'ghost' | 'destructive';
};
export const ConfirmationAction: React.FC<ConfirmationActionProps> = ({
variant = 'default',
...props
}) => (
<Button className="h-8 px-3 text-sm" variant={variant} type="button" {...props} />
);
ConfirmationAction.displayName = 'ConfirmationAction';
export { useConfirmation };

View File

@@ -1,5 +1,4 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { cn } from '../../../lib/utils';

View File

@@ -1,217 +0,0 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { cn } from '../../../lib/utils';
interface DialogContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
triggerRef: React.MutableRefObject<HTMLElement | null>;
}
const DialogContext = React.createContext<DialogContextValue | null>(null);
function useDialog() {
const ctx = React.useContext(DialogContext);
if (!ctx) throw new Error('Dialog components must be used within <Dialog>');
return ctx;
}
interface DialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean;
children: React.ReactNode;
}
const Dialog: React.FC<DialogProps> = ({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children }) => {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const triggerRef = React.useRef<HTMLElement | null>(null) as React.MutableRefObject<HTMLElement | null>;
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const onOpenChange = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
controlledOnOpenChange?.(next);
},
[isControlled, controlledOnOpenChange]
);
const value = React.useMemo(() => ({ open, onOpenChange, triggerRef }), [open, onOpenChange]);
return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;
};
const DialogTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }>(
({ onClick, children, asChild, ...props }, ref) => {
const { onOpenChange, triggerRef } = useDialog();
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
onOpenChange(true);
onClick?.(e);
},
[onOpenChange, onClick]
);
// asChild: clone child element and compose onClick + capture ref
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<any>;
return React.cloneElement(child, {
onClick: (e: React.MouseEvent<HTMLElement>) => {
onOpenChange(true);
child.props.onClick?.(e);
},
ref: (node: HTMLElement | null) => {
triggerRef.current = node;
// Forward the outer ref
if (typeof ref === 'function') ref(node as any);
else if (ref) (ref as React.MutableRefObject<any>).current = node;
},
});
}
return (
<button
ref={(node) => {
triggerRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}}
type="button"
onClick={handleClick}
{...props}
>
{children}
</button>
);
}
);
DialogTrigger.displayName = 'DialogTrigger';
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
onEscapeKeyDown?: () => void;
onPointerDownOutside?: () => void;
}
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ className, children, onEscapeKeyDown, onPointerDownOutside, ...props }, ref) => {
const { open, onOpenChange, triggerRef } = useDialog();
const contentRef = React.useRef<HTMLDivElement | null>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null);
// Save the element that had focus before opening, restore on close
React.useEffect(() => {
if (open) {
previousFocusRef.current = document.activeElement as HTMLElement;
} else if (previousFocusRef.current) {
// Prefer the trigger, fall back to whatever was focused before
const restoreTarget = triggerRef.current || previousFocusRef.current;
restoreTarget?.focus();
previousFocusRef.current = null;
}
}, [open, triggerRef]);
React.useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onEscapeKeyDown?.();
onOpenChange(false);
return;
}
// Focus trap: Tab / Shift+Tab cycle within the dialog
if (e.key === 'Tab' && contentRef.current) {
const focusable = Array.from(
contentRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown, true);
// Prevent body scroll
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
document.body.style.overflow = prev;
};
}, [open, onOpenChange, onEscapeKeyDown]);
// Auto-focus first focusable element on open
React.useEffect(() => {
if (open && contentRef.current) {
// Small delay to let the portal render
requestAnimationFrame(() => {
const first = contentRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
first?.focus();
});
}
}, [open]);
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-50">
{/* Overlay */}
<div
className="fixed inset-0 animate-dialog-overlay-show bg-black/50 backdrop-blur-sm"
onClick={() => {
onPointerDownOutside?.();
onOpenChange(false);
}}
aria-hidden
/>
{/* Content */}
<div
ref={(node) => {
contentRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
role="dialog"
aria-modal="true"
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2',
'rounded-xl border bg-popover text-popover-foreground shadow-lg',
'animate-dialog-content-show',
className
)}
{...props}
>
{children}
</div>
</div>,
document.body
);
}
);
DialogContent.displayName = 'DialogContent';
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2 ref={ref} className={cn('sr-only', className)} {...props} />
)
);
DialogTitle.displayName = 'DialogTitle';
export { Dialog, DialogTrigger, DialogContent, DialogTitle, useDialog };

View File

@@ -1,5 +1,4 @@
import * as React from 'react';
import { cn } from '../../../lib/utils';
type InputProps = React.InputHTMLAttributes<HTMLInputElement>;

View File

@@ -2,7 +2,6 @@
import { useTranslation } from 'react-i18next';
import { Languages } from 'lucide-react';
import { languages } from '../../../i18n/languages';
type LanguageSelectorProps = {

View File

@@ -1,5 +1,4 @@
import type { ReactNode } from 'react';
import { cn } from '../../../lib/utils';
/* ── Container ─────────────────────────────────────────────────── */

View File

@@ -1,219 +0,0 @@
"use client";
import * as React from 'react';
import { SendHorizonalIcon, SquareIcon } from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Button } from './Button';
import Tooltip from './Tooltip';
/* ─── Context ────────────────────────────────────────────────────── */
type PromptInputStatus = 'ready' | 'submitted' | 'streaming' | 'error';
interface PromptInputContextValue {
status: PromptInputStatus;
}
const PromptInputContext = React.createContext<PromptInputContextValue | null>(null);
const usePromptInput = () => {
const context = React.useContext(PromptInputContext);
if (!context) {
throw new Error('PromptInput components must be used within PromptInput');
}
return context;
};
/* ─── PromptInput (root form) ────────────────────────────────────── */
export interface PromptInputProps extends React.FormHTMLAttributes<HTMLFormElement> {
status?: PromptInputStatus;
}
export const PromptInput = React.forwardRef<HTMLFormElement, PromptInputProps>(
({ className, status = 'ready', children, ...props }, ref) => {
const contextValue = React.useMemo(() => ({ status }), [status]);
return (
<PromptInputContext.Provider value={contextValue}>
<form
ref={ref}
data-slot="prompt-input"
className={cn(
'relative overflow-hidden rounded-xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15',
className
)}
{...props}
>
{children}
</form>
</PromptInputContext.Provider>
);
}
);
PromptInput.displayName = 'PromptInput';
/* ─── PromptInputHeader ──────────────────────────────────────────── */
export const PromptInputHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="prompt-input-header"
className={cn('px-3 pt-3', className)}
{...props}
/>
));
PromptInputHeader.displayName = 'PromptInputHeader';
/* ─── PromptInputBody ────────────────────────────────────────────── */
export const PromptInputBody = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="prompt-input-body"
className={cn('relative', className)}
{...props}
/>
));
PromptInputBody.displayName = 'PromptInputBody';
/* ─── PromptInputTextarea ────────────────────────────────────────── */
export const PromptInputTextarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
data-slot="prompt-input-textarea"
className={cn(
'chat-input-placeholder block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-4 py-2 text-sm leading-6 text-foreground placeholder-muted-foreground/50 focus:outline-none sm:max-h-[300px]',
className
)}
{...props}
/>
));
PromptInputTextarea.displayName = 'PromptInputTextarea';
/* ─── PromptInputFooter ──────────────────────────────────────────── */
export const PromptInputFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="prompt-input-footer"
className={cn('flex items-center justify-between border-t border-border/30 px-3 py-2', className)}
{...props}
/>
));
PromptInputFooter.displayName = 'PromptInputFooter';
/* ─── PromptInputTools ───────────────────────────────────────────── */
export const PromptInputTools = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="prompt-input-tools"
className={cn('flex items-center gap-1', className)}
{...props}
/>
));
PromptInputTools.displayName = 'PromptInputTools';
/* ─── PromptInputButton ──────────────────────────────────────────── */
export interface PromptInputButtonTooltip {
content: React.ReactNode;
shortcut?: string;
side?: 'top' | 'bottom' | 'left' | 'right';
}
export interface PromptInputButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
tooltip?: PromptInputButtonTooltip;
}
export const PromptInputButton = React.forwardRef<HTMLButtonElement, PromptInputButtonProps>(
({ className, tooltip, children, ...props }, ref) => {
const button = (
<Button
ref={ref}
type="button"
variant="ghost"
size="icon"
className={cn('h-8 w-8 [&_svg]:size-4', className)}
{...props}
>
{children}
</Button>
);
if (tooltip) {
return (
<Tooltip
content={
tooltip.shortcut ? (
<span className="flex items-center gap-1.5">
{tooltip.content}
<kbd className="rounded bg-white/20 px-1 text-[10px]">{tooltip.shortcut}</kbd>
</span>
) : (
tooltip.content
)
}
position={tooltip.side ?? 'top'}
>
{button}
</Tooltip>
);
}
return button;
}
);
PromptInputButton.displayName = 'PromptInputButton';
/* ─── PromptInputSubmit ──────────────────────────────────────────── */
export interface PromptInputSubmitProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
status?: PromptInputStatus;
}
export const PromptInputSubmit = React.forwardRef<HTMLButtonElement, PromptInputSubmitProps>(
({ className, status: statusProp, children, ...props }, ref) => {
const context = React.useContext(PromptInputContext);
const status = statusProp ?? context?.status ?? 'ready';
const isActive = status === 'submitted' || status === 'streaming';
return (
<Button
ref={ref}
type={isActive ? 'button' : 'submit'}
variant="default"
size="icon"
className={cn('h-8 w-8 rounded-lg', className)}
{...props}
>
{children ?? (isActive ? (
<SquareIcon className="h-3.5 w-3.5 fill-current" />
) : (
<SendHorizonalIcon className="h-4 w-4" />
))}
</Button>
);
}
);
PromptInputSubmit.displayName = 'PromptInputSubmit';
export { usePromptInput };

View File

@@ -1,122 +0,0 @@
import * as React from 'react';
import { cn } from '../../../lib/utils';
/* ─── Types ──────────────────────────────────────────────────────── */
export type QueueItemStatus = 'completed' | 'in_progress' | 'pending';
/* ─── Context ────────────────────────────────────────────────────── */
interface QueueItemContextValue {
status: QueueItemStatus;
}
const QueueItemContext = React.createContext<QueueItemContextValue | null>(null);
function useQueueItem() {
const ctx = React.useContext(QueueItemContext);
if (!ctx) throw new Error('QueueItem sub-components must be used within <QueueItem>');
return ctx;
}
/* ─── Queue ──────────────────────────────────────────────────────── */
export const Queue = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="queue"
role="list"
className={cn('space-y-0.5', className)}
{...props}
/>
),
);
Queue.displayName = 'Queue';
/* ─── QueueItem ──────────────────────────────────────────────────── */
export interface QueueItemProps extends React.HTMLAttributes<HTMLDivElement> {
status?: QueueItemStatus;
}
export const QueueItem = React.forwardRef<HTMLDivElement, QueueItemProps>(
({ status = 'pending', className, children, ...props }, ref) => {
const value = React.useMemo(() => ({ status }), [status]);
return (
<QueueItemContext.Provider value={value}>
<div
ref={ref}
data-slot="queue-item"
data-status={status}
role="listitem"
className={cn('flex items-start gap-2 py-0.5', className)}
{...props}
>
{children}
</div>
</QueueItemContext.Provider>
);
},
);
QueueItem.displayName = 'QueueItem';
/* ─── QueueItemIndicator ─────────────────────────────────────────── */
export const QueueItemIndicator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { status } = useQueueItem();
return (
<div
ref={ref}
data-slot="queue-item-indicator"
aria-hidden="true"
className={cn('mt-0.5 flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center', className)}
{...props}
>
{status === 'completed' && (
<svg className="h-3.5 w-3.5 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
{status === 'in_progress' && (
<span className="h-2 w-2 animate-pulse rounded-full bg-blue-500 dark:bg-blue-400" />
)}
{status === 'pending' && (
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeWidth={2} />
</svg>
)}
</div>
);
},
);
QueueItemIndicator.displayName = 'QueueItemIndicator';
/* ─── QueueItemContent ───────────────────────────────────────────── */
export const QueueItemContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { status } = useQueueItem();
return (
<div
ref={ref}
data-slot="queue-item-content"
className={cn(
'min-w-0 flex-1 text-xs',
status === 'completed' && 'text-muted-foreground line-through',
status === 'in_progress' && 'font-medium text-foreground',
status === 'pending' && 'text-foreground',
className,
)}
{...props}
>
{children}
</div>
);
},
);
QueueItemContent.displayName = 'QueueItemContent';

View File

@@ -1,198 +0,0 @@
"use client";
import * as React from 'react';
import { BrainIcon, ChevronDownIcon } from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './Collapsible';
import { Shimmer } from './Shimmer';
/* ─── Context ────────────────────────────────────────────────────── */
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = React.createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = React.useContext(ReasoningContext);
if (!context) {
throw new Error('Reasoning components must be used within Reasoning');
}
return context;
};
/* ─── Reasoning (root) ───────────────────────────────────────────── */
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export interface ReasoningProps extends React.HTMLAttributes<HTMLDivElement> {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
}
export const Reasoning = React.memo<ReasoningProps>(
({
className,
isStreaming = false,
open: controlledOpen,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
const isExplicitlyClosed = defaultOpen === false;
// Controllable open state
const [internalOpen, setInternalOpen] = React.useState(resolvedDefaultOpen);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : internalOpen;
const setIsOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
// Duration tracking
const [duration, setDuration] = React.useState<number | undefined>(durationProp);
const hasEverStreamedRef = React.useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = React.useState(false);
const startTimeRef = React.useRef<number | null>(null);
// Sync external duration prop
React.useEffect(() => {
if (durationProp !== undefined) setDuration(durationProp);
}, [durationProp]);
// Track streaming start/end for duration
React.useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming]);
// Auto-open when streaming starts
React.useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close after streaming ends
React.useEffect(() => {
if (hasEverStreamedRef.current && !isStreaming && isOpen && !hasAutoClosed) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const contextValue = React.useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen]
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className={cn('not-prose', className)}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
Reasoning.displayName = 'Reasoning';
/* ─── ReasoningTrigger ───────────────────────────────────────────── */
export interface ReasoningTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => React.ReactNode;
}
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number): React.ReactNode => {
if (isStreaming || duration === 0) {
return <Shimmer>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = React.memo<ReasoningTriggerProps>(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
'flex w-full items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground',
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="h-4 w-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform',
isOpen ? 'rotate-180' : 'rotate-0'
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
ReasoningTrigger.displayName = 'ReasoningTrigger';
/* ─── ReasoningContent ───────────────────────────────────────────── */
export interface ReasoningContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export const ReasoningContent = React.memo<ReasoningContentProps>(
({ className, children, ...props }) => (
<CollapsibleContent
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
>
{children}
</CollapsibleContent>
)
);
ReasoningContent.displayName = 'ReasoningContent';

View File

@@ -1,5 +1,4 @@
import * as React from 'react';
import { cn } from '../../../lib/utils';
type ScrollAreaProps = React.HTMLAttributes<HTMLDivElement>;

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