mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 23:15:33 +08:00
Compare commits
4 Commits
main
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e92de7cb7 | ||
|
|
bacca8d62b | ||
|
|
aabf331e91 | ||
|
|
053e43447a |
@@ -3,13 +3,6 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add clarification on auto mode ([392c73b](https://github.com/siteboon/claudecodeui/commit/392c73b6933600ea8a589c5d4eff5f7b830f99c5))
|
||||
* enhance regex to correctly parse wrapper file paths for claude.exe ([#741](https://github.com/siteboon/claudecodeui/issues/741)) ([beb0a50](https://github.com/siteboon/claudecodeui/commit/beb0a50413beddfb16f6b49103e1b6b80567cb90))
|
||||
|
||||
## [1.31.5](https://github.com/siteboon/claudecodeui/compare/v1.31.4...v1.31.5) (2026-04-30)
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **Sitzungsverwaltung** – Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen
|
||||
- **Plugin-System** – CloudCLI mit eigenen Plugins erweitern – neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI Integration** *(Optional)* – Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung
|
||||
- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`public/modelConstants.js`](public/modelConstants.js))
|
||||
- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`shared/modelConstants.js`](shared/modelConstants.js))
|
||||
|
||||
|
||||
## Schnellstart
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
|
||||
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
|
||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`public/modelConstants.js`에서 전체 지원 모델 확인)
|
||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`shared/modelConstants.js`에서 전체 지원 모델 확인)
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](public/modelConstants.js) for the full list of supported models)
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
|
||||
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
|
||||
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`public/modelConstants.js`](public/modelConstants.js) для полного списка поддерживаемых моделей)
|
||||
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`shared/modelConstants.js`](shared/modelConstants.js) для полного списка поддерживаемых моделей)
|
||||
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **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 [`public/modelConstants.js`](public/modelConstants.js) dosyasına bak)
|
||||
- **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ıç
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
|
||||
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
|
||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js))
|
||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`shared/modelConstants.js`](shared/modelConstants.js))
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.32.0",
|
||||
"version": "1.31.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.32.0",
|
||||
"version": "1.31.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.32.0",
|
||||
"version": "1.31.5",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "dist-server/server/index.js",
|
||||
@@ -10,8 +10,6 @@
|
||||
"files": [
|
||||
"server/",
|
||||
"shared/",
|
||||
"public/api-docs.html",
|
||||
"public/modelConstants.js",
|
||||
"dist/",
|
||||
"dist-server/",
|
||||
"scripts/",
|
||||
|
||||
@@ -822,7 +822,7 @@ data: {"type":"done"}</code></pre>
|
||||
|
||||
<script type="module">
|
||||
// Import model constants
|
||||
import { PROVIDERS } from './modelConstants.js';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '/shared/modelConstants.js';
|
||||
|
||||
// Dynamic URL replacement
|
||||
const apiUrl = window.location.origin;
|
||||
@@ -834,14 +834,15 @@ data: {"type":"done"}</code></pre>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const modelCell = document.getElementById('model-options-cell');
|
||||
if (modelCell) {
|
||||
const providerModels = PROVIDERS.map(provider => {
|
||||
const models = provider.models.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
return `<strong>${provider.name}:</strong> ${models} (default: <code>${provider.models.DEFAULT}</code>)`;
|
||||
}).join('<br><br>');
|
||||
const claudeModels = CLAUDE_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
const cursorModels = CURSOR_MODELS.OPTIONS.slice(0, 8).map(m => `<code>${m.value}</code>`).join(', ');
|
||||
const codexModels = CODEX_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
|
||||
modelCell.innerHTML = `
|
||||
Model identifier for the AI provider:<br><br>
|
||||
${providerModels}
|
||||
<strong>Claude:</strong> ${claudeModels} (default: <code>${CLAUDE_MODELS.DEFAULT}</code>)<br><br>
|
||||
<strong>Cursor:</strong> ${cursorModels}, and more (default: <code>${CURSOR_MODELS.DEFAULT}</code>)<br><br>
|
||||
<strong>Codex:</strong> ${codexModels} (default: <code>${CODEX_MODELS.DEFAULT}</code>)
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,841 +0,0 @@
|
||||
/**
|
||||
* Documentation Model Definitions
|
||||
* Used by README links and the public API docs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Claude (Anthropic) Models
|
||||
*/
|
||||
export const CLAUDE_MODELS = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: "default",
|
||||
label: "Default (recommended)",
|
||||
description: "Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
label: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
label: "Sonnet (1M context)",
|
||||
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
label: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "default",
|
||||
};
|
||||
|
||||
/**
|
||||
* Cursor Models
|
||||
*/
|
||||
export const CURSOR_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "auto", label: "auto", description: "Auto" },
|
||||
{
|
||||
value: "composer-2-fast",
|
||||
label: "composer-2-fast",
|
||||
description: "Composer 2 Fast",
|
||||
},
|
||||
{
|
||||
value: "composer-2",
|
||||
label: "composer-2",
|
||||
description: "Composer 2",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-low",
|
||||
label: "gpt-5.3-codex-low",
|
||||
description: "Codex 5.3 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-low-fast",
|
||||
label: "gpt-5.3-codex-low-fast",
|
||||
description: "Codex 5.3 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex",
|
||||
label: "gpt-5.3-codex",
|
||||
description: "Codex 5.3",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-fast",
|
||||
label: "gpt-5.3-codex-fast",
|
||||
description: "Codex 5.3 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-high",
|
||||
label: "gpt-5.3-codex-high",
|
||||
description: "Codex 5.3 High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-high-fast",
|
||||
label: "gpt-5.3-codex-high-fast",
|
||||
description: "Codex 5.3 High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-xhigh",
|
||||
label: "gpt-5.3-codex-xhigh",
|
||||
description: "Codex 5.3 Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-xhigh-fast",
|
||||
label: "gpt-5.3-codex-xhigh-fast",
|
||||
description: "Codex 5.3 Extra High Fast",
|
||||
},
|
||||
{ value: "gpt-5.2", label: "gpt-5.2", description: "GPT-5.2" },
|
||||
{
|
||||
value: "gpt-5.2-codex-low",
|
||||
label: "gpt-5.2-codex-low",
|
||||
description: "Codex 5.2 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-low-fast",
|
||||
label: "gpt-5.2-codex-low-fast",
|
||||
description: "Codex 5.2 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex",
|
||||
label: "gpt-5.2-codex",
|
||||
description: "Codex 5.2",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-fast",
|
||||
label: "gpt-5.2-codex-fast",
|
||||
description: "Codex 5.2 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-high",
|
||||
label: "gpt-5.2-codex-high",
|
||||
description: "Codex 5.2 High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-high-fast",
|
||||
label: "gpt-5.2-codex-high-fast",
|
||||
description: "Codex 5.2 High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-xhigh",
|
||||
label: "gpt-5.2-codex-xhigh",
|
||||
description: "Codex 5.2 Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-xhigh-fast",
|
||||
label: "gpt-5.2-codex-xhigh-fast",
|
||||
description: "Codex 5.2 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-low",
|
||||
label: "gpt-5.1-codex-max-low",
|
||||
description: "Codex 5.1 Max Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-low-fast",
|
||||
label: "gpt-5.1-codex-max-low-fast",
|
||||
description: "Codex 5.1 Max Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-medium",
|
||||
label: "gpt-5.1-codex-max-medium",
|
||||
description: "Codex 5.1 Max",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-medium-fast",
|
||||
label: "gpt-5.1-codex-max-medium-fast",
|
||||
description: "Codex 5.1 Max Medium Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-high",
|
||||
label: "gpt-5.1-codex-max-high",
|
||||
description: "Codex 5.1 Max High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-high-fast",
|
||||
label: "gpt-5.1-codex-max-high-fast",
|
||||
description: "Codex 5.1 Max High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-xhigh",
|
||||
label: "gpt-5.1-codex-max-xhigh",
|
||||
description: "Codex 5.1 Max Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-xhigh-fast",
|
||||
label: "gpt-5.1-codex-max-xhigh-fast",
|
||||
description: "Codex 5.1 Max Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "composer-2.5",
|
||||
label: "composer-2.5",
|
||||
description: "Composer 2.5",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-high",
|
||||
label: "gpt-5.5-high",
|
||||
description: "GPT-5.5 1M High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-high-fast",
|
||||
label: "gpt-5.5-high-fast",
|
||||
description: "GPT-5.5 High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-high",
|
||||
label: "claude-opus-4-7-thinking-high",
|
||||
description: "Opus 4.7 1M High Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-high",
|
||||
label: "gpt-5.4-high",
|
||||
description: "GPT-5.4 1M High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-high-fast",
|
||||
label: "gpt-5.4-high-fast",
|
||||
description: "GPT-5.4 High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-high-thinking",
|
||||
label: "claude-4.6-opus-high-thinking",
|
||||
description: "Opus 4.6 1M Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-high-thinking-fast",
|
||||
label: "claude-4.6-opus-high-thinking-fast",
|
||||
description: "Opus 4.6 1M Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "composer-2.5-fast",
|
||||
label: "composer-2.5-fast",
|
||||
description: "Composer 2.5 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-none",
|
||||
label: "gpt-5.5-none",
|
||||
description: "GPT-5.5 1M None",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-none-fast",
|
||||
label: "gpt-5.5-none-fast",
|
||||
description: "GPT-5.5 None Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-low",
|
||||
label: "gpt-5.5-low",
|
||||
description: "GPT-5.5 1M Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-low-fast",
|
||||
label: "gpt-5.5-low-fast",
|
||||
description: "GPT-5.5 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-medium",
|
||||
label: "gpt-5.5-medium",
|
||||
description: "GPT-5.5 1M",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-medium-fast",
|
||||
label: "gpt-5.5-medium-fast",
|
||||
description: "GPT-5.5 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-extra-high",
|
||||
label: "gpt-5.5-extra-high",
|
||||
description: "GPT-5.5 1M Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-extra-high-fast",
|
||||
label: "gpt-5.5-extra-high-fast",
|
||||
description: "GPT-5.5 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-sonnet-medium",
|
||||
label: "claude-4.6-sonnet-medium",
|
||||
description: "Sonnet 4.6 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-sonnet-medium-thinking",
|
||||
label: "claude-4.6-sonnet-medium-thinking",
|
||||
description: "Sonnet 4.6 1M Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-low",
|
||||
label: "claude-opus-4-7-low",
|
||||
description: "Opus 4.7 1M Low",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-low-fast",
|
||||
label: "claude-opus-4-7-low-fast",
|
||||
description: "Opus 4.7 1M Low Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-medium",
|
||||
label: "claude-opus-4-7-medium",
|
||||
description: "Opus 4.7 1M Medium",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-medium-fast",
|
||||
label: "claude-opus-4-7-medium-fast",
|
||||
description: "Opus 4.7 1M Medium Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-high",
|
||||
label: "claude-opus-4-7-high",
|
||||
description: "Opus 4.7 1M High",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-high-fast",
|
||||
label: "claude-opus-4-7-high-fast",
|
||||
description: "Opus 4.7 1M High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-xhigh",
|
||||
label: "claude-opus-4-7-xhigh",
|
||||
description: "Opus 4.7 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-xhigh-fast",
|
||||
label: "claude-opus-4-7-xhigh-fast",
|
||||
description: "Opus 4.7 1M Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-max",
|
||||
label: "claude-opus-4-7-max",
|
||||
description: "Opus 4.7 1M Max",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-max-fast",
|
||||
label: "claude-opus-4-7-max-fast",
|
||||
description: "Opus 4.7 1M Max Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-low",
|
||||
label: "claude-opus-4-7-thinking-low",
|
||||
description: "Opus 4.7 1M Low Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-low-fast",
|
||||
label: "claude-opus-4-7-thinking-low-fast",
|
||||
description: "Opus 4.7 1M Low Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-medium",
|
||||
label: "claude-opus-4-7-thinking-medium",
|
||||
description: "Opus 4.7 1M Medium Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-medium-fast",
|
||||
label: "claude-opus-4-7-thinking-medium-fast",
|
||||
description: "Opus 4.7 1M Medium Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-high-fast",
|
||||
label: "claude-opus-4-7-thinking-high-fast",
|
||||
description: "Opus 4.7 1M High Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-xhigh",
|
||||
label: "claude-opus-4-7-thinking-xhigh",
|
||||
description: "Opus 4.7 1M Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-xhigh-fast",
|
||||
label: "claude-opus-4-7-thinking-xhigh-fast",
|
||||
description: "Opus 4.7 1M Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-max",
|
||||
label: "claude-opus-4-7-thinking-max",
|
||||
description: "Opus 4.7 1M Max Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-max-fast",
|
||||
label: "claude-opus-4-7-thinking-max-fast",
|
||||
description: "Opus 4.7 1M Max Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "grok-build-0.1",
|
||||
label: "grok-build-0.1",
|
||||
description: "Grok Build 0.1 1M",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-low",
|
||||
label: "gpt-5.4-low",
|
||||
description: "GPT-5.4 1M Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-medium",
|
||||
label: "gpt-5.4-medium",
|
||||
description: "GPT-5.4 1M",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-medium-fast",
|
||||
label: "gpt-5.4-medium-fast",
|
||||
description: "GPT-5.4 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-xhigh",
|
||||
label: "gpt-5.4-xhigh",
|
||||
description: "GPT-5.4 1M Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-xhigh-fast",
|
||||
label: "gpt-5.4-xhigh-fast",
|
||||
description: "GPT-5.4 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-high",
|
||||
label: "claude-4.6-opus-high",
|
||||
description: "Opus 4.6 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-max",
|
||||
label: "claude-4.6-opus-max",
|
||||
description: "Opus 4.6 1M Max",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-max-thinking",
|
||||
label: "claude-4.6-opus-max-thinking",
|
||||
description: "Opus 4.6 1M Max Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-max-thinking-fast",
|
||||
label: "claude-4.6-opus-max-thinking-fast",
|
||||
description: "Opus 4.6 1M Max Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-opus-high",
|
||||
label: "claude-4.5-opus-high",
|
||||
description: "Opus 4.5",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-opus-high-thinking",
|
||||
label: "claude-4.5-opus-high-thinking",
|
||||
description: "Opus 4.5 Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-low",
|
||||
label: "gpt-5.2-low",
|
||||
description: "GPT-5.2 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-low-fast",
|
||||
label: "gpt-5.2-low-fast",
|
||||
description: "GPT-5.2 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-fast",
|
||||
label: "gpt-5.2-fast",
|
||||
description: "GPT-5.2 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-high",
|
||||
label: "gpt-5.2-high",
|
||||
description: "GPT-5.2 High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-high-fast",
|
||||
label: "gpt-5.2-high-fast",
|
||||
description: "GPT-5.2 High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-xhigh",
|
||||
label: "gpt-5.2-xhigh",
|
||||
description: "GPT-5.2 Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-xhigh-fast",
|
||||
label: "gpt-5.2-xhigh-fast",
|
||||
description: "GPT-5.2 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.1-pro",
|
||||
label: "gemini-3.1-pro",
|
||||
description: "Gemini 3.1 Pro",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-none",
|
||||
label: "gpt-5.4-mini-none",
|
||||
description: "GPT-5.4 Mini None",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-low",
|
||||
label: "gpt-5.4-mini-low",
|
||||
description: "GPT-5.4 Mini Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-medium",
|
||||
label: "gpt-5.4-mini-medium",
|
||||
description: "GPT-5.4 Mini",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-high",
|
||||
label: "gpt-5.4-mini-high",
|
||||
description: "GPT-5.4 Mini High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-xhigh",
|
||||
label: "gpt-5.4-mini-xhigh",
|
||||
description: "GPT-5.4 Mini Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-none",
|
||||
label: "gpt-5.4-nano-none",
|
||||
description: "GPT-5.4 Nano None",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-low",
|
||||
label: "gpt-5.4-nano-low",
|
||||
description: "GPT-5.4 Nano Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-medium",
|
||||
label: "gpt-5.4-nano-medium",
|
||||
description: "GPT-5.4 Nano",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-high",
|
||||
label: "gpt-5.4-nano-high",
|
||||
description: "GPT-5.4 Nano High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-xhigh",
|
||||
label: "gpt-5.4-nano-xhigh",
|
||||
description: "GPT-5.4 Nano Extra High",
|
||||
},
|
||||
{
|
||||
value: "grok-4.3",
|
||||
label: "grok-4.3",
|
||||
description: "Grok 4.3 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-sonnet",
|
||||
label: "claude-4.5-sonnet",
|
||||
description: "Sonnet 4.5",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-sonnet-thinking",
|
||||
label: "claude-4.5-sonnet-thinking",
|
||||
description: "Sonnet 4.5 Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-low",
|
||||
label: "gpt-5.1-low",
|
||||
description: "GPT-5.1 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1",
|
||||
label: "gpt-5.1",
|
||||
description: "GPT-5.1",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-high",
|
||||
label: "gpt-5.1-high",
|
||||
description: "GPT-5.1 High",
|
||||
},
|
||||
{
|
||||
value: "gemini-3-flash",
|
||||
label: "gemini-3-flash",
|
||||
description: "Gemini 3 Flash",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.5-flash",
|
||||
label: "gemini-3.5-flash",
|
||||
description: "Gemini 3.5 Flash",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-mini-low",
|
||||
label: "gpt-5.1-codex-mini-low",
|
||||
description: "Codex 5.1 Mini Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-mini",
|
||||
label: "gpt-5.1-codex-mini",
|
||||
description: "Codex 5.1 Mini",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-mini-high",
|
||||
label: "gpt-5.1-codex-mini-high",
|
||||
description: "Codex 5.1 Mini High",
|
||||
},
|
||||
{
|
||||
value: "claude-4-sonnet",
|
||||
label: "claude-4-sonnet",
|
||||
description: "Sonnet 4",
|
||||
},
|
||||
{
|
||||
value: "claude-4-sonnet-thinking",
|
||||
label: "claude-4-sonnet-thinking",
|
||||
description: "Sonnet 4 Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5-mini",
|
||||
label: "gpt-5-mini",
|
||||
description: "GPT-5 Mini",
|
||||
},
|
||||
{
|
||||
value: "kimi-k2.5",
|
||||
label: "kimi-k2.5",
|
||||
description: "Kimi K2.5",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "composer-2.5-fast",
|
||||
};
|
||||
|
||||
/**
|
||||
* Codex (OpenAI) Models
|
||||
*/
|
||||
export const CODEX_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "gpt-5.5", label: "gpt-5.5" },
|
||||
{ value: "gpt-5.4", label: "gpt-5.4" },
|
||||
{ value: "gpt-5.4-mini", label: "gpt-5.4-mini" },
|
||||
{ value: "gpt-5.3-codex", label: "gpt-5.3-codex" },
|
||||
{ value: "gpt-5.2", label: "gpt-5.2" },
|
||||
],
|
||||
|
||||
DEFAULT: "gpt-5.4",
|
||||
};
|
||||
|
||||
/**
|
||||
* Gemini Models
|
||||
*/
|
||||
export const GEMINI_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
|
||||
{ value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
|
||||
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
|
||||
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
||||
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
|
||||
{
|
||||
value: "gemini-2.0-flash-thinking-exp",
|
||||
label: "Gemini 2.0 Flash Thinking",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "gemini-3.1-pro-preview",
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenCode Models
|
||||
*
|
||||
* OpenCode model ids include the upstream provider prefix.
|
||||
*/
|
||||
export const OPENCODE_MODELS = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: "opencode/big-pickle",
|
||||
label: "Big Pickle",
|
||||
description: "opencode - opencode/big-pickle",
|
||||
},
|
||||
{
|
||||
value: "opencode/deepseek-v4-flash-free",
|
||||
label: "Deepseek V4 Flash Free",
|
||||
description: "opencode - opencode/deepseek-v4-flash-free",
|
||||
},
|
||||
{
|
||||
value: "opencode/nemotron-3-super-free",
|
||||
label: "Nemotron 3 Super Free",
|
||||
description: "opencode - opencode/nemotron-3-super-free",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-haiku-20241022",
|
||||
label: "Claude 3.5 Haiku (2024-10-22)",
|
||||
description: "anthropic - anthropic/claude-3-5-haiku-20241022",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-haiku-latest",
|
||||
label: "Claude 3.5 Haiku Latest",
|
||||
description: "anthropic - anthropic/claude-3-5-haiku-latest",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-sonnet-20240620",
|
||||
label: "Claude 3.5 Sonnet (2024-06-20)",
|
||||
description: "anthropic - anthropic/claude-3-5-sonnet-20240620",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-sonnet-20241022",
|
||||
label: "Claude 3.5 Sonnet (2024-10-22)",
|
||||
description: "anthropic - anthropic/claude-3-5-sonnet-20241022",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-7-sonnet-20250219",
|
||||
label: "Claude 3.7 Sonnet (2025-02-19)",
|
||||
description: "anthropic - anthropic/claude-3-7-sonnet-20250219",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-haiku-20240307",
|
||||
label: "Claude 3 Haiku (2024-03-07)",
|
||||
description: "anthropic - anthropic/claude-3-haiku-20240307",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-opus-20240229",
|
||||
label: "Claude 3 Opus (2024-02-29)",
|
||||
description: "anthropic - anthropic/claude-3-opus-20240229",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-sonnet-20240229",
|
||||
label: "Claude 3 Sonnet (2024-02-29)",
|
||||
description: "anthropic - anthropic/claude-3-sonnet-20240229",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-haiku-4-5",
|
||||
label: "Claude Haiku 4.5",
|
||||
description: "anthropic - anthropic/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-haiku-4-5-20251001",
|
||||
label: "Claude Haiku 4.5 (2025-10-01)",
|
||||
description: "anthropic - anthropic/claude-haiku-4-5-20251001",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-0",
|
||||
label: "Claude Opus 4.0",
|
||||
description: "anthropic - anthropic/claude-opus-4-0",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-1",
|
||||
label: "Claude Opus 4.1",
|
||||
description: "anthropic - anthropic/claude-opus-4-1",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-1-20250805",
|
||||
label: "Claude Opus 4.1 (2025-08-05)",
|
||||
description: "anthropic - anthropic/claude-opus-4-1-20250805",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-20250514",
|
||||
label: "Claude Opus 4 (2025-05-14)",
|
||||
description: "anthropic - anthropic/claude-opus-4-20250514",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-5",
|
||||
label: "Claude Opus 4.5",
|
||||
description: "anthropic - anthropic/claude-opus-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-5-20251101",
|
||||
label: "Claude Opus 4.5 (2025-11-01)",
|
||||
description: "anthropic - anthropic/claude-opus-4-5-20251101",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-6",
|
||||
label: "Claude Opus 4.6",
|
||||
description: "anthropic - anthropic/claude-opus-4-6",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-6-fast",
|
||||
label: "Claude Opus 4.6 Fast",
|
||||
description: "anthropic - anthropic/claude-opus-4-6-fast",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-7",
|
||||
label: "Claude Opus 4.7",
|
||||
description: "anthropic - anthropic/claude-opus-4-7",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-7-fast",
|
||||
label: "Claude Opus 4.7 Fast",
|
||||
description: "anthropic - anthropic/claude-opus-4-7-fast",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-0",
|
||||
label: "Claude Sonnet 4.0",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-0",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-20250514",
|
||||
label: "Claude Sonnet 4 (2025-05-14)",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-20250514",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-5",
|
||||
label: "Claude Sonnet 4.5",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-5-20250929",
|
||||
label: "Claude Sonnet 4.5 (2025-09-29)",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-5-20250929",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-6",
|
||||
label: "Claude Sonnet 4.6",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.2",
|
||||
label: "GPT-5.2",
|
||||
description: "openai - openai/gpt-5.2",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.3-codex",
|
||||
label: "GPT-5.3 Codex",
|
||||
description: "openai - openai/gpt-5.3-codex",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.3-codex-spark",
|
||||
label: "GPT-5.3 Codex Spark",
|
||||
description: "openai - openai/gpt-5.3-codex-spark",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
description: "openai - openai/gpt-5.4",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4-fast",
|
||||
label: "GPT-5.4 Fast",
|
||||
description: "openai - openai/gpt-5.4-fast",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4-mini",
|
||||
label: "GPT-5.4 Mini",
|
||||
description: "openai - openai/gpt-5.4-mini",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4-mini-fast",
|
||||
label: "GPT-5.4 Mini Fast",
|
||||
description: "openai - openai/gpt-5.4-mini-fast",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.5",
|
||||
label: "GPT-5.5",
|
||||
description: "openai - openai/gpt-5.5",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.5-fast",
|
||||
label: "GPT-5.5 Fast",
|
||||
description: "openai - openai/gpt-5.5-fast",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.5-pro",
|
||||
label: "GPT-5.5 Pro",
|
||||
description: "openai - openai/gpt-5.5-pro",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "anthropic/claude-sonnet-4-5",
|
||||
};
|
||||
|
||||
/**
|
||||
* Ordered provider registry. Display order in documentation.
|
||||
*/
|
||||
export const PROVIDERS = [
|
||||
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS },
|
||||
{ id: "codex", name: "OpenAI", models: CODEX_MODELS },
|
||||
{ id: "gemini", name: "Google", models: GEMINI_MODELS },
|
||||
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS },
|
||||
{ id: "opencode", name: "OpenCode", models: OPENCODE_MODELS },
|
||||
];
|
||||
@@ -75,7 +75,7 @@
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/public/modelConstants.js) for the full list of supported models)
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/shared/modelConstants.js) for the full list of supported models)
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -17,8 +17,7 @@ import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
||||
import {
|
||||
createNotificationEvent,
|
||||
@@ -205,7 +204,7 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
|
||||
// Map model (default to sonnet)
|
||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
||||
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
||||
// Model logged at query start below
|
||||
|
||||
// Map system prompt configuration
|
||||
@@ -492,17 +491,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
};
|
||||
|
||||
try {
|
||||
const resolvedModel = await providerModelsService.resolveResumeModel(
|
||||
'claude',
|
||||
sessionId,
|
||||
options.model,
|
||||
);
|
||||
|
||||
// Map CLI options to SDK format
|
||||
const sdkOptions = mapCliOptionsToSDK({
|
||||
...options,
|
||||
model: resolvedModel || options.model,
|
||||
});
|
||||
const sdkOptions = mapCliOptionsToSDK(options);
|
||||
|
||||
// Load MCP configuration
|
||||
const mcpServers = await loadMcpConfig(options.cwd);
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
@@ -29,7 +28,6 @@ function isWorkspaceTrustPrompt(text = '') {
|
||||
async function spawnCursor(command, options = {}, ws) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options;
|
||||
const resolvedModel = await providerModelsService.resolveResumeModel('cursor', sessionId, model);
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
let hasRetriedWithTrust = false;
|
||||
@@ -54,10 +52,9 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Provide a prompt (works for both new and resumed sessions)
|
||||
baseArgs.push('-p', command);
|
||||
|
||||
// Model overrides are applied to both new and resumed sessions so a
|
||||
// session-scoped change request can take effect on the next turn.
|
||||
if (resolvedModel) {
|
||||
baseArgs.push('--model', resolvedModel);
|
||||
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
||||
if (!sessionId && model) {
|
||||
baseArgs.push('--model', model);
|
||||
}
|
||||
|
||||
// Request streaming JSON when we are providing a prompt
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
||||
@@ -121,11 +120,6 @@ async function buildGeminiProcessEnv() {
|
||||
|
||||
async function spawnGemini(command, options = {}, ws) {
|
||||
const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;
|
||||
const resolvedModel = await providerModelsService.resolveResumeModel(
|
||||
'gemini',
|
||||
sessionId,
|
||||
options.model
|
||||
);
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
||||
@@ -250,7 +244,7 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
}
|
||||
|
||||
// Add model for all sessions (both new and resumed)
|
||||
let modelToUse = resolvedModel || 'gemini-2.5-flash';
|
||||
let modelToUse = options.model || 'gemini-2.5-flash';
|
||||
args.push('--model', modelToUse);
|
||||
args.push('--output-format', 'stream-json');
|
||||
|
||||
|
||||
@@ -45,12 +45,6 @@ import {
|
||||
isGeminiSessionActive,
|
||||
getActiveGeminiSessions,
|
||||
} from './gemini-cli.js';
|
||||
import {
|
||||
spawnOpenCode,
|
||||
abortOpenCodeSession,
|
||||
isOpenCodeSessionActive,
|
||||
getActiveOpenCodeSessions,
|
||||
} from './opencode-cli.js';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import {
|
||||
stripAnsiSequences,
|
||||
@@ -100,25 +94,21 @@ const wss = createWebSocketServer(server, {
|
||||
spawnCursor,
|
||||
queryCodex,
|
||||
spawnGemini,
|
||||
spawnOpenCode,
|
||||
abortClaudeSDKSession,
|
||||
abortCursorSession,
|
||||
abortCodexSession,
|
||||
abortGeminiSession,
|
||||
abortOpenCodeSession,
|
||||
resolveToolApproval,
|
||||
isClaudeSDKSessionActive,
|
||||
isCursorSessionActive,
|
||||
isCodexSessionActive,
|
||||
isGeminiSessionActive,
|
||||
isOpenCodeSessionActive,
|
||||
reconnectSessionWriter,
|
||||
getPendingApprovalsForSession,
|
||||
getActiveClaudeSDKSessions,
|
||||
getActiveCursorSessions,
|
||||
getActiveCodexSessions,
|
||||
getActiveGeminiSessions,
|
||||
getActiveOpenCodeSessions,
|
||||
},
|
||||
shell: {
|
||||
getSessionById: (sessionId) => sessionManager.getSession(sessionId),
|
||||
@@ -1158,18 +1148,6 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
});
|
||||
}
|
||||
|
||||
// OpenCode token totals are surfaced through provider history reads.
|
||||
// This legacy endpoint only knows file-backed session formats.
|
||||
if (provider === 'opencode') {
|
||||
return res.json({
|
||||
used: 0,
|
||||
total: 0,
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking is available in OpenCode session history, not this legacy endpoint'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Codex sessions
|
||||
if (provider === 'codex') {
|
||||
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { initializeDatabase } from '@/modules/database/init-db.js';
|
||||
export { closeConnection, getConnection, getDatabasePath } from '@/modules/database/connection.js';
|
||||
export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
|
||||
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
||||
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
||||
|
||||
@@ -33,7 +33,6 @@ type ProjectApiView = {
|
||||
cursorSessions: [];
|
||||
codexSessions: [];
|
||||
geminiSessions: [];
|
||||
opencodeSessions: [];
|
||||
sessionMeta: {
|
||||
hasMore: false;
|
||||
total: 0;
|
||||
@@ -85,7 +84,6 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
|
||||
cursorSessions: [],
|
||||
codexSessions: [],
|
||||
geminiSessions: [],
|
||||
opencodeSessions: [],
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0,
|
||||
|
||||
@@ -14,7 +14,7 @@ type SessionSummary = {
|
||||
lastActivity: string;
|
||||
};
|
||||
|
||||
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
|
||||
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini', SessionSummary[]>;
|
||||
|
||||
type SessionRepositoryRow = {
|
||||
provider: string;
|
||||
@@ -34,7 +34,6 @@ export type ProjectListItem = {
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -75,7 +74,6 @@ export type ProjectSessionsPageApiView = {
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -141,7 +139,6 @@ function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByPr
|
||||
cursor: [],
|
||||
codex: [],
|
||||
gemini: [],
|
||||
opencode: [],
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -256,7 +253,6 @@ export async function getProjectsWithSessions(
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
@@ -313,7 +309,6 @@ export async function getArchivedProjectsWithSessions(
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
@@ -346,7 +341,6 @@ export async function getProjectSessionsPage(
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
|
||||
@@ -37,7 +37,6 @@ Current provider ids in this repo are:
|
||||
- `codex`
|
||||
- `cursor`
|
||||
- `gemini`
|
||||
- `opencode`
|
||||
|
||||
Those ids are mirrored in backend unions and frontend provider constants. If
|
||||
adding a new provider, update every place that hardcodes this list.
|
||||
@@ -56,8 +55,7 @@ server/modules/providers/list/<provider>/
|
||||
<provider>-session-synchronizer.provider.ts
|
||||
```
|
||||
|
||||
The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
|
||||
`opencode`.
|
||||
The existing provider folders are `claude`, `codex`, `cursor`, and `gemini`.
|
||||
|
||||
## What Each Facet Does
|
||||
|
||||
@@ -83,7 +81,7 @@ The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
|
||||
- Update `server/modules/providers/provider.routes.ts`.
|
||||
- Update `server/routes/agent.js` if the provider is launchable from the agent runtime.
|
||||
- Update `server/index.js` if the provider needs runtime boot or shutdown wiring.
|
||||
- Update `public/modelConstants.js` if the provider appears in README or public API docs.
|
||||
- Update `shared/modelConstants.js` if the provider appears in UI provider pickers.
|
||||
- Update `src/components/chat/hooks/useChatProviderState.ts` and
|
||||
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
|
||||
the provider should be selectable in chat.
|
||||
@@ -124,7 +122,6 @@ Current MCP formats in this repo are:
|
||||
| Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` |
|
||||
| Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` |
|
||||
| Gemini | `.gemini/settings.json` | `user`, `project` | `stdio`, `http` |
|
||||
| OpenCode | `~/.config/opencode/opencode.json` or `<workspace>/opencode.json` (`.jsonc` is read when present) | `user`, `project` | `stdio`, `http` |
|
||||
|
||||
5. Implement skills.
|
||||
|
||||
@@ -145,7 +142,6 @@ Current skill discovery roots are:
|
||||
| Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `<workspace>/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. |
|
||||
| Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
|
||||
| Gemini | `~/.gemini/skills`, `~/.agents/skills` | `<workspace>/.gemini/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
|
||||
| OpenCode | `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | Cwd-to-topmost-git-root `.opencode/skills`, `.claude/skills`, and `.agents/skills` | `/` | Reuses OpenCode, Claude, and Agents skill locations. Overlapping roots are deduplicated before scanning. |
|
||||
|
||||
Command forms currently used by the providers are:
|
||||
|
||||
@@ -154,7 +150,6 @@ Command forms currently used by the providers are:
|
||||
- Codex skills: `$skill-name`
|
||||
- Cursor skills: `/skill-name`
|
||||
- Gemini skills: `/skill-name`
|
||||
- OpenCode skills: `/skill-name`
|
||||
|
||||
6. Implement sessions.
|
||||
|
||||
@@ -192,7 +187,6 @@ Current session sync roots are:
|
||||
| Codex | `~/.codex/sessions/**/*.jsonl` | Uses `~/.codex/session_index.jsonl` for title lookup and the last `task_complete` message for a fallback title. |
|
||||
| Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. |
|
||||
| Gemini | `~/.gemini/tmp/**/*.jsonl` | Current full scans only index temp JSONL chat artifacts. Single-file sync also accepts legacy `.json` files. |
|
||||
| OpenCode | `~/.local/share/opencode/opencode.db` | Reads active sessions/messages/parts from OpenCode's shared SQLite database and stores `jsonl_path` as `null` so deleting one app session cannot remove the shared DB. |
|
||||
|
||||
8. Register the provider.
|
||||
|
||||
@@ -209,11 +203,10 @@ If the provider can run live chat sessions, update the runtime entrypoints too:
|
||||
|
||||
If the provider is visible in the UI, update:
|
||||
|
||||
- provider model fallback files under `server/modules/providers/list/<provider>/`
|
||||
- `shared/modelConstants.js`
|
||||
- `src/components/chat/hooks/useChatProviderState.ts`
|
||||
- `src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx`
|
||||
- `src/components/provider-auth/view/ProviderLoginModal.tsx`
|
||||
- `src/components/mcp/constants.ts`
|
||||
|
||||
## Minimal Wrapper Template
|
||||
|
||||
@@ -331,7 +324,6 @@ Useful tests in this repo:
|
||||
|
||||
- `server/modules/providers/tests/mcp.test.ts`
|
||||
- `server/modules/providers/tests/skills.test.ts`
|
||||
- `server/modules/providers/tests/opencode-sessions.test.ts`
|
||||
|
||||
If you touch sessions or session synchronization, add or update focused tests
|
||||
alongside the implementation.
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { query, type ModelInfo, type Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelOption,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'default', label: 'Default (recommended)' },
|
||||
{ value: 'sonnet[1m]', label: 'Sonnet (1M context)' },
|
||||
{ value: 'opus', label: 'Opus' },
|
||||
{ value: 'opus[1m]', label: 'Opus (1M context)' },
|
||||
{ value: 'haiku', label: 'Haiku' },
|
||||
{ value: 'sonnet', label: 'sonnet' },
|
||||
],
|
||||
DEFAULT: 'default',
|
||||
};
|
||||
|
||||
type ClaudeModelQueryOptions = Pick<Options, 'env' | 'pathToClaudeCodeExecutable' | 'permissionMode'>;
|
||||
type ClaudeInitEvent = {
|
||||
sessionId?: string;
|
||||
session_id?: string;
|
||||
type?: string;
|
||||
subtype?: string;
|
||||
model?: string;
|
||||
message?: {
|
||||
content?: unknown;
|
||||
model?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const ANSI_PATTERN = new RegExp(
|
||||
'[\\u001B\\u009B][[\\]()#;?]*(?:'
|
||||
+ '(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]'
|
||||
+ '|(?:[\\dA-PR-TZcf-ntqry=><~]))',
|
||||
'g',
|
||||
);
|
||||
|
||||
const buildClaudeQueryOptions = (): ClaudeModelQueryOptions => ({
|
||||
env: { ...process.env },
|
||||
pathToClaudeCodeExecutable: resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH),
|
||||
permissionMode: 'default',
|
||||
});
|
||||
|
||||
const mapClaudeModel = (model: ModelInfo): ProviderModelOption => ({
|
||||
value: model.value,
|
||||
label: model.displayName || model.value,
|
||||
description: model.description || undefined,
|
||||
});
|
||||
|
||||
const buildClaudeModelsDefinition = (models: ModelInfo[]): ProviderModelsDefinition => {
|
||||
const options: ProviderModelOption[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
for (const model of models) {
|
||||
const mappedModel = mapClaudeModel(model);
|
||||
if (seenValues.has(mappedModel.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(mappedModel.value);
|
||||
options.push(mappedModel);
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return CLAUDE_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
const defaultValue = options.find((option) => option.value === 'default')?.value
|
||||
?? options[0]?.value
|
||||
?? CLAUDE_FALLBACK_MODELS.DEFAULT;
|
||||
|
||||
return {
|
||||
OPTIONS: options,
|
||||
DEFAULT: defaultValue,
|
||||
};
|
||||
};
|
||||
|
||||
const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => {
|
||||
const eventSessionId = event.sessionId ?? event.session_id;
|
||||
if (eventSessionId && eventSessionId !== sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentModel = extractClaudeModelFromMessageContent(event.message?.content);
|
||||
if (contentModel) {
|
||||
return contentModel;
|
||||
}
|
||||
|
||||
const directModel = event.model?.trim();
|
||||
if (directModel) {
|
||||
return directModel;
|
||||
}
|
||||
|
||||
const messageModel = event.message?.model?.trim();
|
||||
return messageModel || null;
|
||||
};
|
||||
|
||||
const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, '');
|
||||
|
||||
const extractTaggedContent = (content: string, tagName: string): string | null => {
|
||||
const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const extractClaudeModelFromTextContent = (content: string): string | null => {
|
||||
const localCommandStdout = extractTaggedContent(content, 'local-command-stdout');
|
||||
if (localCommandStdout !== null) {
|
||||
const cleanedStdout = stripAnsi(localCommandStdout).replace(/\s+/g, ' ').trim();
|
||||
const changedModel = /(?:set|changed|switched)\s+model\s+to\s+(.+?)\.?$/i.exec(cleanedStdout);
|
||||
if (changedModel?.[1]?.trim()) {
|
||||
return changedModel[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
const modelTag = extractTaggedContent(content, 'model')?.trim();
|
||||
return modelTag || null;
|
||||
};
|
||||
|
||||
const extractClaudeModelFromMessageContent = (content: unknown): string | null => {
|
||||
if (typeof content === 'string') {
|
||||
return extractClaudeModelFromTextContent(content);
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object' || !('text' in part) || typeof part.text !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const model = extractClaudeModelFromTextContent(part.text);
|
||||
if (model) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const readClaudeSessionModelFromJsonl = async (
|
||||
sessionId: string,
|
||||
jsonlPath: string,
|
||||
): Promise<ProviderCurrentActiveModel | null> => {
|
||||
const content = await readFile(jsonlPath, 'utf8');
|
||||
const lines = content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
try {
|
||||
const event = JSON.parse(lines[index]) as ClaudeInitEvent;
|
||||
const model = extractClaudeEventModel(event, sessionId);
|
||||
if (model) {
|
||||
return { model };
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed JSONL lines that can happen during concurrent writes.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export class ClaudeProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
let queryInstance: ReturnType<typeof query> | null = null;
|
||||
|
||||
try {
|
||||
// The SDK exposes its runtime model catalog on the initialized query
|
||||
// instance, so we create a lightweight query and immediately close it
|
||||
// after reading the control-plane metadata.
|
||||
queryInstance = query({
|
||||
prompt: 'Get supported models',
|
||||
options: buildClaudeQueryOptions(),
|
||||
});
|
||||
|
||||
const supportedModels = await queryInstance.supportedModels();
|
||||
|
||||
return buildClaudeModelsDefinition(supportedModels);
|
||||
} catch {
|
||||
return CLAUDE_FALLBACK_MODELS;
|
||||
} finally {
|
||||
queryInstance?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
|
||||
if (!sessionId?.trim()) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonlPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
const activeModel = jsonlPath
|
||||
? await readClaudeSessionModelFromJsonl(sessionId, jsonlPath)
|
||||
: null;
|
||||
if (activeModel?.model) {
|
||||
return activeModel;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the provider default when the session-backed lookup fails.
|
||||
}
|
||||
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('claude', input);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js';
|
||||
import { ClaudeProviderModels } from '@/modules/providers/list/claude/claude-models.provider.js';
|
||||
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
|
||||
import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js';
|
||||
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
|
||||
import { ClaudeSkillsProvider } from '@/modules/providers/list/claude/claude-skills.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class ClaudeProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new ClaudeProviderModels();
|
||||
readonly mcp = new ClaudeMcpProvider();
|
||||
readonly auth: IProviderAuth = new ClaudeProviderAuth();
|
||||
readonly skills: IProviderSkills = new ClaudeSkillsProvider();
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import TOML from '@iarna/toml';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelOption,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'gpt-5.5', label: 'gpt-5.5' },
|
||||
{ value: 'gpt-5.4', label: 'gpt-5.4' },
|
||||
{ value: 'gpt-5.4-mini', label: 'gpt-5.4-mini' },
|
||||
{ value: 'gpt-5.3-codex', label: 'gpt-5.3-codex' },
|
||||
{ value: 'gpt-5.2', label: 'gpt-5.2' },
|
||||
],
|
||||
DEFAULT: 'gpt-5.4',
|
||||
};
|
||||
|
||||
type CodexCachedModel = {
|
||||
slug?: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
visibility?: string;
|
||||
supported_in_api?: boolean;
|
||||
};
|
||||
|
||||
const CODEX_MODELS_CACHE_PATH = path.join(os.homedir(), '.codex', 'models_cache.json');
|
||||
const CODEX_CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
|
||||
const isCodexCachedModel = (value: unknown): value is CodexCachedModel => {
|
||||
const record = readObjectRecord(value);
|
||||
return Boolean(record && readOptionalString(record.slug));
|
||||
};
|
||||
|
||||
const readCodexPriority = (value: unknown): number => (
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER
|
||||
);
|
||||
|
||||
const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({
|
||||
value: model.slug as string,
|
||||
label: readOptionalString(model.display_name) ?? (model.slug as string),
|
||||
description: readOptionalString(model.description),
|
||||
});
|
||||
|
||||
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
|
||||
const sortedModels = [...models]
|
||||
.filter((model) => model.visibility !== 'hidden' && model.supported_in_api !== false)
|
||||
.sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority));
|
||||
|
||||
const options: ProviderModelOption[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
for (const model of sortedModels) {
|
||||
const mappedModel = mapCodexModel(model);
|
||||
if (seenValues.has(mappedModel.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(mappedModel.value);
|
||||
options.push(mappedModel);
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return CODEX_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
return {
|
||||
OPTIONS: options,
|
||||
DEFAULT: options[0]?.value ?? CODEX_FALLBACK_MODELS.DEFAULT,
|
||||
};
|
||||
};
|
||||
|
||||
export class CodexProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
try {
|
||||
const raw = await readFile(CODEX_MODELS_CACHE_PATH, 'utf8');
|
||||
const parsed = readObjectRecord(JSON.parse(raw));
|
||||
const models = Array.isArray(parsed?.models)
|
||||
? parsed.models.filter(isCodexCachedModel)
|
||||
: [];
|
||||
|
||||
return buildCodexModelsDefinition(models);
|
||||
} catch {
|
||||
return CODEX_FALLBACK_MODELS;
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||
try {
|
||||
const raw = await readFile(CODEX_CONFIG_PATH, 'utf8');
|
||||
const parsed = readObjectRecord(TOML.parse(raw));
|
||||
const model = readOptionalString(parsed?.model);
|
||||
if (!model) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
};
|
||||
} catch {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('codex', input);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,52 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||
import {
|
||||
addUniqueProviderSkillSource,
|
||||
findTopmostGitRoot,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const hasGitMarker = async (dirPath: string): Promise<boolean> => {
|
||||
try {
|
||||
const gitMarkerStats = await fs.stat(path.join(dirPath, '.git'));
|
||||
return gitMarkerStats.isDirectory() || gitMarkerStats.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const findTopmostGitRoot = async (startPath: string): Promise<string | null> => {
|
||||
let currentPath = path.resolve(startPath);
|
||||
let topmostGitRoot: string | null = null;
|
||||
|
||||
while (true) {
|
||||
if (await hasGitMarker(currentPath)) {
|
||||
topmostGitRoot = currentPath;
|
||||
}
|
||||
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentPath = parentPath;
|
||||
}
|
||||
|
||||
return topmostGitRoot;
|
||||
};
|
||||
|
||||
const addUniqueSource = (
|
||||
sources: ProviderSkillSource[],
|
||||
seenRootDirs: Set<string>,
|
||||
source: ProviderSkillSource,
|
||||
): void => {
|
||||
const normalizedRootDir = path.resolve(source.rootDir);
|
||||
if (seenRootDirs.has(normalizedRootDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seenRootDirs.add(normalizedRootDir);
|
||||
sources.push({ ...source, rootDir: normalizedRootDir });
|
||||
};
|
||||
|
||||
export class CodexSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
@@ -18,7 +58,7 @@ export class CodexSkillsProvider extends SkillsProvider {
|
||||
const seenRootDirs = new Set<string>();
|
||||
const repoRoot = await findTopmostGitRoot(workspacePath);
|
||||
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
@@ -27,29 +67,29 @@ export class CodexSkillsProvider extends SkillsProvider {
|
||||
if (repoRoot) {
|
||||
// Codex checks repository skills at the launch folder, one folder above it,
|
||||
// and the topmost git root; these can collapse to the same directory.
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(path.dirname(workspacePath), '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(repoRoot, '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
}
|
||||
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
scope: 'admin',
|
||||
rootDir: path.join('/etc', 'codex', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
addUniqueSource(sources, seenRootDirs, {
|
||||
scope: 'system',
|
||||
rootDir: path.join(os.homedir(), '.codex', 'skills', '.system'),
|
||||
commandPrefix: '$',
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js';
|
||||
import { CodexProviderModels } from '@/modules/providers/list/codex/codex-models.provider.js';
|
||||
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
|
||||
import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js';
|
||||
import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js';
|
||||
import { CodexSkillsProvider } from '@/modules/providers/list/codex/codex-skills.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class CodexProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new CodexProviderModels();
|
||||
readonly mcp = new CodexMcpProvider();
|
||||
readonly auth: IProviderAuth = new CodexProviderAuth();
|
||||
readonly skills: IProviderSkills = new CodexSkillsProvider();
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { access, readdir } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelOption,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
sanitizeLeafDirectoryName,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: "opus-4.6-thinking", label: "Claude 4.6 Opus (Thinking)" },
|
||||
{ value: "gpt-5.3-codex", label: "GPT-5.3" },
|
||||
{ value: "gpt-5.2-high", label: "GPT-5.2 High" },
|
||||
{ value: "gemini-3-pro", label: "Gemini 3 Pro" },
|
||||
{ value: "opus-4.5-thinking", label: "Claude 4.5 Opus (Thinking)" },
|
||||
{ value: "gpt-5.2", label: "GPT-5.2" },
|
||||
{ value: "gpt-5.1", label: "GPT-5.1" },
|
||||
{ value: "gpt-5.1-high", label: "GPT-5.1 High" },
|
||||
{ value: "composer-1", label: "Composer 1" },
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "sonnet-4.5", label: "Claude 4.5 Sonnet" },
|
||||
{ value: "sonnet-4.5-thinking", label: "Claude 4.5 Sonnet (Thinking)" },
|
||||
{ value: "opus-4.5", label: "Claude 4.5 Opus" },
|
||||
{ value: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
|
||||
{ value: "gpt-5.1-codex-high", label: "GPT-5.1 Codex High" },
|
||||
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
||||
{ value: "gpt-5.1-codex-max-high", label: "GPT-5.1 Codex Max High" },
|
||||
{ value: "opus-4.1", label: "Claude 4.1 Opus" },
|
||||
{ value: "grok", label: "Grok" },
|
||||
],
|
||||
DEFAULT: 'composer-2-fast',
|
||||
};
|
||||
|
||||
type CursorModelRow = {
|
||||
name: string;
|
||||
description: string;
|
||||
current: boolean;
|
||||
default: boolean;
|
||||
};
|
||||
|
||||
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
|
||||
const CURSOR_CHATS_ROOT = path.join(os.homedir(), '.cursor', 'chats');
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
const ANSI_PATTERN = new RegExp(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
'[\\u001B\\u009B][[\\]()#;?]*(?:'
|
||||
+ '(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]'
|
||||
+ '|(?:[\\dA-PR-TZcf-ntqry=><~]))',
|
||||
'g',
|
||||
);
|
||||
|
||||
const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, '');
|
||||
|
||||
const parseModelLine = (line: string): CursorModelRow | null => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (
|
||||
!trimmed
|
||||
|| trimmed === 'Available models'
|
||||
|| trimmed.startsWith('Loading models')
|
||||
|| trimmed.startsWith('Tip:')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^(.+?)\s+-\s+(.+)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = match[1].trim();
|
||||
let description = match[2].trim();
|
||||
const current = /\(current\)/i.test(description);
|
||||
const defaultModel = /\(default\)/i.test(description);
|
||||
|
||||
description = description.replace(/\s*\((current|default)\)/gi, '').replace(/\s{2,}/g, ' ').trim();
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
current,
|
||||
default: defaultModel,
|
||||
};
|
||||
};
|
||||
|
||||
const parseModelsOutput = (text: string): CursorModelRow[] => {
|
||||
const models: CursorModelRow[] = [];
|
||||
|
||||
for (const line of stripAnsi(text).split(/\r?\n/)) {
|
||||
const parsed = parseModelLine(line);
|
||||
if (parsed) {
|
||||
models.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
};
|
||||
|
||||
const runCursorListModels = (): Promise<string> => new Promise((resolve, reject) => {
|
||||
const cursorProcess = spawnFunction('cursor-agent', ['--list-models'], {
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cursorProcess.kill('SIGTERM');
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error('cursor-agent --list-models timed out'));
|
||||
}
|
||||
}, CURSOR_MODELS_TIMEOUT_MS);
|
||||
|
||||
const finish = (error: Error | null, output: string) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(output);
|
||||
};
|
||||
|
||||
cursorProcess.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
cursorProcess.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
cursorProcess.on('error', (error) => {
|
||||
finish(error instanceof Error ? error : new Error(String(error)), '');
|
||||
});
|
||||
|
||||
cursorProcess.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
finish(new Error(stderr.trim() || `cursor-agent --list-models exited with code ${code}`), '');
|
||||
return;
|
||||
}
|
||||
|
||||
finish(null, stdout);
|
||||
});
|
||||
});
|
||||
|
||||
const buildCursorModelsDefinition = (models: CursorModelRow[]): ProviderModelsDefinition => {
|
||||
const options: ProviderModelOption[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
for (const model of models) {
|
||||
if (seenValues.has(model.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(model.name);
|
||||
options.push({
|
||||
value: model.name,
|
||||
label: model.name,
|
||||
description: model.description || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return CURSOR_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
const defaultValue = models.find((model) => model.default)?.name
|
||||
?? models.find((model) => model.current)?.name
|
||||
?? options[0]?.value
|
||||
?? CURSOR_FALLBACK_MODELS.DEFAULT;
|
||||
|
||||
return {
|
||||
OPTIONS: options,
|
||||
DEFAULT: defaultValue,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveCursorSessionStorePath = async (sessionId: string): Promise<string | null> => {
|
||||
const safeSessionId = sanitizeLeafDirectoryName(sessionId, 'cursor session id');
|
||||
|
||||
try {
|
||||
const workspaceEntries = await readdir(CURSOR_CHATS_ROOT, { withFileTypes: true });
|
||||
for (const workspaceEntry of workspaceEntries) {
|
||||
if (!workspaceEntry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const storeDbPath = path.join(CURSOR_CHATS_ROOT, workspaceEntry.name, safeSessionId, 'store.db');
|
||||
try {
|
||||
await access(storeDbPath);
|
||||
return storeDbPath;
|
||||
} catch {
|
||||
// Keep scanning sibling workspaces until the matching session directory is found.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export class CursorProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
try {
|
||||
const stdout = await runCursorListModels();
|
||||
const models = parseModelsOutput(stdout);
|
||||
return buildCursorModelsDefinition(models);
|
||||
} catch {
|
||||
return CURSOR_FALLBACK_MODELS;
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
|
||||
if (!sessionId?.trim()) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
try {
|
||||
const storeDbPath = await resolveCursorSessionStorePath(sessionId);
|
||||
if (!storeDbPath) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
try {
|
||||
const row = db.prepare(`SELECT value FROM meta WHERE key='0' LIMIT 1;`).get() as {
|
||||
value?: Buffer | string;
|
||||
} | undefined;
|
||||
const metadataText = Buffer.isBuffer(row?.value)
|
||||
? row.value.toString('utf8')
|
||||
: typeof row?.value === 'string' && row.value.trim()
|
||||
? Buffer.from(row.value.trim(), 'hex').toString('utf8')
|
||||
: '';
|
||||
if (!metadataText) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(metadataText) as { lastUsedModel?: string };
|
||||
if (typeof metadata.lastUsedModel === 'string' && metadata.lastUsedModel.trim()) {
|
||||
return {
|
||||
model: metadata.lastUsedModel.trim(),
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the provider default when Cursor metadata cannot be read.
|
||||
}
|
||||
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('cursor', input);
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,7 @@ 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,
|
||||
sanitizeLeafDirectoryName,
|
||||
} from '@/shared/utils.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'cursor';
|
||||
|
||||
@@ -191,6 +186,24 @@ function normalizeCursorToolInput(toolName: string, rawInput: unknown): unknown
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -201,7 +214,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const safeSessionId = sanitizeLeafDirectoryName(sessionId, 'cursor session id');
|
||||
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);
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
|
||||
import { CursorProviderModels } from '@/modules/providers/list/cursor/cursor-models.provider.js';
|
||||
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
|
||||
import { CursorSessionSynchronizer } from '@/modules/providers/list/cursor/cursor-session-synchronizer.provider.js';
|
||||
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
|
||||
import { CursorSkillsProvider } from '@/modules/providers/list/cursor/cursor-skills.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class CursorProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new CursorProviderModels();
|
||||
readonly mcp = new CursorMcpProvider();
|
||||
readonly auth: IProviderAuth = new CursorProviderAuth();
|
||||
readonly skills: IProviderSkills = new CursorSkillsProvider();
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
|
||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
|
||||
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' },
|
||||
],
|
||||
DEFAULT: 'gemini-3.1-pro-preview',
|
||||
};
|
||||
|
||||
export class GeminiProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
return GEMINI_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||
return buildDefaultProviderCurrentActiveModel(GEMINI_FALLBACK_MODELS);
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('gemini', input);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
|
||||
import { GeminiProviderModels } from '@/modules/providers/list/gemini/gemini-models.provider.js';
|
||||
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
|
||||
import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js';
|
||||
import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
|
||||
import { GeminiSkillsProvider } from '@/modules/providers/list/gemini/gemini-skills.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class GeminiProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new GeminiProviderModels();
|
||||
readonly mcp = new GeminiMcpProvider();
|
||||
readonly auth: IProviderAuth = new GeminiProviderAuth();
|
||||
readonly skills: IProviderSkills = new GeminiSkillsProvider();
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type OpenCodeCredentialsStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const OPENCODE_ENV_CREDENTIAL_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GROQ_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
];
|
||||
|
||||
export class OpenCodeProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Checks whether the OpenCode CLI is available to the server process.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
try {
|
||||
const result = spawn.sync('opencode', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return !result.error && result.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns OpenCode CLI installation and credential status.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
const credentials = await this.checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'opencode',
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads OpenCode's auth store or falls back to provider API key environment variables.
|
||||
*/
|
||||
private async checkCredentials(): Promise<OpenCodeCredentialsStatus> {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
|
||||
const content = await readFile(authPath, 'utf8');
|
||||
const auth = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
|
||||
for (const [providerId, providerAuth] of Object.entries(auth)) {
|
||||
const providerRecord = readObjectRecord(providerAuth);
|
||||
if (!providerRecord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasCredential = Object.values(providerRecord).some(
|
||||
(value) => readOptionalString(value) !== undefined || Boolean(readObjectRecord(value)),
|
||||
);
|
||||
if (hasCredential) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: `${providerId} credentials`,
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: error instanceof Error ? error.message : 'Failed to read OpenCode auth',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const envCredential = OPENCODE_ENV_CREDENTIAL_KEYS.find((key) => process.env[key]?.trim());
|
||||
if (envCredential) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: envCredential,
|
||||
method: 'environment',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'OpenCode not configured',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
type OpenCodeConfigPath = {
|
||||
filePath: string;
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
const fileExists = async (filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes JSONC comments without touching comment-like text inside strings.
|
||||
*/
|
||||
const stripJsonComments = (content: string): string => {
|
||||
let output = '';
|
||||
let inString = false;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const char = content[index];
|
||||
const next = content[index + 1];
|
||||
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === quote) {
|
||||
inString = false;
|
||||
quote = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === '\'') {
|
||||
inString = true;
|
||||
quote = char;
|
||||
output += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '/') {
|
||||
while (index < content.length && content[index] !== '\n') {
|
||||
index += 1;
|
||||
}
|
||||
output += '\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '*') {
|
||||
index += 2;
|
||||
while (index < content.length && !(content[index] === '*' && content[index + 1] === '/')) {
|
||||
index += 1;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += char;
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
const stripTrailingCommas = (content: string): string =>
|
||||
content.replace(/,\s*([}\]])/g, '$1');
|
||||
|
||||
const readOpenCodeConfig = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(stripTrailingCommas(stripJsonComments(content))) as unknown;
|
||||
return readObjectRecord(parsed) ?? {};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeOpenCodeConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
const resolveOpenCodeConfigPath = async (scope: McpScope, workspacePath: string): Promise<OpenCodeConfigPath> => {
|
||||
const root = scope === 'user'
|
||||
? path.join(os.homedir(), '.config', 'opencode')
|
||||
: workspacePath;
|
||||
const jsonPath = path.join(root, 'opencode.json');
|
||||
const jsoncPath = path.join(root, 'opencode.jsonc');
|
||||
|
||||
if (await fileExists(jsonPath)) {
|
||||
return { filePath: jsonPath, exists: true };
|
||||
}
|
||||
|
||||
if (await fileExists(jsoncPath)) {
|
||||
return { filePath: jsoncPath, exists: true };
|
||||
}
|
||||
|
||||
return { filePath: jsonPath, exists: false };
|
||||
};
|
||||
|
||||
export class OpenCodeMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('opencode', ['user', 'project'], ['stdio', 'http']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const { filePath } = await resolveOpenCodeConfigPath(scope, workspacePath);
|
||||
const config = await readOpenCodeConfig(filePath);
|
||||
return readObjectRecord(config.mcp) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const { filePath } = await resolveOpenCodeConfigPath(scope, workspacePath);
|
||||
const config = await readOpenCodeConfig(filePath);
|
||||
config.mcp = servers;
|
||||
await writeOpenCodeConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'local',
|
||||
command: [input.command, ...(input.args ?? [])],
|
||||
enabled: true,
|
||||
environment: input.env ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'remote',
|
||||
url: input.url,
|
||||
enabled: true,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
const config = readObjectRecord(rawConfig);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.type === 'local' || config.command !== undefined) {
|
||||
const commandParts = typeof config.command === 'string'
|
||||
? [config.command, ...(readStringArray(config.args) ?? [])]
|
||||
: readStringArray(config.command);
|
||||
const command = commandParts?.[0];
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'opencode',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command,
|
||||
args: commandParts.slice(1),
|
||||
env: readStringRecord(config.environment) ?? readStringRecord(config.env),
|
||||
};
|
||||
}
|
||||
|
||||
if (config.type === 'remote' || typeof config.url === 'string') {
|
||||
const url = readOptionalString(config.url);
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'opencode',
|
||||
name,
|
||||
scope,
|
||||
transport: 'http',
|
||||
url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelOption,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
getOpenCodeDatabasePath,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: 'anthropic/claude-sonnet-4-5',
|
||||
label: 'Claude Sonnet 4.5',
|
||||
description: 'anthropic - anthropic/claude-sonnet-4-5',
|
||||
},
|
||||
{
|
||||
value: 'anthropic/claude-opus-4-1',
|
||||
label: 'Claude Opus 4.1',
|
||||
description: 'anthropic - anthropic/claude-opus-4-1',
|
||||
},
|
||||
{
|
||||
value: 'anthropic/claude-haiku-4-5',
|
||||
label: 'Claude Haiku 4.5',
|
||||
description: 'anthropic - anthropic/claude-haiku-4-5',
|
||||
},
|
||||
{
|
||||
value: 'openai/gpt-5.1',
|
||||
label: 'GPT-5.1',
|
||||
description: 'openai - openai/gpt-5.1',
|
||||
},
|
||||
{
|
||||
value: 'openai/gpt-5.1-codex',
|
||||
label: 'GPT-5.1 Codex',
|
||||
description: 'openai - openai/gpt-5.1-codex',
|
||||
},
|
||||
{
|
||||
value: 'openai/gpt-5.4-mini',
|
||||
label: 'GPT-5.4 Mini',
|
||||
description: 'openai - openai/gpt-5.4-mini',
|
||||
},
|
||||
{
|
||||
value: 'google/gemini-2.5-pro',
|
||||
label: 'Gemini 2.5 Pro',
|
||||
description: 'google - google/gemini-2.5-pro',
|
||||
},
|
||||
{
|
||||
value: 'google/gemini-2.5-flash',
|
||||
label: 'Gemini 2.5 Flash',
|
||||
description: 'google - google/gemini-2.5-flash',
|
||||
},
|
||||
],
|
||||
DEFAULT: 'anthropic/claude-sonnet-4-5',
|
||||
};
|
||||
|
||||
const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000;
|
||||
const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
const DATE_TOKEN = /^\d{8}$/;
|
||||
const SIMPLE_NUMBER_TOKEN = /^\d$/;
|
||||
const VERSION_TOKEN = /^[a-z]\d+$/i;
|
||||
const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/;
|
||||
const SHORT_ACRONYM_TOKEN = /^[a-z]{2,3}$/;
|
||||
|
||||
export const parseOpenCodeModelsStdout = (stdout: string): string[] => {
|
||||
const ids: string[] = [];
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('{') || line.startsWith('[')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (MODEL_ID_LINE.test(line)) {
|
||||
ids.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(ids)];
|
||||
};
|
||||
|
||||
const formatDateToken = (token: string): string => (
|
||||
`${token.slice(0, 4)}-${token.slice(4, 6)}-${token.slice(6, 8)}`
|
||||
);
|
||||
|
||||
const formatModelToken = (token: string, nextToken?: string): string => {
|
||||
const lower = token.toLowerCase();
|
||||
|
||||
if (VERSION_TOKEN.test(token)) {
|
||||
return token.toUpperCase();
|
||||
}
|
||||
|
||||
if (SHORT_ACRONYM_TOKEN.test(lower) && nextToken && NUMERIC_TOKEN.test(nextToken)) {
|
||||
return token.toUpperCase();
|
||||
}
|
||||
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
};
|
||||
|
||||
const formatOpenCodeModelSlug = (slug: string): string => {
|
||||
const labelParts: string[] = [];
|
||||
const dateParts: string[] = [];
|
||||
const tokens = slug.split('-').filter(Boolean);
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
const nextToken = tokens[index + 1];
|
||||
|
||||
if (DATE_TOKEN.test(token)) {
|
||||
dateParts.push(formatDateToken(token));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SIMPLE_NUMBER_TOKEN.test(token) && nextToken && SIMPLE_NUMBER_TOKEN.test(nextToken)) {
|
||||
labelParts.push(`${token}.${nextToken}`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
labelParts.push(formatModelToken(token, nextToken));
|
||||
}
|
||||
|
||||
const label = (labelParts.join(' ').trim() || slug).replace(/^GPT\s+/, 'GPT-');
|
||||
if (dateParts.length === 0) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `${label} (${dateParts.join(', ')})`;
|
||||
};
|
||||
|
||||
const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: string } => {
|
||||
const separatorIndex = id.indexOf('/');
|
||||
if (separatorIndex < 0) {
|
||||
return {
|
||||
upstreamProvider: '',
|
||||
slug: id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
upstreamProvider: id.slice(0, separatorIndex),
|
||||
slug: id.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const labelForOpenCodeModelId = (id: string): string => {
|
||||
const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label;
|
||||
if (fallbackLabel) {
|
||||
return fallbackLabel;
|
||||
}
|
||||
|
||||
const { slug } = readOpenCodeModelParts(id);
|
||||
return formatOpenCodeModelSlug(slug);
|
||||
};
|
||||
|
||||
const descriptionForOpenCodeModelId = (id: string): string => {
|
||||
const { upstreamProvider } = readOpenCodeModelParts(id);
|
||||
return upstreamProvider ? `${upstreamProvider} - ${id}` : id;
|
||||
};
|
||||
|
||||
export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
|
||||
const options: ProviderModelOption[] = ids.map((value) => ({
|
||||
value,
|
||||
label: labelForOpenCodeModelId(value),
|
||||
description: descriptionForOpenCodeModelId(value),
|
||||
}));
|
||||
|
||||
const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value
|
||||
?? options[0]?.value
|
||||
?? OPENCODE_FALLBACK_MODELS.DEFAULT;
|
||||
|
||||
return {
|
||||
OPTIONS: options,
|
||||
DEFAULT: defaultValue,
|
||||
};
|
||||
};
|
||||
|
||||
const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => {
|
||||
if (typeof rawModel === 'string') {
|
||||
const trimmed = rawModel.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return parseOpenCodeSessionModelValue(JSON.parse(trimmed));
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
const record = readObjectRecord(rawModel);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readOptionalString(record.id)
|
||||
?? readOptionalString(record.model)
|
||||
?? readOptionalString(record.name)
|
||||
?? readOptionalString(record.value)
|
||||
?? null;
|
||||
};
|
||||
|
||||
const runOpenCodeModelsCommand = (): Promise<string> => new Promise((resolve, reject) => {
|
||||
const openCodeProcess = spawnFunction('opencode', ['models'], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
openCodeProcess.kill('SIGTERM');
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error('opencode models timed out'));
|
||||
}
|
||||
}, OPEN_CODE_MODELS_TIMEOUT_MS);
|
||||
|
||||
const finish = (error: Error | null, output: string) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(output);
|
||||
};
|
||||
|
||||
openCodeProcess.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
openCodeProcess.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
openCodeProcess.on('error', (error) => {
|
||||
finish(error instanceof Error ? error : new Error(String(error)), '');
|
||||
});
|
||||
|
||||
openCodeProcess.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
finish(new Error(stderr.trim() || `opencode models exited with code ${code}`), '');
|
||||
return;
|
||||
}
|
||||
|
||||
finish(null, stdout);
|
||||
});
|
||||
});
|
||||
|
||||
export class OpenCodeProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
try {
|
||||
const stdout = await runOpenCodeModelsCommand();
|
||||
const ids = parseOpenCodeModelsStdout(stdout);
|
||||
if (ids.length === 0) {
|
||||
return OPENCODE_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
return buildOpenCodeDefinitionFromIds(ids);
|
||||
} catch {
|
||||
return OPENCODE_FALLBACK_MODELS;
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
|
||||
if (!sessionId?.trim()) {
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
try {
|
||||
const dbPath = getOpenCodeDatabasePath();
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
try {
|
||||
const row = db.prepare(`
|
||||
SELECT
|
||||
s.id AS sessionId,
|
||||
s.model AS model,
|
||||
s.agent AS agent,
|
||||
s.directory AS directory,
|
||||
s.time_updated AS timeUpdated,
|
||||
s.time_created AS timeCreated
|
||||
FROM session s
|
||||
WHERE s.id = ?
|
||||
ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC
|
||||
LIMIT 1
|
||||
`).get(sessionId) as {
|
||||
sessionId?: string;
|
||||
model?: unknown;
|
||||
agent?: string | null;
|
||||
directory?: string | null;
|
||||
timeUpdated?: number | null;
|
||||
timeCreated?: number | null;
|
||||
} | undefined;
|
||||
|
||||
const model = parseOpenCodeSessionModelValue(row?.model);
|
||||
if (model) {
|
||||
return {
|
||||
model,
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the provider default when OpenCode session lookup fails.
|
||||
}
|
||||
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('opencode', input);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import fsSync from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
import {
|
||||
getOpenCodeDatabasePath,
|
||||
normalizeProviderTimestamp,
|
||||
normalizeSessionName,
|
||||
readJsonRecord,
|
||||
readOptionalString,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
type OpenCodeSessionRow = {
|
||||
id: string;
|
||||
directory: string | null;
|
||||
title: string | null;
|
||||
time_created: number | null;
|
||||
time_updated: number | null;
|
||||
worktree: string | null;
|
||||
};
|
||||
|
||||
type SynchronizeRowsResult = {
|
||||
processed: number;
|
||||
firstSessionId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Session indexer for OpenCode's SQLite-backed session store.
|
||||
*/
|
||||
export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'opencode' as const;
|
||||
|
||||
/**
|
||||
* Scans OpenCode's shared opencode.db and upserts active sessions into DB.
|
||||
*/
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const result = this.synchronizeRows(since);
|
||||
return result.processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles watcher changes for opencode.db.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
if (path.basename(filePath) !== 'opencode.db') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = this.synchronizeRows(undefined, 1);
|
||||
return result.firstSessionId;
|
||||
}
|
||||
|
||||
private synchronizeRows(since?: Date, limit?: number): SynchronizeRowsResult {
|
||||
const dbPath = getOpenCodeDatabasePath();
|
||||
if (!fsSync.existsSync(dbPath)) {
|
||||
return { processed: 0, firstSessionId: null };
|
||||
}
|
||||
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const sinceMillis = since?.getTime() ?? null;
|
||||
const limitClause = limit ? 'LIMIT ?' : '';
|
||||
const params = limit ? [sinceMillis, sinceMillis, limit] : [sinceMillis, sinceMillis];
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
s.id AS id,
|
||||
s.directory AS directory,
|
||||
s.title AS title,
|
||||
s.time_created AS time_created,
|
||||
s.time_updated AS time_updated,
|
||||
p.worktree AS worktree
|
||||
FROM session s
|
||||
LEFT JOIN project p ON p.id = s.project_id
|
||||
WHERE s.time_archived IS NULL
|
||||
AND (? IS NULL OR COALESCE(s.time_updated, s.time_created, 0) >= ?)
|
||||
ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC, s.id DESC
|
||||
${limitClause}
|
||||
`).all(...params) as OpenCodeSessionRow[];
|
||||
|
||||
let processed = 0;
|
||||
let firstSessionId: string | null = null;
|
||||
for (const row of rows) {
|
||||
const indexedSessionId = this.upsertSession(db, row);
|
||||
if (!indexedSessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!firstSessionId) {
|
||||
firstSessionId = indexedSessionId;
|
||||
}
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return { processed, firstSessionId };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn('[OpenCodeProvider] Failed to synchronize sessions:', message);
|
||||
return { processed: 0, firstSessionId: null };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
private upsertSession(db: Database.Database, row: OpenCodeSessionRow): string | null {
|
||||
const sessionId = readOptionalString(row.id);
|
||||
const projectPath = readOptionalString(row.directory) ?? readOptionalString(row.worktree);
|
||||
if (!sessionId || !projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackTitle = 'Untitled OpenCode Session';
|
||||
const existingSession = sessionsDb.getSessionById(sessionId);
|
||||
const existingName = existingSession?.custom_name;
|
||||
const nextName = existingName && existingName !== fallbackTitle
|
||||
? existingName
|
||||
: readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
|
||||
|
||||
// OpenCode stores every session in one shared sqlite database, so jsonl_path
|
||||
// must stay null to avoid deleting opencode.db when one app session is removed.
|
||||
sessionsDb.createSession(
|
||||
sessionId,
|
||||
this.provider,
|
||||
projectPath,
|
||||
normalizeSessionName(nextName, fallbackTitle),
|
||||
normalizeProviderTimestamp(row.time_created),
|
||||
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
|
||||
null,
|
||||
);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {
|
||||
try {
|
||||
const row = db.prepare(`
|
||||
SELECT p.data AS data
|
||||
FROM message m
|
||||
INNER JOIN part p
|
||||
ON p.session_id = m.session_id
|
||||
AND p.message_id = m.id
|
||||
WHERE m.session_id = ?
|
||||
AND json_extract(m.data, '$.role') = 'user'
|
||||
AND json_extract(p.data, '$.type') = 'text'
|
||||
ORDER BY COALESCE(m.time_created, 0), COALESCE(p.time_created, 0)
|
||||
LIMIT 1
|
||||
`).get(sessionId) as { data: string | null } | undefined;
|
||||
|
||||
const data = readJsonRecord(row?.data);
|
||||
return readOptionalString(data?.text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
import fsSync from 'node:fs';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import {
|
||||
createNormalizedMessage,
|
||||
generateMessageId,
|
||||
getOpenCodeDatabasePath,
|
||||
normalizeProviderTimestamp,
|
||||
readObjectRecord,
|
||||
readJsonRecord,
|
||||
readOptionalString,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'opencode';
|
||||
|
||||
type OpenCodeHistoryRow = {
|
||||
message_id: string;
|
||||
message_time_created: number | null;
|
||||
message_data: string | null;
|
||||
part_id: string | null;
|
||||
part_time_created: number | null;
|
||||
part_data: string | null;
|
||||
};
|
||||
|
||||
type OpenCodeTokenTotals = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
reasoningTokens: number;
|
||||
};
|
||||
|
||||
const openOpenCodeDatabase = (): Database.Database | null => {
|
||||
const dbPath = getOpenCodeDatabasePath();
|
||||
if (!fsSync.existsSync(dbPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
};
|
||||
|
||||
const formatToolContent = (value: unknown): string => {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenCode can persist the first prompt as a JSON string literal inside a text
|
||||
* part, for example `"hello"` instead of `hello`. Decode only complete JSON
|
||||
* string literals so normal assistant/user prose remains untouched.
|
||||
*/
|
||||
const unwrapJsonStringLiteral = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return typeof parsed === 'string' ? parsed : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const extractText = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return unwrapJsonStringLiteral(value);
|
||||
}
|
||||
|
||||
const record = readObjectRecord(value);
|
||||
const text = readOptionalString(record?.text)
|
||||
?? readOptionalString(record?.content)
|
||||
?? '';
|
||||
return unwrapJsonStringLiteral(text);
|
||||
};
|
||||
|
||||
const hasUserRole = (value: unknown): boolean => {
|
||||
const record = readObjectRecord(value);
|
||||
return readOptionalString(record?.role) === 'user';
|
||||
};
|
||||
|
||||
const isUserTextEcho = (raw: AnyRecord): boolean => {
|
||||
return readOptionalString(raw.role) === 'user'
|
||||
|| hasUserRole(raw.message)
|
||||
|| hasUserRole(raw.part);
|
||||
};
|
||||
|
||||
const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | undefined => {
|
||||
if (!totals) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inputTokens = totals.inputTokens;
|
||||
const outputTokens = totals.outputTokens;
|
||||
const cacheReadTokens = totals.cacheReadTokens;
|
||||
const cacheCreationTokens = totals.cacheCreationTokens;
|
||||
const reasoningTokens = totals.reasoningTokens;
|
||||
const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + reasoningTokens;
|
||||
|
||||
if (used <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
used,
|
||||
total: used,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenCode stores per-message token counts on assistant `message.data` objects
|
||||
* (see MessageV2.Assistant). Older DBs also had session-level counters; this
|
||||
* matches current `opencode.db` layouts that only persist message JSON.
|
||||
*/
|
||||
const aggregateOpenCodeSessionTokenUsage = (
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
): AnyRecord | undefined => {
|
||||
const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[];
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
let reasoningTokens = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const info = readJsonRecord(row.data);
|
||||
if (readOptionalString(info?.role) !== 'assistant') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokens = readObjectRecord(info?.tokens);
|
||||
if (!tokens) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inputTokens += Number(tokens.input ?? 0);
|
||||
outputTokens += Number(tokens.output ?? 0);
|
||||
reasoningTokens += Number(tokens.reasoning ?? 0);
|
||||
const cache = readObjectRecord(tokens.cache);
|
||||
cacheReadTokens += Number(cache?.read ?? 0);
|
||||
cacheCreationTokens += Number(cache?.write ?? 0);
|
||||
}
|
||||
|
||||
return buildTokenUsage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
reasoningTokens,
|
||||
});
|
||||
};
|
||||
|
||||
export class OpenCodeSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Normalizes live `opencode run --format json` events into frontend messages.
|
||||
*/
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
const raw = readObjectRecord(rawMessage);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const type = readOptionalString(raw.type) ?? readOptionalString(raw.event);
|
||||
const eventSessionId = readOptionalString(raw.sessionID) ?? readOptionalString(raw.sessionId) ?? sessionId;
|
||||
const timestamp = normalizeProviderTimestamp(raw.time ?? raw.timestamp);
|
||||
const baseId = readOptionalString(raw.id)
|
||||
?? readOptionalString(raw.messageID)
|
||||
?? generateMessageId('opencode');
|
||||
|
||||
if (type === 'text') {
|
||||
// The client already renders an optimistic user bubble, so provider user
|
||||
// echoes must not be streamed back as assistant text.
|
||||
if (isUserTextEcho(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = extractText(raw.text ?? raw.delta ?? raw.message);
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_delta',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'reasoning') {
|
||||
const content = extractText(raw.text ?? raw.delta ?? raw.message);
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'tool_use') {
|
||||
const toolName = readOptionalString(raw.tool) ?? readOptionalString(raw.name) ?? 'Tool';
|
||||
const toolId = readOptionalString(raw.callID) ?? readOptionalString(raw.toolCallId) ?? baseId;
|
||||
const toolMessage = createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName,
|
||||
toolInput: raw.input ?? raw.arguments ?? {},
|
||||
toolId,
|
||||
});
|
||||
|
||||
if (raw.output !== undefined || raw.error !== undefined) {
|
||||
toolMessage.toolResult = {
|
||||
content: formatToolContent(raw.output ?? raw.error),
|
||||
isError: raw.error !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return [toolMessage];
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown OpenCode error',
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'step_finish') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_end',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads OpenCode history from the shared SQLite session database.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
const db = openOpenCodeDatabase();
|
||||
if (!db) {
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
m.id AS message_id,
|
||||
m.time_created AS message_time_created,
|
||||
m.data AS message_data,
|
||||
p.id AS part_id,
|
||||
p.time_created AS part_time_created,
|
||||
p.data AS part_data
|
||||
FROM message m
|
||||
LEFT JOIN part p
|
||||
ON p.session_id = m.session_id
|
||||
AND p.message_id = m.id
|
||||
WHERE m.session_id = ?
|
||||
ORDER BY
|
||||
COALESCE(m.time_created, 0),
|
||||
m.id,
|
||||
COALESCE(p.time_created, 0),
|
||||
p.id
|
||||
`).all(sessionId) as OpenCodeHistoryRow[];
|
||||
|
||||
const normalized = this.normalizeHistoryRows(rows, sessionId);
|
||||
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
|
||||
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||
const total = normalized.length;
|
||||
const messages = normalizedLimit === null
|
||||
? normalized
|
||||
: normalized.slice(
|
||||
Math.max(0, total - normalizedOffset - normalizedLimit),
|
||||
Math.max(0, total - normalizedOffset),
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
total,
|
||||
hasMore: normalizedLimit === null
|
||||
? false
|
||||
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
|
||||
offset: normalizedOffset,
|
||||
limit: normalizedLimit,
|
||||
tokenUsage,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[OpenCodeProvider] Failed to load session ${sessionId}:`, message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeHistoryRows(rows: OpenCodeHistoryRow[], sessionId: string): NormalizedMessage[] {
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
const emittedMessageErrors = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const timestamp = normalizeProviderTimestamp(row.part_time_created ?? row.message_time_created);
|
||||
const baseId = `${row.message_id}_${row.part_id ?? normalized.length}`;
|
||||
const messageInfo = readJsonRecord(row.message_data);
|
||||
const messageRole = readOptionalString(messageInfo?.role);
|
||||
|
||||
if (
|
||||
messageInfo
|
||||
&& messageRole === 'assistant'
|
||||
&& messageInfo.error != null
|
||||
&& !emittedMessageErrors.has(row.message_id)
|
||||
) {
|
||||
emittedMessageErrors.add(row.message_id);
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_error`,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: formatToolContent(messageInfo.error),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!row.part_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const partData = readJsonRecord(row.part_data) ?? {};
|
||||
const partType = readOptionalString(partData.type);
|
||||
if (!partType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'text') {
|
||||
const content = extractText(partData);
|
||||
if (content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: messageRole === 'user' ? 'user' : 'assistant',
|
||||
content,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'reasoning') {
|
||||
const content = extractText(partData);
|
||||
if (content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'tool') {
|
||||
const state = readObjectRecord(partData.state) ?? {};
|
||||
const status = readOptionalString(state.status);
|
||||
const toolMessage = createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: readOptionalString(partData.tool) ?? 'Tool',
|
||||
toolInput: state.input ?? partData.input ?? {},
|
||||
toolId: readOptionalString(partData.callID) ?? row.part_id,
|
||||
});
|
||||
|
||||
if (status === 'completed' || status === 'error') {
|
||||
toolMessage.toolResult = {
|
||||
content: formatToolContent(state.output ?? state.error),
|
||||
isError: status === 'error',
|
||||
};
|
||||
}
|
||||
|
||||
normalized.push(toolMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'step-finish') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_end',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'patch' || partType === 'agent') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: partType === 'patch' ? 'Patch' : 'Agent',
|
||||
toolInput: partData,
|
||||
toolId: row.part_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||
import {
|
||||
addUniqueProviderSkillSource,
|
||||
findTopmostGitRoot,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const OPENCODE_PROJECT_SKILL_DIRS = [
|
||||
['.opencode', 'skills'],
|
||||
['.claude', 'skills'],
|
||||
['.agents', 'skills'],
|
||||
];
|
||||
|
||||
const OPENCODE_USER_SKILL_DIRS = [
|
||||
['.config', 'opencode', 'skills'],
|
||||
['.claude', 'skills'],
|
||||
['.agents', 'skills'],
|
||||
];
|
||||
|
||||
export class OpenCodeSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('opencode');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
const sources: ProviderSkillSource[] = [];
|
||||
const seenRootDirs = new Set<string>();
|
||||
const repoRoot = await findTopmostGitRoot(workspacePath);
|
||||
|
||||
for (const projectRoot of this.getProjectSearchRoots(workspacePath, repoRoot)) {
|
||||
for (const skillDir of OPENCODE_PROJECT_SKILL_DIRS) {
|
||||
// OpenCode intentionally reads Claude and Agents skill folders so users
|
||||
// can reuse the same skill libraries across compatible coding agents.
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'project',
|
||||
rootDir: path.join(projectRoot, ...skillDir),
|
||||
commandPrefix: '/',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const skillDir of OPENCODE_USER_SKILL_DIRS) {
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), ...skillDir),
|
||||
commandPrefix: '/',
|
||||
});
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
private getProjectSearchRoots(workspacePath: string, repoRoot: string | null): string[] {
|
||||
const roots: string[] = [];
|
||||
const normalizedWorkspacePath = path.resolve(workspacePath);
|
||||
const normalizedRepoRoot = repoRoot ? path.resolve(repoRoot) : null;
|
||||
let currentPath = normalizedWorkspacePath;
|
||||
|
||||
while (true) {
|
||||
roots.push(currentPath);
|
||||
if (!normalizedRepoRoot || currentPath === normalizedRepoRoot) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentPath = parentPath;
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { OpenCodeProviderAuth } from '@/modules/providers/list/opencode/opencode-auth.provider.js';
|
||||
import { OpenCodeProviderModels } from '@/modules/providers/list/opencode/opencode-models.provider.js';
|
||||
import { OpenCodeMcpProvider } from '@/modules/providers/list/opencode/opencode-mcp.provider.js';
|
||||
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
|
||||
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
|
||||
import { OpenCodeSkillsProvider } from '@/modules/providers/list/opencode/opencode-skills.provider.js';
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class OpenCodeProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new OpenCodeProviderModels();
|
||||
readonly mcp = new OpenCodeMcpProvider();
|
||||
readonly auth: IProviderAuth = new OpenCodeProviderAuth();
|
||||
readonly skills: IProviderSkills = new OpenCodeSkillsProvider();
|
||||
readonly sessions: IProviderSessions = new OpenCodeSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new OpenCodeSessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('opencode');
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
|
||||
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
||||
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
|
||||
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
|
||||
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
|
||||
import type { IProvider } from '@/shared/interfaces.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
@@ -12,7 +11,6 @@ const providers: Record<LLMProvider, IProvider> = {
|
||||
codex: new CodexProvider(),
|
||||
cursor: new CursorProvider(),
|
||||
gemini: new GeminiProvider(),
|
||||
opencode: new OpenCodeProvider(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,17 +2,10 @@ import express, { type Request, type Response } from 'express';
|
||||
|
||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
|
||||
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
||||
import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
|
||||
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
McpScope,
|
||||
McpTransport,
|
||||
ProviderChangeActiveModelInput,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -180,13 +173,7 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
|
||||
|
||||
const parseProvider = (value: unknown): LLMProvider => {
|
||||
const normalized = normalizeProviderParam(value);
|
||||
if (
|
||||
normalized === 'claude'
|
||||
|| normalized === 'codex'
|
||||
|| normalized === 'cursor'
|
||||
|| normalized === 'gemini'
|
||||
|| normalized === 'opencode'
|
||||
) {
|
||||
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -252,29 +239,6 @@ const parseSessionSearchLimit = (value: unknown): number => {
|
||||
return Math.max(1, Math.min(parsed, 100));
|
||||
};
|
||||
|
||||
const parseChangeActiveModelPayload = (payload: unknown): ProviderChangeActiveModelInput => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const body = payload as Record<string, unknown>;
|
||||
const model = readOptionalQueryString(body.model);
|
||||
if (!model) {
|
||||
throw new AppError('model is required.', {
|
||||
code: 'MODEL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: '',
|
||||
model,
|
||||
};
|
||||
};
|
||||
|
||||
router.get(
|
||||
'/:provider/auth/status',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
@@ -284,30 +248,6 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:provider/models',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const bypassCache = parseOptionalBooleanQuery(req.query.bypassCache, 'bypassCache') ?? false;
|
||||
const result = await providerModelsService.getProviderModels(provider, { bypassCache });
|
||||
res.json(createApiSuccessResponse({ provider, models: result.models, cache: result.cache }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/sessions/:sessionId/active-model',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const sessionId = parseSessionId(req.params.sessionId);
|
||||
const payload = parseChangeActiveModelPayload(req.body);
|
||||
const result = await providerModelsService.changeActiveModel(provider, {
|
||||
...payload,
|
||||
sessionId,
|
||||
});
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- Skills routes -----------------
|
||||
router.get(
|
||||
'/:provider/skills',
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { IProvider } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsCacheInfo,
|
||||
ProviderModelsDefinition,
|
||||
ProviderModelsResult,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
|
||||
|
||||
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
||||
|
||||
type ProviderModelsServiceDependencies = {
|
||||
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
||||
cachePath?: string;
|
||||
activeModelChangesPath?: string;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
type ProviderModelsOptions = {
|
||||
bypassCache?: boolean;
|
||||
};
|
||||
|
||||
type ProviderModelsCacheEntry = {
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
models: ProviderModelsDefinition;
|
||||
};
|
||||
|
||||
type ProviderModelsCacheFile = {
|
||||
version: number;
|
||||
entries: Record<string, ProviderModelsCacheEntry>;
|
||||
};
|
||||
|
||||
const getProviderModelsCachePath = (): string => path.join(
|
||||
os.homedir(),
|
||||
'.cloudcli',
|
||||
'provider-models-cache.json',
|
||||
);
|
||||
|
||||
const toProviderModelsCacheInfo = (
|
||||
entry: ProviderModelsCacheEntry,
|
||||
source: ProviderModelsCacheInfo['source'],
|
||||
): ProviderModelsCacheInfo => ({
|
||||
updatedAt: new Date(entry.updatedAt).toISOString(),
|
||||
expiresAt: new Date(entry.expiresAt).toISOString(),
|
||||
source,
|
||||
});
|
||||
|
||||
const isProviderModelOption = (
|
||||
value: unknown,
|
||||
): value is ProviderModelsDefinition['OPTIONS'][number] => (
|
||||
Boolean(value)
|
||||
&& typeof value === 'object'
|
||||
&& typeof (value as ProviderModelsDefinition['OPTIONS'][number]).value === 'string'
|
||||
&& typeof (value as ProviderModelsDefinition['OPTIONS'][number]).label === 'string'
|
||||
&& (
|
||||
typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'undefined'
|
||||
|| typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'string'
|
||||
)
|
||||
);
|
||||
|
||||
const isProviderModelsDefinition = (value: unknown): value is ProviderModelsDefinition => (
|
||||
Boolean(value)
|
||||
&& typeof value === 'object'
|
||||
&& Array.isArray((value as ProviderModelsDefinition).OPTIONS)
|
||||
&& (value as ProviderModelsDefinition).OPTIONS.every(isProviderModelOption)
|
||||
&& typeof (value as ProviderModelsDefinition).DEFAULT === 'string'
|
||||
);
|
||||
|
||||
const isProviderModelsCacheEntry = (value: unknown): value is ProviderModelsCacheEntry => (
|
||||
Boolean(value)
|
||||
&& typeof value === 'object'
|
||||
&& typeof (value as ProviderModelsCacheEntry).updatedAt === 'number'
|
||||
&& typeof (value as ProviderModelsCacheEntry).expiresAt === 'number'
|
||||
&& isProviderModelsDefinition((value as ProviderModelsCacheEntry).models)
|
||||
);
|
||||
|
||||
const readProviderModelsCacheFile = async (
|
||||
cachePath: string,
|
||||
): Promise<ProviderModelsCacheFile | null> => {
|
||||
try {
|
||||
const raw = await readFile(cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<ProviderModelsCacheFile>;
|
||||
if (parsed.version !== PROVIDER_MODELS_CACHE_VERSION || !parsed.entries || typeof parsed.entries !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = Object.fromEntries(
|
||||
Object.entries(parsed.entries).filter((entry): entry is [string, ProviderModelsCacheEntry] =>
|
||||
isProviderModelsCacheEntry(entry[1]),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
version: PROVIDER_MODELS_CACHE_VERSION,
|
||||
entries,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeProviderModelsCacheFile = async (
|
||||
cachePath: string,
|
||||
entries: Map<LLMProvider, ProviderModelsCacheEntry>,
|
||||
now: number,
|
||||
): Promise<void> => {
|
||||
const serializableEntries = Object.fromEntries(
|
||||
[...entries.entries()].filter(([, entry]) => entry.expiresAt > now),
|
||||
);
|
||||
const payload: ProviderModelsCacheFile = {
|
||||
version: PROVIDER_MODELS_CACHE_VERSION,
|
||||
entries: serializableEntries,
|
||||
};
|
||||
|
||||
await mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider model lookup service.
|
||||
*
|
||||
* Routes and other service callers use this layer instead of resolving provider
|
||||
* classes directly so the provider-registry dependency stays centralized in one
|
||||
* place.
|
||||
*/
|
||||
export const createProviderModelsService = (dependencies: ProviderModelsServiceDependencies = {}) => {
|
||||
const resolveProvider = dependencies.resolveProvider ?? providerRegistry.resolveProvider;
|
||||
const cachePath = dependencies.cachePath ?? getProviderModelsCachePath();
|
||||
const activeModelChangesPath = dependencies.activeModelChangesPath;
|
||||
const now = dependencies.now ?? (() => Date.now());
|
||||
const memoryCache = new Map<LLMProvider, ProviderModelsCacheEntry>();
|
||||
const pendingRequests = new Map<LLMProvider, Promise<ProviderModelsResult>>();
|
||||
let persistedCacheLoaded = false;
|
||||
let persistedCacheLoadPromise: Promise<void> | null = null;
|
||||
|
||||
const pruneExpiredMemoryEntry = (
|
||||
provider: LLMProvider,
|
||||
currentTime: number,
|
||||
source: ProviderModelsCacheInfo['source'],
|
||||
): ProviderModelsResult | null => {
|
||||
const cachedEntry = memoryCache.get(provider);
|
||||
if (!cachedEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cachedEntry.expiresAt > currentTime) {
|
||||
return {
|
||||
models: cachedEntry.models,
|
||||
cache: toProviderModelsCacheInfo(cachedEntry, source),
|
||||
};
|
||||
}
|
||||
|
||||
memoryCache.delete(provider);
|
||||
return null;
|
||||
};
|
||||
|
||||
const loadPersistedCache = async (): Promise<void> => {
|
||||
if (persistedCacheLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!persistedCacheLoadPromise) {
|
||||
persistedCacheLoadPromise = (async () => {
|
||||
const cacheFile = await readProviderModelsCacheFile(cachePath);
|
||||
const currentTime = now();
|
||||
|
||||
for (const [provider, entry] of Object.entries(cacheFile?.entries ?? {})) {
|
||||
if (entry.expiresAt > currentTime) {
|
||||
memoryCache.set(provider as LLMProvider, entry);
|
||||
}
|
||||
}
|
||||
|
||||
persistedCacheLoaded = true;
|
||||
})().finally(() => {
|
||||
persistedCacheLoadPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
await persistedCacheLoadPromise;
|
||||
};
|
||||
|
||||
const persistCache = async (): Promise<void> => {
|
||||
try {
|
||||
await writeProviderModelsCacheFile(cachePath, memoryCache, now());
|
||||
} catch (error) {
|
||||
console.warn('Unable to persist provider models cache:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const setCacheEntry = async (
|
||||
provider: LLMProvider,
|
||||
models: ProviderModelsDefinition,
|
||||
): Promise<ProviderModelsCacheEntry> => {
|
||||
const currentTime = now();
|
||||
const entry: ProviderModelsCacheEntry = {
|
||||
updatedAt: currentTime,
|
||||
expiresAt: currentTime + PROVIDER_MODELS_CACHE_TTL_MS,
|
||||
models,
|
||||
};
|
||||
|
||||
memoryCache.set(provider, entry);
|
||||
await persistCache();
|
||||
return entry;
|
||||
};
|
||||
|
||||
const loadAndCacheModels = (
|
||||
provider: LLMProvider,
|
||||
): Promise<ProviderModelsResult> => {
|
||||
const request = resolveProvider(provider).models.getSupportedModels()
|
||||
.then(async (models) => {
|
||||
const entry = await setCacheEntry(provider, models);
|
||||
return {
|
||||
models,
|
||||
cache: toProviderModelsCacheInfo(entry, 'fresh'),
|
||||
};
|
||||
})
|
||||
.finally(() => {
|
||||
pendingRequests.delete(provider);
|
||||
});
|
||||
|
||||
pendingRequests.set(provider, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const getProviderModels = async (
|
||||
provider: LLMProvider,
|
||||
options: ProviderModelsOptions = {},
|
||||
): Promise<ProviderModelsResult> => {
|
||||
if (options.bypassCache) {
|
||||
const pendingRequest = pendingRequests.get(provider);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
return loadAndCacheModels(provider);
|
||||
}
|
||||
|
||||
const cachedModels = pruneExpiredMemoryEntry(provider, now(), 'memory');
|
||||
if (cachedModels) {
|
||||
return cachedModels;
|
||||
}
|
||||
|
||||
const pendingRequest = pendingRequests.get(provider);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
await loadPersistedCache();
|
||||
|
||||
const persistedModels = pruneExpiredMemoryEntry(provider, now(), 'disk');
|
||||
if (persistedModels) {
|
||||
return persistedModels;
|
||||
}
|
||||
|
||||
const postLoadPendingRequest = pendingRequests.get(provider);
|
||||
if (postLoadPendingRequest) {
|
||||
return postLoadPendingRequest;
|
||||
}
|
||||
|
||||
return loadAndCacheModels(provider);
|
||||
};
|
||||
|
||||
const getCurrentActiveModel = async (
|
||||
provider: LLMProvider,
|
||||
sessionId?: string,
|
||||
): Promise<ProviderCurrentActiveModel> => resolveProvider(provider).models.getCurrentActiveModel(sessionId);
|
||||
|
||||
const changeActiveModel = async (
|
||||
provider: LLMProvider,
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> => resolveProvider(provider).models.changeActiveModel(input);
|
||||
|
||||
const getChangedActiveModel = async (
|
||||
provider: LLMProvider,
|
||||
sessionId: string,
|
||||
): Promise<ProviderSessionActiveModelChange> => readProviderSessionActiveModelChange(provider, sessionId, {
|
||||
filePath: activeModelChangesPath,
|
||||
});
|
||||
|
||||
const resolveResumeModel = async (
|
||||
provider: LLMProvider,
|
||||
sessionId: string | undefined,
|
||||
requestedModel?: string | null,
|
||||
): Promise<string | undefined> => {
|
||||
const normalizedRequestedModel = typeof requestedModel === 'string' ? requestedModel.trim() : '';
|
||||
if (!sessionId?.trim()) {
|
||||
return normalizedRequestedModel || undefined;
|
||||
}
|
||||
|
||||
const changedModel = await getChangedActiveModel(provider, sessionId);
|
||||
if (changedModel.supported && changedModel.changed && changedModel.model?.trim()) {
|
||||
return changedModel.model.trim();
|
||||
}
|
||||
|
||||
return normalizedRequestedModel || undefined;
|
||||
};
|
||||
|
||||
const clearCache = (): void => {
|
||||
memoryCache.clear();
|
||||
pendingRequests.clear();
|
||||
persistedCacheLoaded = false;
|
||||
persistedCacheLoadPromise = null;
|
||||
};
|
||||
|
||||
return {
|
||||
getProviderModels,
|
||||
getCurrentActiveModel,
|
||||
getChangedActiveModel,
|
||||
changeActiveModel,
|
||||
resolveResumeModel,
|
||||
clearCache,
|
||||
};
|
||||
};
|
||||
|
||||
export const providerModelsService = createProviderModelsService();
|
||||
@@ -22,7 +22,6 @@ export const sessionSynchronizerService = {
|
||||
codex: 0,
|
||||
cursor: 0,
|
||||
gemini: 0,
|
||||
opencode: 0,
|
||||
};
|
||||
const failures: string[] = [];
|
||||
|
||||
|
||||
@@ -34,10 +34,6 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
|
||||
provider: 'gemini',
|
||||
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
||||
},
|
||||
{
|
||||
provider: 'opencode',
|
||||
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
|
||||
},
|
||||
];
|
||||
|
||||
const WATCHER_IGNORED_PATTERNS = [
|
||||
@@ -71,10 +67,6 @@ let watcherRescheduleAfterRefresh = false;
|
||||
* Filters watcher events to provider-specific session artifact file types.
|
||||
*/
|
||||
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
|
||||
if (provider === 'opencode') {
|
||||
return path.basename(filePath) === 'opencode.db';
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
IProvider,
|
||||
IProviderAuth,
|
||||
IProviderMcp,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
@@ -18,7 +17,6 @@ import type { LLMProvider } from '@/shared/types.js';
|
||||
*/
|
||||
export abstract class AbstractProvider implements IProvider {
|
||||
readonly id: LLMProvider;
|
||||
abstract readonly models: IProviderModels;
|
||||
abstract readonly mcp: IProviderMcp;
|
||||
abstract readonly auth: IProviderAuth;
|
||||
abstract readonly skills: IProviderSkills;
|
||||
|
||||
@@ -169,93 +169,6 @@ test('providerMcpService handles codex MCP TOML config and capability validation
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers OpenCode MCP support for user/project config files, JSONC-compatible
|
||||
* reads, and validation for unsupported scope/transport combinations.
|
||||
*/
|
||||
test('providerMcpService handles opencode MCP config and capability validation', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-opencode-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
await fs.mkdir(path.join(tempRoot, '.config', 'opencode'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'),
|
||||
`{
|
||||
// Existing comments should not block OpenCode MCP reads.
|
||||
"mcp": {}
|
||||
}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-user-stdio',
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { API_KEY: 'x' },
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-project-http',
|
||||
scope: 'project',
|
||||
transport: 'http',
|
||||
url: 'https://opencode.example.com/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const userConfig = await readJson(path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'));
|
||||
const userServers = userConfig.mcp as Record<string, unknown>;
|
||||
const userStdio = userServers['opencode-user-stdio'] as Record<string, unknown>;
|
||||
assert.equal(userStdio.type, 'local');
|
||||
assert.deepEqual(userStdio.command, ['node', 'server.js']);
|
||||
assert.deepEqual(userStdio.environment, { API_KEY: 'x' });
|
||||
|
||||
const projectConfig = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||
const projectServers = projectConfig.mcp as Record<string, unknown>;
|
||||
const projectHttp = projectServers['opencode-project-http'] as Record<string, unknown>;
|
||||
assert.equal(projectHttp.type, 'remote');
|
||||
assert.equal(projectHttp.url, 'https://opencode.example.com/mcp');
|
||||
|
||||
const grouped = await providerMcpService.listProviderMcpServers('opencode', { workspacePath });
|
||||
assert.ok(grouped.user.some((server) => server.name === 'opencode-user-stdio' && server.transport === 'stdio'));
|
||||
assert.ok(grouped.project.some((server) => server.name === 'opencode-project-http' && server.transport === 'http'));
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-local',
|
||||
scope: 'local',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.upsertProviderMcpServer('opencode', {
|
||||
name: 'opencode-sse',
|
||||
scope: 'project',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
workspacePath,
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
|
||||
*/
|
||||
@@ -342,7 +255,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
});
|
||||
|
||||
const expectCursorGlobal = process.platform !== 'win32';
|
||||
assert.equal(globalResult.length, expectCursorGlobal ? 5 : 4);
|
||||
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
|
||||
assert.ok(globalResult.every((entry) => entry.created === true));
|
||||
|
||||
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||
@@ -354,9 +267,6 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
||||
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
|
||||
|
||||
if (expectCursorGlobal) {
|
||||
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildOpenCodeDefinitionFromIds,
|
||||
parseOpenCodeModelsStdout,
|
||||
} from '@/modules/providers/list/opencode/opencode-models.provider.js';
|
||||
|
||||
test('OpenCode models provider parses plain CLI output and removes duplicates', () => {
|
||||
const ids = parseOpenCodeModelsStdout(`
|
||||
opencode/big-pickle
|
||||
not a model
|
||||
anthropic/claude-opus-4-7-fast
|
||||
anthropic/claude-opus-4-7-fast
|
||||
openai/gpt-5.5-pro
|
||||
`);
|
||||
|
||||
assert.deepEqual(ids, [
|
||||
'opencode/big-pickle',
|
||||
'anthropic/claude-opus-4-7-fast',
|
||||
'openai/gpt-5.5-pro',
|
||||
]);
|
||||
});
|
||||
|
||||
test('OpenCode models provider formats frontend labels from provider-prefixed ids', () => {
|
||||
const definition = buildOpenCodeDefinitionFromIds([
|
||||
'opencode/deepseek-v4-flash-free',
|
||||
'opencode/nemotron-3-super-free',
|
||||
'anthropic/claude-3-5-sonnet-20241022',
|
||||
'anthropic/claude-opus-4-7-fast',
|
||||
'openai/gpt-5.4-mini-fast',
|
||||
'openai/gpt-5.5-pro',
|
||||
'newprovider/alpha-v12-special-20261231',
|
||||
]);
|
||||
|
||||
assert.deepEqual(definition.OPTIONS, [
|
||||
{
|
||||
value: 'opencode/deepseek-v4-flash-free',
|
||||
label: 'Deepseek V4 Flash Free',
|
||||
description: 'opencode - opencode/deepseek-v4-flash-free',
|
||||
},
|
||||
{
|
||||
value: 'opencode/nemotron-3-super-free',
|
||||
label: 'Nemotron 3 Super Free',
|
||||
description: 'opencode - opencode/nemotron-3-super-free',
|
||||
},
|
||||
{
|
||||
value: 'anthropic/claude-3-5-sonnet-20241022',
|
||||
label: 'Claude 3.5 Sonnet (2024-10-22)',
|
||||
description: 'anthropic - anthropic/claude-3-5-sonnet-20241022',
|
||||
},
|
||||
{
|
||||
value: 'anthropic/claude-opus-4-7-fast',
|
||||
label: 'Claude Opus 4.7 Fast',
|
||||
description: 'anthropic - anthropic/claude-opus-4-7-fast',
|
||||
},
|
||||
{
|
||||
value: 'openai/gpt-5.4-mini-fast',
|
||||
label: 'GPT-5.4 Mini Fast',
|
||||
description: 'openai - openai/gpt-5.4-mini-fast',
|
||||
},
|
||||
{
|
||||
value: 'openai/gpt-5.5-pro',
|
||||
label: 'GPT-5.5 Pro',
|
||||
description: 'openai - openai/gpt-5.5-pro',
|
||||
},
|
||||
{
|
||||
value: 'newprovider/alpha-v12-special-20261231',
|
||||
label: 'Alpha V12 Special (2026-12-31)',
|
||||
description: 'newprovider - newprovider/alpha-v12-special-20261231',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1,321 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
||||
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
|
||||
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
|
||||
|
||||
const patchHomeDir = (nextHomeDir: string) => {
|
||||
const original = os.homedir;
|
||||
(os as any).homedir = () => nextHomeDir;
|
||||
return () => {
|
||||
(os as any).homedir = original;
|
||||
};
|
||||
};
|
||||
|
||||
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'opencode-provider-db-'));
|
||||
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||
|
||||
closeConnection();
|
||||
process.env.DATABASE_PATH = databasePath;
|
||||
await initializeDatabase();
|
||||
|
||||
try {
|
||||
await runTest();
|
||||
} finally {
|
||||
closeConnection();
|
||||
if (previousDatabasePath === undefined) {
|
||||
delete process.env.DATABASE_PATH;
|
||||
} else {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
}
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): Promise<void> => {
|
||||
const dataDir = path.join(homeDir, '.local', 'share', 'opencode');
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
|
||||
const db = new Database(path.join(dataDir, 'opencode.db'));
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE project (
|
||||
id TEXT PRIMARY KEY,
|
||||
worktree TEXT NOT NULL,
|
||||
vcs TEXT,
|
||||
name TEXT,
|
||||
icon_url TEXT,
|
||||
icon_color TEXT,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
time_initialized INTEGER,
|
||||
sandboxes TEXT NOT NULL,
|
||||
commands TEXT,
|
||||
icon_url_override TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
slug TEXT NOT NULL,
|
||||
directory TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
share_url TEXT,
|
||||
summary_additions INTEGER,
|
||||
summary_deletions INTEGER,
|
||||
summary_files INTEGER,
|
||||
summary_diffs TEXT,
|
||||
revert TEXT,
|
||||
permission TEXT,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
time_compacting INTEGER,
|
||||
time_archived INTEGER,
|
||||
workspace_id TEXT,
|
||||
path TEXT,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE part (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
FOREIGN KEY (message_id) REFERENCES message(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX part_session_idx ON part (session_id);
|
||||
CREATE INDEX session_project_idx ON session (project_id);
|
||||
CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id);
|
||||
CREATE INDEX part_message_id_id_idx ON part (message_id, id);
|
||||
`);
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(
|
||||
'project-1',
|
||||
workspacePath,
|
||||
1_700_000_000_000,
|
||||
1_700_000_001_000,
|
||||
'[]',
|
||||
);
|
||||
db.prepare(`
|
||||
INSERT INTO session (
|
||||
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'open-session-1',
|
||||
'project-1',
|
||||
'open-session-1',
|
||||
workspacePath,
|
||||
'OpenCode indexed title',
|
||||
'0.0.0',
|
||||
1_700_000_000_000,
|
||||
1_700_000_004_000,
|
||||
null,
|
||||
);
|
||||
|
||||
const userMessageData = JSON.stringify({
|
||||
role: 'user',
|
||||
time: { created: 1_700_000_001_000 },
|
||||
agent: 'test',
|
||||
model: { providerID: 'anthropic', modelID: 'claude' },
|
||||
});
|
||||
const assistantMessageData = JSON.stringify({
|
||||
role: 'assistant',
|
||||
time: { created: 1_700_000_002_000, completed: 1_700_000_003_000 },
|
||||
parentID: 'message-user',
|
||||
modelID: 'anthropic/claude-sonnet-4-5',
|
||||
providerID: 'anthropic',
|
||||
mode: 'default',
|
||||
agent: 'test',
|
||||
path: { cwd: '.', root: '.' },
|
||||
cost: 0.01,
|
||||
tokens: {
|
||||
input: 10,
|
||||
output: 20,
|
||||
reasoning: 0,
|
||||
cache: { read: 3, write: 2 },
|
||||
},
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('message-user', 'open-session-1', 1_700_000_001_000, 1_700_000_001_500, userMessageData);
|
||||
db.prepare(
|
||||
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('message-assistant', 'open-session-1', 1_700_000_002_000, 1_700_000_003_000, assistantMessageData);
|
||||
|
||||
const insertPart = db.prepare(`
|
||||
INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertPart.run(
|
||||
'part-user-text',
|
||||
'message-user',
|
||||
'open-session-1',
|
||||
1_700_000_001_000,
|
||||
1_700_000_001_000,
|
||||
JSON.stringify({
|
||||
type: 'text',
|
||||
text: JSON.stringify('Build the OpenCode integration.'),
|
||||
}),
|
||||
);
|
||||
insertPart.run(
|
||||
'part-reasoning',
|
||||
'message-assistant',
|
||||
'open-session-1',
|
||||
1_700_000_002_000,
|
||||
1_700_000_002_000,
|
||||
JSON.stringify({
|
||||
type: 'reasoning',
|
||||
text: 'I will inspect the provider shape first.',
|
||||
time: { start: 0, end: 1 },
|
||||
}),
|
||||
);
|
||||
insertPart.run(
|
||||
'part-assistant-text',
|
||||
'message-assistant',
|
||||
'open-session-1',
|
||||
1_700_000_002_500,
|
||||
1_700_000_002_500,
|
||||
JSON.stringify({
|
||||
type: 'text',
|
||||
text: 'The provider is wired.',
|
||||
}),
|
||||
);
|
||||
insertPart.run(
|
||||
'part-tool',
|
||||
'message-assistant',
|
||||
'open-session-1',
|
||||
1_700_000_003_000,
|
||||
1_700_000_003_000,
|
||||
JSON.stringify({
|
||||
type: 'tool',
|
||||
tool: 'bash',
|
||||
callID: 'tool-call-1',
|
||||
state: {
|
||||
status: 'completed',
|
||||
input: { command: 'npm test' },
|
||||
output: 'ok',
|
||||
title: 'bash',
|
||||
metadata: {},
|
||||
time: { start: 0, end: 1 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
|
||||
test('OpenCode session synchronizer indexes sqlite sessions without deletable transcript paths', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||
await withIsolatedDatabase(() => {
|
||||
const synchronizer = new OpenCodeSessionSynchronizer();
|
||||
const processed = synchronizer.synchronize();
|
||||
|
||||
return Promise.resolve(processed).then((count) => {
|
||||
assert.equal(count, 1);
|
||||
const indexed = sessionsDb.getSessionById('open-session-1');
|
||||
assert.equal(indexed?.provider, 'opencode');
|
||||
assert.equal(indexed?.project_path, workspacePath);
|
||||
assert.equal(indexed?.custom_name, 'OpenCode indexed title');
|
||||
assert.equal(indexed?.jsonl_path, null);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
|
||||
const provider = new OpenCodeSessionsProvider();
|
||||
const normalized = provider.normalizeMessage({
|
||||
type: 'text',
|
||||
sessionID: 'open-session-live',
|
||||
text: JSON.stringify('hello bro'),
|
||||
}, null);
|
||||
|
||||
assert.equal(normalized.length, 1);
|
||||
assert.equal(normalized[0]?.kind, 'stream_delta');
|
||||
assert.equal(normalized[0]?.content, 'hello bro');
|
||||
|
||||
const userEcho = provider.normalizeMessage({
|
||||
type: 'text',
|
||||
sessionID: 'open-session-live',
|
||||
role: 'user',
|
||||
text: 'hello bro',
|
||||
}, null);
|
||||
|
||||
assert.deepEqual(userEcho, []);
|
||||
});
|
||||
|
||||
test('OpenCode sessions provider reads sqlite history and token usage', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-history-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||
const provider = new OpenCodeSessionsProvider();
|
||||
const history = await provider.fetchHistory('open-session-1');
|
||||
|
||||
assert.equal(history.total, 4);
|
||||
assert.equal(history.messages[0]?.kind, 'text');
|
||||
assert.equal(history.messages[0]?.role, 'user');
|
||||
assert.equal(history.messages[0]?.content, 'Build the OpenCode integration.');
|
||||
assert.equal(history.messages[1]?.kind, 'thinking');
|
||||
assert.equal(history.messages[2]?.content, 'The provider is wired.');
|
||||
assert.equal(history.messages[3]?.kind, 'tool_use');
|
||||
assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false });
|
||||
assert.deepEqual(history.tokenUsage, {
|
||||
used: 35,
|
||||
total: 35,
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 3,
|
||||
cacheCreationTokens: 2,
|
||||
});
|
||||
|
||||
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });
|
||||
assert.equal(paged.messages.length, 2);
|
||||
assert.equal(paged.hasMore, true);
|
||||
assert.equal(paged.messages[0]?.content, 'The provider is wired.');
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,318 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createProviderModelsService,
|
||||
PROVIDER_MODELS_CACHE_TTL_MS,
|
||||
} from '@/modules/providers/services/provider-models.service.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
LLMProvider,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import { writeProviderSessionActiveModelChange } from '@/shared/utils.js';
|
||||
|
||||
const createModels = (value: string): ProviderModelsDefinition => ({
|
||||
OPTIONS: [{ value, label: value }],
|
||||
DEFAULT: value,
|
||||
});
|
||||
|
||||
const createCurrentActiveModel = (model: string): ProviderCurrentActiveModel => ({
|
||||
model,
|
||||
});
|
||||
|
||||
const createSessionActiveModelChange = (
|
||||
provider: LLMProvider,
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): ProviderSessionActiveModelChange => ({
|
||||
provider,
|
||||
sessionId: input.sessionId,
|
||||
supported: true,
|
||||
changed: true,
|
||||
model: input.model,
|
||||
});
|
||||
|
||||
const createEphemeralCachePath = (): string => path.join(
|
||||
os.tmpdir(),
|
||||
`provider-model-cache-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
|
||||
);
|
||||
|
||||
test('provider models service delegates to the resolved provider model adapter', async () => {
|
||||
const calls: LLMProvider[] = [];
|
||||
const service = createProviderModelsService({
|
||||
cachePath: createEphemeralCachePath(),
|
||||
resolveProvider: (provider) => {
|
||||
calls.push(provider);
|
||||
return {
|
||||
models: {
|
||||
getSupportedModels: async () => createModels(`${provider}-models`),
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const models = await service.getProviderModels('codex', { bypassCache: true });
|
||||
|
||||
assert.deepEqual(calls, ['codex']);
|
||||
assert.equal(models.models.DEFAULT, 'codex-models');
|
||||
assert.equal(models.cache.source, 'fresh');
|
||||
});
|
||||
|
||||
test('provider models service returns each provider adapter result without rewriting it', async () => {
|
||||
const expectedModels: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'cursor-a', label: 'Cursor A' },
|
||||
{ value: 'cursor-b', label: 'Cursor B' },
|
||||
],
|
||||
DEFAULT: 'cursor-b',
|
||||
};
|
||||
|
||||
const service = createProviderModelsService({
|
||||
cachePath: createEphemeralCachePath(),
|
||||
resolveProvider: () => ({
|
||||
models: {
|
||||
getSupportedModels: async () => expectedModels,
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const models = await service.getProviderModels('cursor', { bypassCache: true });
|
||||
|
||||
assert.deepEqual(models.models, expectedModels);
|
||||
});
|
||||
|
||||
test('provider models are cached for the three-day ttl', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-ttl-'));
|
||||
let currentTime = 1_000;
|
||||
let loadCount = 0;
|
||||
|
||||
try {
|
||||
const service = createProviderModelsService({
|
||||
cachePath: path.join(tempRoot, 'models-cache.json'),
|
||||
now: () => currentTime,
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => {
|
||||
loadCount += 1;
|
||||
return createModels(`${provider}-${loadCount}`);
|
||||
},
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const first = await service.getProviderModels('codex');
|
||||
const cached = await service.getProviderModels('codex');
|
||||
assert.equal(loadCount, 1);
|
||||
assert.equal(cached.models.DEFAULT, first.models.DEFAULT);
|
||||
assert.equal(cached.cache.source, 'memory');
|
||||
|
||||
currentTime += PROVIDER_MODELS_CACHE_TTL_MS - 1;
|
||||
await service.getProviderModels('codex');
|
||||
assert.equal(loadCount, 1);
|
||||
|
||||
currentTime += 2;
|
||||
const refreshed = await service.getProviderModels('codex');
|
||||
assert.equal(loadCount, 2);
|
||||
assert.equal(refreshed.models.DEFAULT, 'codex-2');
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('provider model cache is persisted across service instances', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
|
||||
const cachePath = path.join(tempRoot, 'models-cache.json');
|
||||
|
||||
try {
|
||||
const writer = createProviderModelsService({
|
||||
cachePath,
|
||||
resolveProvider: () => ({
|
||||
models: {
|
||||
getSupportedModels: async () => createModels('gemini-cached'),
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
await writer.getProviderModels('gemini');
|
||||
|
||||
const reader = createProviderModelsService({
|
||||
cachePath,
|
||||
resolveProvider: () => ({
|
||||
models: {
|
||||
getSupportedModels: async () => {
|
||||
throw new Error('loader should not be called for persisted cache hits');
|
||||
},
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const models = await reader.getProviderModels('gemini');
|
||||
assert.equal(models.models.DEFAULT, 'gemini-cached');
|
||||
assert.equal(models.cache.source, 'disk');
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('concurrent provider model requests share one load operation', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-pending-'));
|
||||
let loadCount = 0;
|
||||
|
||||
try {
|
||||
const service = createProviderModelsService({
|
||||
cachePath: path.join(tempRoot, 'models-cache.json'),
|
||||
resolveProvider: () => ({
|
||||
models: {
|
||||
getSupportedModels: async () => {
|
||||
loadCount += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return createModels('claude-cached');
|
||||
},
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel('claude-active'),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange('claude', input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
service.getProviderModels('claude'),
|
||||
service.getProviderModels('claude'),
|
||||
]);
|
||||
|
||||
assert.equal(loadCount, 1);
|
||||
assert.equal(first.models.DEFAULT, 'claude-cached');
|
||||
assert.equal(second.models.DEFAULT, 'claude-cached');
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('bypassCache forces a fresh provider fetch and updates cache metadata', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-refresh-'));
|
||||
let currentTime = 1_000;
|
||||
let loadCount = 0;
|
||||
|
||||
try {
|
||||
const service = createProviderModelsService({
|
||||
cachePath: path.join(tempRoot, 'models-cache.json'),
|
||||
now: () => currentTime,
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => {
|
||||
loadCount += 1;
|
||||
return createModels(`${provider}-${loadCount}`);
|
||||
},
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active-${loadCount}`),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const first = await service.getProviderModels('claude');
|
||||
currentTime += 50;
|
||||
const refreshed = await service.getProviderModels('claude', { bypassCache: true });
|
||||
|
||||
assert.equal(first.models.DEFAULT, 'claude-1');
|
||||
assert.equal(refreshed.models.DEFAULT, 'claude-2');
|
||||
assert.equal(refreshed.cache.source, 'fresh');
|
||||
assert.notEqual(refreshed.cache.updatedAt, first.cache.updatedAt);
|
||||
assert.equal(loadCount, 2);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('provider models service delegates current active model lookups to the provider adapter', async () => {
|
||||
const calls: Array<{ provider: LLMProvider; sessionId?: string }> = [];
|
||||
const service = createProviderModelsService({
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => createModels(`${provider}-models`),
|
||||
getCurrentActiveModel: async (sessionId) => {
|
||||
calls.push({ provider, sessionId });
|
||||
return createCurrentActiveModel(`${provider}-${sessionId}`);
|
||||
},
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const activeModel = await service.getCurrentActiveModel('opencode', 'session-123');
|
||||
|
||||
assert.deepEqual(calls, [{ provider: 'opencode', sessionId: 'session-123' }]);
|
||||
assert.equal(activeModel.model, 'opencode-session-123');
|
||||
});
|
||||
|
||||
test('provider models service delegates active model change requests to the provider adapter', async () => {
|
||||
const calls: Array<{ provider: LLMProvider; input: ProviderChangeActiveModelInput }> = [];
|
||||
const service = createProviderModelsService({
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => createModels(`${provider}-models`),
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||
changeActiveModel: async (input) => {
|
||||
calls.push({ provider, input });
|
||||
return createSessionActiveModelChange(provider, input);
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const changedModel = await service.changeActiveModel('claude', {
|
||||
sessionId: 'session-123',
|
||||
model: 'opus',
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{
|
||||
provider: 'claude',
|
||||
input: {
|
||||
sessionId: 'session-123',
|
||||
model: 'opus',
|
||||
},
|
||||
}]);
|
||||
assert.equal(changedModel.changed, true);
|
||||
assert.equal(changedModel.model, 'opus');
|
||||
});
|
||||
|
||||
test('resolveResumeModel prefers a stored changed model over the requested one', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-change-'));
|
||||
const activeModelChangesPath = path.join(tempRoot, 'session-model-changes.json');
|
||||
|
||||
try {
|
||||
const service = createProviderModelsService({
|
||||
activeModelChangesPath,
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => createModels(`${provider}-models`),
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await writeProviderSessionActiveModelChange('cursor', {
|
||||
sessionId: 'session-456',
|
||||
model: 'composer-2',
|
||||
}, {
|
||||
filePath: activeModelChangesPath,
|
||||
});
|
||||
|
||||
const model = await service.resolveResumeModel('cursor', 'session-456', 'composer-2-fast');
|
||||
assert.equal(model, 'composer-2');
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -377,72 +377,6 @@ test('providerSkillsService lists codex repository, user, and system skills', {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers OpenCode skill lookup across cwd-to-git-root project folders
|
||||
* plus the global OpenCode/Claude/Agents compatibility locations.
|
||||
*/
|
||||
test('providerSkillsService lists opencode project and user compatibility skills', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-opencode-'));
|
||||
const repoRoot = path.join(tempRoot, 'repo');
|
||||
const workspacePath = path.join(repoRoot, 'packages', 'app');
|
||||
await fs.mkdir(path.join(repoRoot, '.git'), { recursive: true });
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.opencode', 'skills'),
|
||||
'opencode-cwd-dir',
|
||||
'opencode-cwd',
|
||||
'OpenCode cwd skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(repoRoot, 'packages', '.claude', 'skills'),
|
||||
'opencode-claude-parent-dir',
|
||||
'opencode-claude-parent',
|
||||
'OpenCode Claude parent skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(repoRoot, '.agents', 'skills'),
|
||||
'opencode-agents-root-dir',
|
||||
'opencode-agents-root',
|
||||
'OpenCode Agents root skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.config', 'opencode', 'skills'),
|
||||
'opencode-user-dir',
|
||||
'opencode-user',
|
||||
'OpenCode user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.claude', 'skills'),
|
||||
'opencode-claude-user-dir',
|
||||
'opencode-claude-user',
|
||||
'OpenCode Claude user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.agents', 'skills'),
|
||||
'opencode-agents-user-dir',
|
||||
'opencode-agents-user',
|
||||
'OpenCode Agents user skill',
|
||||
);
|
||||
|
||||
const skills = await providerSkillsService.listProviderSkills('opencode', { workspacePath });
|
||||
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
|
||||
assert.equal(byName.get('opencode-cwd')?.scope, 'project');
|
||||
assert.equal(byName.get('opencode-claude-parent')?.scope, 'project');
|
||||
assert.equal(byName.get('opencode-agents-root')?.scope, 'project');
|
||||
assert.equal(byName.get('opencode-user')?.scope, 'user');
|
||||
assert.equal(byName.get('opencode-claude-user')?.scope, 'user');
|
||||
assert.equal(byName.get('opencode-agents-user')?.scope, 'user');
|
||||
assert.equal(byName.get('opencode-cwd')?.command, '/opencode-cwd');
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Gemini and Cursor skill directory rules, including shared
|
||||
* `.agents/skills` project support.
|
||||
|
||||
@@ -29,12 +29,10 @@ type ChatWebSocketDependencies = {
|
||||
spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
|
||||
abortCursorSession: (sessionId: string) => boolean;
|
||||
abortCodexSession: (sessionId: string) => boolean;
|
||||
abortGeminiSession: (sessionId: string) => boolean;
|
||||
abortOpenCodeSession: (sessionId: string) => boolean;
|
||||
resolveToolApproval: (
|
||||
requestId: string,
|
||||
payload: {
|
||||
@@ -48,21 +46,19 @@ type ChatWebSocketDependencies = {
|
||||
isCursorSessionActive: (sessionId: string) => boolean;
|
||||
isCodexSessionActive: (sessionId: string) => boolean;
|
||||
isGeminiSessionActive: (sessionId: string) => boolean;
|
||||
isOpenCodeSessionActive: (sessionId: string) => boolean;
|
||||
reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
|
||||
getPendingApprovalsForSession: (sessionId: string) => unknown[];
|
||||
getActiveClaudeSDKSessions: () => unknown;
|
||||
getActiveCursorSessions: () => unknown;
|
||||
getActiveCodexSessions: () => unknown;
|
||||
getActiveGeminiSessions: () => unknown;
|
||||
getActiveOpenCodeSessions: () => unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes potentially invalid provider names coming from websocket payloads.
|
||||
*/
|
||||
function readProvider(value: unknown): LLMProvider {
|
||||
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
|
||||
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini') {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -138,11 +134,6 @@ export function handleChatConnection(
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'opencode-command') {
|
||||
await dependencies.spawnOpenCode(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'cursor-resume') {
|
||||
await dependencies.spawnCursor(
|
||||
'',
|
||||
@@ -167,8 +158,6 @@ export function handleChatConnection(
|
||||
success = dependencies.abortCodexSession(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
success = dependencies.abortGeminiSession(sessionId);
|
||||
} else if (provider === 'opencode') {
|
||||
success = dependencies.abortOpenCodeSession(sessionId);
|
||||
} else {
|
||||
success = await dependencies.abortClaudeSDKSession(sessionId);
|
||||
}
|
||||
@@ -225,8 +214,6 @@ export function handleChatConnection(
|
||||
isActive = dependencies.isCodexSessionActive(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
isActive = dependencies.isGeminiSessionActive(sessionId);
|
||||
} else if (provider === 'opencode') {
|
||||
isActive = dependencies.isOpenCodeSessionActive(sessionId);
|
||||
} else {
|
||||
isActive = dependencies.isClaudeSDKSessionActive(sessionId);
|
||||
if (isActive) {
|
||||
@@ -264,7 +251,6 @@ export function handleChatConnection(
|
||||
cursor: dependencies.getActiveCursorSessions(),
|
||||
codex: dependencies.getActiveCodexSessions(),
|
||||
gemini: dependencies.getActiveGeminiSessions(),
|
||||
opencode: dependencies.getActiveOpenCodeSessions(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,13 +136,6 @@ function buildShellCommand(
|
||||
return command;
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
if (hasSession && sessionId) {
|
||||
return `opencode --session "${sessionId}"`;
|
||||
}
|
||||
return initialCommand || 'opencode';
|
||||
}
|
||||
|
||||
const command = initialCommand || 'claude';
|
||||
if (hasSession && sessionId) {
|
||||
if (os.platform() === 'win32') {
|
||||
@@ -396,8 +389,6 @@ export function handleShellConnection(
|
||||
? 'Codex'
|
||||
: provider === 'gemini'
|
||||
? 'Gemini'
|
||||
: provider === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
welcomeMsg = hasSession
|
||||
? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
|
||||
|
||||
@@ -17,7 +17,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 { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Track active sessions
|
||||
@@ -203,12 +202,6 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
permissionMode = 'default'
|
||||
} = options;
|
||||
|
||||
const resolvedModel = await providerModelsService.resolveResumeModel(
|
||||
'codex',
|
||||
sessionId,
|
||||
model,
|
||||
);
|
||||
|
||||
const workingDirectory = cwd || projectPath || process.cwd();
|
||||
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
|
||||
|
||||
@@ -229,7 +222,7 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
skipGitRepoCheck: true,
|
||||
sandboxMode,
|
||||
approvalPolicy,
|
||||
model: resolvedModel
|
||||
model
|
||||
};
|
||||
|
||||
// Start or resume thread
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
const activeOpenCodeProcesses = new Map();
|
||||
|
||||
function readOpenCodeSessionId(event) {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return event.sessionID || event.sessionId || null;
|
||||
}
|
||||
|
||||
async function spawnOpenCode(command, options = {}, ws) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
|
||||
const workingDir = cwd || projectPath || process.cwd();
|
||||
const processKey = sessionId || Date.now().toString();
|
||||
let capturedSessionId = sessionId || null;
|
||||
let sessionCreatedSent = false;
|
||||
let stdoutLineBuffer = '';
|
||||
let terminalNotificationSent = false;
|
||||
let opencodeProcess = null;
|
||||
|
||||
const notifyTerminalState = ({ code = null, error = null } = {}) => {
|
||||
if (terminalNotificationSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminalNotificationSent = true;
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
if (code === 0 && !error) {
|
||||
notifyRunStopped({
|
||||
userId: ws?.userId || null,
|
||||
provider: 'opencode',
|
||||
sessionId: finalSessionId,
|
||||
sessionName: sessionSummary,
|
||||
stopReason: 'completed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
notifyRunFailed({
|
||||
userId: ws?.userId || null,
|
||||
provider: 'opencode',
|
||||
sessionId: finalSessionId,
|
||||
sessionName: sessionSummary,
|
||||
error: error || `OpenCode CLI exited with code ${code}`,
|
||||
});
|
||||
};
|
||||
|
||||
const registerSession = (nextSessionId) => {
|
||||
if (!nextSessionId || capturedSessionId === nextSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
capturedSessionId = nextSessionId;
|
||||
if (processKey !== capturedSessionId && opencodeProcess) {
|
||||
activeOpenCodeProcesses.delete(processKey);
|
||||
activeOpenCodeProcesses.set(capturedSessionId, opencodeProcess);
|
||||
}
|
||||
if (opencodeProcess) {
|
||||
opencodeProcess.sessionId = capturedSessionId;
|
||||
}
|
||||
|
||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||
ws.setSessionId(capturedSessionId);
|
||||
}
|
||||
|
||||
if (!sessionId && !sessionCreatedSent) {
|
||||
sessionCreatedSent = true;
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'session_created',
|
||||
newSessionId: capturedSessionId,
|
||||
sessionId: capturedSessionId,
|
||||
provider: 'opencode',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const processOpenCodeOutputLine = (line) => {
|
||||
if (!line || !line.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(line);
|
||||
} catch {
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'stream_delta',
|
||||
content: line,
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
provider: 'opencode',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
registerSession(readOpenCodeSessionId(response));
|
||||
const normalized = sessionsService.normalizeMessage(
|
||||
'opencode',
|
||||
response,
|
||||
capturedSessionId || sessionId || null,
|
||||
);
|
||||
for (const msg of normalized) {
|
||||
ws.send(msg);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorContent = error instanceof Error ? error.message : String(error);
|
||||
console.error('[OpenCode] Failed to process JSON output:', errorContent);
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'error',
|
||||
content: errorContent,
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
provider: 'opencode',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
|
||||
const args = ['run', '--format', 'json'];
|
||||
if (sessionId) {
|
||||
args.push('--session', sessionId);
|
||||
}
|
||||
if (resolvedModel) {
|
||||
args.push('--model', resolvedModel);
|
||||
}
|
||||
if (command && command.trim()) {
|
||||
args.push(command.trim());
|
||||
}
|
||||
|
||||
opencodeProcess = spawnFunction('opencode', args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
activeOpenCodeProcesses.set(processKey, opencodeProcess);
|
||||
opencodeProcess.sessionId = processKey;
|
||||
opencodeProcess.stdin.end();
|
||||
|
||||
opencodeProcess.stdout.on('data', (data) => {
|
||||
stdoutLineBuffer += data.toString();
|
||||
const completeLines = stdoutLineBuffer.split(/\r?\n/);
|
||||
stdoutLineBuffer = completeLines.pop() || '';
|
||||
|
||||
completeLines.forEach((line) => {
|
||||
processOpenCodeOutputLine(line.trim());
|
||||
});
|
||||
});
|
||||
|
||||
opencodeProcess.stderr.on('data', (data) => {
|
||||
const stderrText = data.toString();
|
||||
if (!stderrText.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'error',
|
||||
content: stderrText,
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
provider: 'opencode',
|
||||
}));
|
||||
});
|
||||
|
||||
opencodeProcess.on('close', async (code) => {
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeOpenCodeProcesses.delete(finalSessionId);
|
||||
activeOpenCodeProcesses.delete(processKey);
|
||||
|
||||
if (stdoutLineBuffer.trim()) {
|
||||
processOpenCodeOutputLine(stdoutLineBuffer.trim());
|
||||
stdoutLineBuffer = '';
|
||||
}
|
||||
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command,
|
||||
sessionId: finalSessionId,
|
||||
provider: 'opencode',
|
||||
}));
|
||||
|
||||
if (code === 0) {
|
||||
notifyTerminalState({ code });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 127 || code === null) {
|
||||
const installed = await providerAuthService.isProviderInstalled('opencode');
|
||||
if (!installed) {
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'error',
|
||||
content: 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/',
|
||||
sessionId: finalSessionId,
|
||||
provider: 'opencode',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
notifyTerminalState({ code });
|
||||
reject(new Error(code === null ? 'OpenCode CLI process was terminated' : `OpenCode CLI exited with code ${code}`));
|
||||
});
|
||||
|
||||
opencodeProcess.on('error', async (error) => {
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeOpenCodeProcesses.delete(finalSessionId);
|
||||
activeOpenCodeProcesses.delete(processKey);
|
||||
|
||||
const installed = await providerAuthService.isProviderInstalled('opencode');
|
||||
const errorContent = !installed
|
||||
? 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/'
|
||||
: error.message;
|
||||
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'error',
|
||||
content: errorContent,
|
||||
sessionId: finalSessionId,
|
||||
provider: 'opencode',
|
||||
}));
|
||||
notifyTerminalState({ error });
|
||||
reject(error);
|
||||
});
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
function abortOpenCodeSession(sessionId) {
|
||||
const process = activeOpenCodeProcesses.get(sessionId);
|
||||
if (!process) {
|
||||
return false;
|
||||
}
|
||||
|
||||
process.kill('SIGTERM');
|
||||
activeOpenCodeProcesses.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isOpenCodeSessionActive(sessionId) {
|
||||
return activeOpenCodeProcesses.has(sessionId);
|
||||
}
|
||||
|
||||
function getActiveOpenCodeSessions() {
|
||||
return Array.from(activeOpenCodeProcesses.keys());
|
||||
}
|
||||
|
||||
export {
|
||||
spawnOpenCode,
|
||||
abortOpenCodeSession,
|
||||
isOpenCodeSessionActive,
|
||||
getActiveOpenCodeSessions,
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { spawnOpenCode } from './opencode-cli.js';
|
||||
|
||||
const findEnvKey = (name) =>
|
||||
Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()) || name;
|
||||
|
||||
async function createFakeOpenCodeExecutable(binDir) {
|
||||
const scriptPath = path.join(binDir, 'opencode.js');
|
||||
await writeFile(scriptPath, `
|
||||
const events = [
|
||||
{ type: 'text', sessionID: 'open-live-1', text: 'assistant response' },
|
||||
{ type: 'step_finish', sessionID: 'open-live-1' },
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
`, 'utf8');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const commandPath = path.join(binDir, 'opencode.cmd');
|
||||
await writeFile(commandPath, '@echo off\r\nnode "%~dp0opencode.js" %*\r\n', 'utf8');
|
||||
return;
|
||||
}
|
||||
|
||||
const commandPath = path.join(binDir, 'opencode');
|
||||
await writeFile(commandPath, '#!/bin/sh\nnode "$(dirname "$0")/opencode.js" "$@"\n', 'utf8');
|
||||
await chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-'));
|
||||
const pathKey = findEnvKey('PATH');
|
||||
const pathExtKey = findEnvKey('PATHEXT');
|
||||
const previousPath = process.env[pathKey];
|
||||
const previousPathExt = process.env[pathExtKey];
|
||||
const messages = [];
|
||||
const writer = {
|
||||
userId: null,
|
||||
sessionId: null,
|
||||
send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
setSessionId(sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await createFakeOpenCodeExecutable(tempRoot);
|
||||
process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
|
||||
if (process.platform === 'win32') {
|
||||
process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
|
||||
? previousPathExt
|
||||
: `.COM;.EXE;.BAT;.CMD${previousPathExt ? `;${previousPathExt}` : ''}`;
|
||||
}
|
||||
|
||||
await spawnOpenCode('Hi', { cwd: tempRoot }, writer);
|
||||
|
||||
const sessionCreatedIndex = messages.findIndex((message) => message.kind === 'session_created');
|
||||
const assistantDeltaIndex = messages.findIndex((message) =>
|
||||
message.kind === 'stream_delta' && message.content === 'assistant response',
|
||||
);
|
||||
const streamEnd = messages.find((message) => message.kind === 'stream_end');
|
||||
const complete = messages.find((message) => message.kind === 'complete');
|
||||
|
||||
assert.notEqual(sessionCreatedIndex, -1);
|
||||
assert.notEqual(assistantDeltaIndex, -1);
|
||||
assert.ok(sessionCreatedIndex < assistantDeltaIndex);
|
||||
assert.equal(messages[sessionCreatedIndex].newSessionId, 'open-live-1');
|
||||
assert.equal(writer.sessionId, 'open-live-1');
|
||||
assert.equal(streamEnd?.sessionId, 'open-live-1');
|
||||
assert.equal(complete?.sessionId, 'open-live-1');
|
||||
assert.equal(messages.some((message) => message.kind === 'error'), false);
|
||||
} finally {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env[pathKey];
|
||||
} else {
|
||||
process.env[pathKey] = previousPath;
|
||||
}
|
||||
|
||||
if (previousPathExt === undefined) {
|
||||
delete process.env[pathExtKey];
|
||||
} else {
|
||||
process.env[pathExtKey] = previousPathExt;
|
||||
}
|
||||
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -9,9 +9,8 @@ import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
import { queryCodex } from '../openai-codex.js';
|
||||
import { spawnGemini } from '../gemini-cli.js';
|
||||
import { spawnOpenCode } from '../opencode-cli.js';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { providerModelsService } from '../modules/providers/services/provider-models.service.js';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
import { normalizeProjectPath } from '../shared/utils.js';
|
||||
|
||||
@@ -609,7 +608,7 @@ class ResponseCollector {
|
||||
/**
|
||||
* POST /api/agent
|
||||
*
|
||||
* Trigger an AI agent to work on a project.
|
||||
* Trigger an AI agent (Claude or Cursor) to work on a project.
|
||||
* Supports automatic GitHub branch and pull request creation after successful completion.
|
||||
*
|
||||
* ================================================================================================
|
||||
@@ -634,7 +633,7 @@ class ResponseCollector {
|
||||
* - Source for auto-generated branch names (if createBranch=true and no branchName)
|
||||
* - Fallback for PR title if no commits are made
|
||||
*
|
||||
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'
|
||||
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini'
|
||||
* Default: 'claude'
|
||||
*
|
||||
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
|
||||
@@ -752,7 +751,7 @@ class ResponseCollector {
|
||||
* Input Validations (400 Bad Request):
|
||||
* - Either githubUrl OR projectPath must be provided (not neither)
|
||||
* - message must be non-empty string
|
||||
* - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode'
|
||||
* - provider must be 'claude', 'cursor', 'codex', or 'gemini'
|
||||
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
|
||||
* - branchName must pass Git naming rules (if provided)
|
||||
*
|
||||
@@ -860,8 +859,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
return res.status(400).json({ error: 'message is required' });
|
||||
}
|
||||
|
||||
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' });
|
||||
if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' });
|
||||
}
|
||||
|
||||
// Validate GitHub branch/PR creation requirements
|
||||
@@ -939,10 +938,6 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const codexModels = (await providerModelsService.getProviderModels('codex')).models;
|
||||
const geminiModels = (await providerModelsService.getProviderModels('gemini')).models;
|
||||
const opencodeModels = (await providerModelsService.getProviderModels('opencode')).models;
|
||||
|
||||
// Start the appropriate session
|
||||
if (provider === 'claude') {
|
||||
console.log('🤖 Starting Claude SDK session');
|
||||
@@ -972,7 +967,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: sessionId || null,
|
||||
model: model || codexModels.DEFAULT,
|
||||
model: model || CODEX_MODELS.DEFAULT,
|
||||
permissionMode: 'bypassPermissions'
|
||||
}, writer);
|
||||
} else if (provider === 'gemini') {
|
||||
@@ -982,18 +977,9 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: sessionId || null,
|
||||
model: model || geminiModels.DEFAULT,
|
||||
model: model,
|
||||
skipPermissions: true // CLI mode bypasses permissions
|
||||
}, writer);
|
||||
} else if (provider === 'opencode') {
|
||||
console.log('Starting OpenCode CLI session');
|
||||
|
||||
await spawnOpenCode(message.trim(), {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: sessionId || null,
|
||||
model: model || opencodeModels.DEFAULT
|
||||
}, writer);
|
||||
}
|
||||
|
||||
// Handle GitHub branch and PR creation after successful agent completion
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { promises as fs } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { promises as fs } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import express from "express";
|
||||
import express from 'express';
|
||||
|
||||
import { providerModelsService } from "../modules/providers/services/provider-models.service.js";
|
||||
import { parseFrontMatter } from "../shared/frontmatter.js";
|
||||
import { findAppRoot, getModuleDir } from "../utils/runtime-paths.js";
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
import { parseFrontMatter } from '../shared/frontmatter.js';
|
||||
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
|
||||
|
||||
const __dirname = getModuleDir(import.meta.url);
|
||||
// This route reads the top-level package.json for the status command, so it needs the real
|
||||
@@ -15,77 +15,6 @@ const APP_ROOT = findAppRoot(__dirname);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"];
|
||||
|
||||
const MODEL_PROVIDER_LABELS = {
|
||||
claude: "Claude",
|
||||
cursor: "Cursor",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini",
|
||||
opencode: "OpenCode",
|
||||
};
|
||||
|
||||
const readModelProvider = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return "claude";
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return MODEL_PROVIDERS.includes(normalized) ? normalized : "claude";
|
||||
};
|
||||
|
||||
const hasConcreteSessionId = (value) =>
|
||||
typeof value === "string" && value.trim().length > 0;
|
||||
|
||||
const resolveCommandModel = async (provider, catalog, sessionId) => {
|
||||
if (!hasConcreteSessionId(sessionId)) {
|
||||
return catalog.DEFAULT;
|
||||
}
|
||||
|
||||
const currentActiveModel = await providerModelsService.getCurrentActiveModel(
|
||||
provider,
|
||||
sessionId,
|
||||
);
|
||||
return currentActiveModel?.model || catalog.DEFAULT;
|
||||
};
|
||||
|
||||
export const executeModelsCommand = async (args, context) => {
|
||||
const currentProvider = readModelProvider(context?.provider);
|
||||
const result = await providerModelsService.getProviderModels(currentProvider);
|
||||
const catalog = result.models;
|
||||
const currentModel = await resolveCommandModel(
|
||||
currentProvider,
|
||||
catalog,
|
||||
context?.sessionId,
|
||||
);
|
||||
const availableModels = catalog.OPTIONS.map((option) => option.value);
|
||||
const availableOptions = catalog.OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
type: "builtin",
|
||||
action: "models",
|
||||
data: {
|
||||
current: {
|
||||
provider: currentProvider,
|
||||
providerLabel: MODEL_PROVIDER_LABELS[currentProvider],
|
||||
model: currentModel,
|
||||
},
|
||||
available: {
|
||||
[currentProvider]: availableModels,
|
||||
},
|
||||
availableModels,
|
||||
availableOptions,
|
||||
defaultModel: catalog.DEFAULT,
|
||||
cache: result.cache,
|
||||
message: `Current model: ${currentModel}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively scan directory for command files (.md)
|
||||
* @param {string} dir - Directory to scan
|
||||
@@ -107,30 +36,24 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recursively scan subdirectories
|
||||
const subCommands = await scanCommandsDirectory(
|
||||
fullPath,
|
||||
baseDir,
|
||||
namespace,
|
||||
);
|
||||
const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace);
|
||||
commands.push(...subCommands);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
// Parse markdown file for metadata
|
||||
try {
|
||||
const content = await fs.readFile(fullPath, "utf8");
|
||||
const { data: frontmatter, content: commandContent } =
|
||||
parseFrontMatter(content);
|
||||
const content = await fs.readFile(fullPath, 'utf8');
|
||||
const { data: frontmatter, content: commandContent } = parseFrontMatter(content);
|
||||
|
||||
// Calculate relative path from baseDir for command name
|
||||
const relativePath = path.relative(baseDir, fullPath);
|
||||
// Remove .md extension and convert to command name
|
||||
const commandName =
|
||||
"/" + relativePath.replace(/\.md$/, "").replace(/\\/g, "/");
|
||||
const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/');
|
||||
|
||||
// Extract description from frontmatter or first line of content
|
||||
let description = frontmatter.description || "";
|
||||
let description = frontmatter.description || '';
|
||||
if (!description) {
|
||||
const firstLine = commandContent.trim().split("\n")[0];
|
||||
description = firstLine.replace(/^#+\s*/, "").trim();
|
||||
const firstLine = commandContent.trim().split('\n')[0];
|
||||
description = firstLine.replace(/^#+\s*/, '').trim();
|
||||
}
|
||||
|
||||
commands.push({
|
||||
@@ -139,7 +62,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
|
||||
relativePath,
|
||||
description,
|
||||
namespace,
|
||||
metadata: frontmatter,
|
||||
metadata: frontmatter
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error parsing command file ${fullPath}:`, err.message);
|
||||
@@ -148,7 +71,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
|
||||
}
|
||||
} catch (err) {
|
||||
// Directory doesn't exist or can't be accessed - this is okay
|
||||
if (err.code !== "ENOENT" && err.code !== "EACCES") {
|
||||
if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
|
||||
console.error(`Error scanning directory ${dir}:`, err.message);
|
||||
}
|
||||
}
|
||||
@@ -161,41 +84,53 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
|
||||
*/
|
||||
const builtInCommands = [
|
||||
{
|
||||
name: "/help",
|
||||
description: "Show help documentation for Claude Code",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/help',
|
||||
description: 'Show help documentation for Claude Code',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: "/models",
|
||||
description: "View available models for the current provider",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/clear',
|
||||
description: 'Clear the conversation history',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: "/cost",
|
||||
description: "Display token usage and cost information",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/model',
|
||||
description: 'Switch or view the current AI model',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: "/memory",
|
||||
description: "Open CLAUDE.md memory file for editing",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/cost',
|
||||
description: 'Display token usage and cost information',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: "/config",
|
||||
description: "Open settings and configuration",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/memory',
|
||||
description: 'Open CLAUDE.md memory file for editing',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: "/status",
|
||||
description: "Show system status and version information",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/config',
|
||||
description: 'Open settings and configuration',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: '/status',
|
||||
description: 'Show system status and version information',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: '/rewind',
|
||||
description: 'Rewind the conversation to a previous state',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -203,18 +138,14 @@ const builtInCommands = [
|
||||
* Each handler returns { type: 'builtin', action: string, data: any }
|
||||
*/
|
||||
const builtInHandlers = {
|
||||
"/help": async (args, context) => {
|
||||
'/help': async (args, context) => {
|
||||
const helpText = `# Claude Code Commands
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
${builtInCommands
|
||||
.map(
|
||||
(cmd) => `### ${cmd.name}
|
||||
${builtInCommands.map(cmd => `### ${cmd.name}
|
||||
${cmd.description}
|
||||
`,
|
||||
)
|
||||
.join("\n")}
|
||||
`).join('\n')}
|
||||
|
||||
## Custom Commands
|
||||
|
||||
@@ -236,40 +167,71 @@ Custom commands can be created in:
|
||||
`;
|
||||
|
||||
return {
|
||||
type: "builtin",
|
||||
action: "help",
|
||||
type: 'builtin',
|
||||
action: 'help',
|
||||
data: {
|
||||
content: helpText,
|
||||
format: "markdown",
|
||||
commands: builtInCommands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
namespace: command.namespace,
|
||||
})),
|
||||
},
|
||||
format: 'markdown'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
"/models": executeModelsCommand,
|
||||
'/clear': async (args, context) => {
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'clear',
|
||||
data: {
|
||||
message: 'Conversation history cleared'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
"/cost": async (args, context) => {
|
||||
'/model': async (args, context) => {
|
||||
// Read available models from centralized constants
|
||||
const availableModels = {
|
||||
claude: CLAUDE_MODELS.OPTIONS.map(o => o.value),
|
||||
cursor: CURSOR_MODELS.OPTIONS.map(o => o.value),
|
||||
codex: CODEX_MODELS.OPTIONS.map(o => o.value)
|
||||
};
|
||||
|
||||
const currentProvider = context?.provider || 'claude';
|
||||
const currentModel = context?.model || CLAUDE_MODELS.DEFAULT;
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'model',
|
||||
data: {
|
||||
current: {
|
||||
provider: currentProvider,
|
||||
model: currentModel
|
||||
},
|
||||
available: availableModels,
|
||||
message: args.length > 0
|
||||
? `Switching to model: ${args[0]}`
|
||||
: `Current model: ${currentModel}`
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
'/cost': async (args, context) => {
|
||||
const tokenUsage = context?.tokenUsage || {};
|
||||
const provider = readModelProvider(context?.provider);
|
||||
const catalog = (await providerModelsService.getProviderModels(provider)).models;
|
||||
const model = await resolveCommandModel(provider, catalog, context?.sessionId);
|
||||
const provider = context?.provider || 'claude';
|
||||
const model =
|
||||
context?.model ||
|
||||
(provider === 'cursor'
|
||||
? CURSOR_MODELS.DEFAULT
|
||||
: provider === 'codex'
|
||||
? CODEX_MODELS.DEFAULT
|
||||
: CLAUDE_MODELS.DEFAULT);
|
||||
|
||||
const used =
|
||||
Number(
|
||||
tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0,
|
||||
) || 0;
|
||||
const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
|
||||
const total =
|
||||
Number(
|
||||
tokenUsage.total ??
|
||||
tokenUsage.contextWindow ??
|
||||
parseInt(process.env.CONTEXT_WINDOW || "160000", 10),
|
||||
parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
|
||||
) || 160000;
|
||||
const percentage =
|
||||
total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
|
||||
const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
|
||||
|
||||
const inputTokensRaw =
|
||||
Number(
|
||||
@@ -298,9 +260,7 @@ Custom commands can be created in:
|
||||
|
||||
// If we only have total used tokens, treat them as input for display/estimation.
|
||||
const inputTokens =
|
||||
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0
|
||||
? inputTokensRaw + cacheTokens
|
||||
: used;
|
||||
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
|
||||
|
||||
// Rough default rates by provider (USD / 1M tokens).
|
||||
const pricingByProvider = {
|
||||
@@ -315,96 +275,76 @@ Custom commands can be created in:
|
||||
const totalCost = inputCost + outputCost;
|
||||
|
||||
return {
|
||||
type: "builtin",
|
||||
action: "cost",
|
||||
type: 'builtin',
|
||||
action: 'cost',
|
||||
data: {
|
||||
tokenUsage: {
|
||||
used,
|
||||
total,
|
||||
percentage,
|
||||
},
|
||||
tokenBreakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
cache: cacheTokens,
|
||||
},
|
||||
cost: {
|
||||
input: inputCost.toFixed(4),
|
||||
output: outputCost.toFixed(4),
|
||||
total: totalCost.toFixed(4),
|
||||
},
|
||||
provider,
|
||||
model,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
"/status": async (args, context) => {
|
||||
'/status': async (args, context) => {
|
||||
// Read version from package.json
|
||||
const packageJsonPath = path.join(APP_ROOT, "package.json");
|
||||
let version = "unknown";
|
||||
let packageName = "claude-code-ui";
|
||||
const packageJsonPath = path.join(APP_ROOT, 'package.json');
|
||||
let version = 'unknown';
|
||||
let packageName = 'claude-code-ui';
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
await fs.readFile(packageJsonPath, "utf8"),
|
||||
);
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
||||
version = packageJson.version;
|
||||
packageName = packageJson.name;
|
||||
} catch (err) {
|
||||
console.error("Error reading package.json:", err);
|
||||
console.error('Error reading package.json:', err);
|
||||
}
|
||||
|
||||
const uptime = process.uptime();
|
||||
const uptimeMinutes = Math.floor(uptime / 60);
|
||||
const uptimeHours = Math.floor(uptimeMinutes / 60);
|
||||
const uptimeFormatted =
|
||||
uptimeHours > 0
|
||||
? `${uptimeHours}h ${uptimeMinutes % 60}m`
|
||||
: `${uptimeMinutes}m`;
|
||||
|
||||
const statusProvider = readModelProvider(context?.provider);
|
||||
const statusCatalog = (await providerModelsService.getProviderModels(statusProvider)).models;
|
||||
const model = await resolveCommandModel(statusProvider, statusCatalog, context?.sessionId);
|
||||
const memoryUsage = process.memoryUsage();
|
||||
const uptimeFormatted = uptimeHours > 0
|
||||
? `${uptimeHours}h ${uptimeMinutes % 60}m`
|
||||
: `${uptimeMinutes}m`;
|
||||
|
||||
return {
|
||||
type: "builtin",
|
||||
action: "status",
|
||||
type: 'builtin',
|
||||
action: 'status',
|
||||
data: {
|
||||
version,
|
||||
packageName,
|
||||
uptime: uptimeFormatted,
|
||||
uptimeSeconds: Math.floor(uptime),
|
||||
model,
|
||||
provider: statusProvider,
|
||||
model: context?.model || CLAUDE_MODELS.DEFAULT,
|
||||
provider: context?.provider || 'claude',
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
pid: process.pid,
|
||||
memoryUsage: {
|
||||
rssMb: Math.round(memoryUsage.rss / 1024 / 1024),
|
||||
heapUsedMb: Math.round(memoryUsage.heapUsed / 1024 / 1024),
|
||||
heapTotalMb: Math.round(memoryUsage.heapTotal / 1024 / 1024),
|
||||
},
|
||||
},
|
||||
platform: process.platform
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
"/memory": async (args, context) => {
|
||||
'/memory': async (args, context) => {
|
||||
const projectPath = context?.projectPath;
|
||||
|
||||
if (!projectPath) {
|
||||
return {
|
||||
type: "builtin",
|
||||
action: "memory",
|
||||
type: 'builtin',
|
||||
action: 'memory',
|
||||
data: {
|
||||
error: "No project selected",
|
||||
message: "Please select a project to access its CLAUDE.md file",
|
||||
},
|
||||
error: 'No project selected',
|
||||
message: 'Please select a project to access its CLAUDE.md file'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const claudeMdPath = path.join(projectPath, "CLAUDE.md");
|
||||
const claudeMdPath = path.join(projectPath, 'CLAUDE.md');
|
||||
|
||||
// Check if CLAUDE.md exists
|
||||
let exists = false;
|
||||
@@ -416,63 +356,85 @@ Custom commands can be created in:
|
||||
}
|
||||
|
||||
return {
|
||||
type: "builtin",
|
||||
action: "memory",
|
||||
type: 'builtin',
|
||||
action: 'memory',
|
||||
data: {
|
||||
path: claudeMdPath,
|
||||
exists,
|
||||
message: exists
|
||||
? `Opening CLAUDE.md at ${claudeMdPath}`
|
||||
: `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.`,
|
||||
},
|
||||
: `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.`
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
"/config": async (args, context) => {
|
||||
'/config': async (args, context) => {
|
||||
return {
|
||||
type: "builtin",
|
||||
action: "config",
|
||||
type: 'builtin',
|
||||
action: 'config',
|
||||
data: {
|
||||
message: "Opening settings...",
|
||||
},
|
||||
message: 'Opening settings...'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
'/rewind': async (args, context) => {
|
||||
const steps = args[0] ? parseInt(args[0]) : 1;
|
||||
|
||||
if (isNaN(steps) || steps < 1) {
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'rewind',
|
||||
data: {
|
||||
error: 'Invalid steps parameter',
|
||||
message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'rewind',
|
||||
data: {
|
||||
steps,
|
||||
message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...`
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/commands/list
|
||||
* List all available commands from project and user directories
|
||||
*/
|
||||
router.post("/list", async (req, res) => {
|
||||
router.post('/list', async (req, res) => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
const allCommands = [...builtInCommands];
|
||||
|
||||
// Scan project-level commands (.claude/commands/)
|
||||
if (projectPath) {
|
||||
const projectCommandsDir = path.join(projectPath, ".claude", "commands");
|
||||
const projectCommandsDir = path.join(projectPath, '.claude', 'commands');
|
||||
const projectCommands = await scanCommandsDirectory(
|
||||
projectCommandsDir,
|
||||
projectCommandsDir,
|
||||
"project",
|
||||
'project'
|
||||
);
|
||||
allCommands.push(...projectCommands);
|
||||
}
|
||||
|
||||
// Scan user-level commands (~/.claude/commands/)
|
||||
const homeDir = os.homedir();
|
||||
const userCommandsDir = path.join(homeDir, ".claude", "commands");
|
||||
const userCommandsDir = path.join(homeDir, '.claude', 'commands');
|
||||
const userCommands = await scanCommandsDirectory(
|
||||
userCommandsDir,
|
||||
userCommandsDir,
|
||||
"user",
|
||||
'user'
|
||||
);
|
||||
allCommands.push(...userCommands);
|
||||
|
||||
// Separate built-in and custom commands
|
||||
const customCommands = allCommands.filter(
|
||||
(cmd) => cmd.namespace !== "builtin",
|
||||
);
|
||||
const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin');
|
||||
|
||||
// Sort commands alphabetically by name
|
||||
customCommands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -480,13 +442,13 @@ router.post("/list", async (req, res) => {
|
||||
res.json({
|
||||
builtIn: builtInCommands,
|
||||
custom: customCommands,
|
||||
count: allCommands.length,
|
||||
count: allCommands.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error listing commands:", error);
|
||||
console.error('Error listing commands:', error);
|
||||
res.status(500).json({
|
||||
error: "Failed to list commands",
|
||||
message: error.message,
|
||||
error: 'Failed to list commands',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -497,13 +459,13 @@ router.post("/list", async (req, res) => {
|
||||
* This endpoint prepares the command content but doesn't execute bash commands yet
|
||||
* (that will be handled in the command parser utility)
|
||||
*/
|
||||
router.post("/execute", async (req, res) => {
|
||||
router.post('/execute', async (req, res) => {
|
||||
try {
|
||||
const { commandName, commandPath, args = [], context = {} } = req.body;
|
||||
|
||||
if (!commandName) {
|
||||
return res.status(400).json({
|
||||
error: "Command name is required",
|
||||
error: 'Command name is required'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -514,17 +476,14 @@ router.post("/execute", async (req, res) => {
|
||||
const result = await handler(args, context);
|
||||
return res.json({
|
||||
...result,
|
||||
command: commandName,
|
||||
command: commandName
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error executing built-in command ${commandName}:`,
|
||||
error,
|
||||
);
|
||||
console.error(`Error executing built-in command ${commandName}:`, error);
|
||||
return res.status(500).json({
|
||||
error: "Command execution failed",
|
||||
error: 'Command execution failed',
|
||||
message: error.message,
|
||||
command: commandName,
|
||||
command: commandName
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -532,7 +491,7 @@ router.post("/execute", async (req, res) => {
|
||||
// Handle custom commands
|
||||
if (!commandPath) {
|
||||
return res.status(400).json({
|
||||
error: "Command path is required for custom commands",
|
||||
error: 'Command path is required for custom commands'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -540,62 +499,56 @@ router.post("/execute", async (req, res) => {
|
||||
// Security: validate commandPath is within allowed directories
|
||||
{
|
||||
const resolvedPath = path.resolve(commandPath);
|
||||
const userBase = path.resolve(
|
||||
path.join(os.homedir(), ".claude", "commands"),
|
||||
);
|
||||
const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands'));
|
||||
const projectBase = context?.projectPath
|
||||
? path.resolve(path.join(context.projectPath, ".claude", "commands"))
|
||||
? path.resolve(path.join(context.projectPath, '.claude', 'commands'))
|
||||
: null;
|
||||
const isUnder = (base) => {
|
||||
const rel = path.relative(base, resolvedPath);
|
||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
};
|
||||
if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) {
|
||||
return res.status(403).json({
|
||||
error: "Access denied",
|
||||
message: "Command must be in .claude/commands directory",
|
||||
error: 'Access denied',
|
||||
message: 'Command must be in .claude/commands directory'
|
||||
});
|
||||
}
|
||||
}
|
||||
const content = await fs.readFile(commandPath, "utf8");
|
||||
const { data: metadata, content: commandContent } =
|
||||
parseFrontMatter(content);
|
||||
const content = await fs.readFile(commandPath, 'utf8');
|
||||
const { data: metadata, content: commandContent } = parseFrontMatter(content);
|
||||
// Basic argument replacement (will be enhanced in command parser utility)
|
||||
let processedContent = commandContent;
|
||||
|
||||
// Replace $ARGUMENTS with all arguments joined
|
||||
const argsString = args.join(" ");
|
||||
const argsString = args.join(' ');
|
||||
processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString);
|
||||
|
||||
// Replace $1, $2, etc. with positional arguments
|
||||
args.forEach((arg, index) => {
|
||||
const placeholder = `$${index + 1}`;
|
||||
processedContent = processedContent.replace(
|
||||
new RegExp(`\\${placeholder}\\b`, "g"),
|
||||
arg,
|
||||
);
|
||||
processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg);
|
||||
});
|
||||
|
||||
res.json({
|
||||
type: "custom",
|
||||
type: 'custom',
|
||||
command: commandName,
|
||||
content: processedContent,
|
||||
metadata,
|
||||
hasFileIncludes: processedContent.includes("@"),
|
||||
hasBashCommands: processedContent.includes("!"),
|
||||
hasFileIncludes: processedContent.includes('@'),
|
||||
hasBashCommands: processedContent.includes('!')
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({
|
||||
error: "Command not found",
|
||||
message: `Command file not found: ${req.body.commandPath}`,
|
||||
error: 'Command not found',
|
||||
message: `Command file not found: ${req.body.commandPath}`
|
||||
});
|
||||
}
|
||||
|
||||
console.error("Error executing command:", error);
|
||||
console.error('Error executing command:', error);
|
||||
res.status(500).json({
|
||||
error: "Failed to execute command",
|
||||
message: error.message,
|
||||
error: 'Failed to execute command',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CURSOR_FALLBACK_MODELS } from '../modules/providers/list/cursor/cursor-models.provider.js';
|
||||
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -29,7 +29,7 @@ router.get('/config', async (req, res) => {
|
||||
config: {
|
||||
version: 1,
|
||||
model: {
|
||||
modelId: CURSOR_FALLBACK_MODELS.DEFAULT,
|
||||
modelId: CURSOR_MODELS.DEFAULT,
|
||||
displayName: 'GPT-5',
|
||||
},
|
||||
permissions: {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { executeModelsCommand } from '../commands.js';
|
||||
import { providerModelsService } from '../../modules/providers/services/provider-models.service.js';
|
||||
|
||||
test('models command returns available models only for the active provider', async () => {
|
||||
const originalGetProviderModels = providerModelsService.getProviderModels;
|
||||
const originalGetCurrentActiveModel = providerModelsService.getCurrentActiveModel;
|
||||
let getCurrentActiveModelCalls = 0;
|
||||
|
||||
providerModelsService.getProviderModels = async () => ({
|
||||
models: {
|
||||
OPTIONS: [{ value: 'gpt-5.4', label: 'gpt-5.4' }],
|
||||
DEFAULT: 'gpt-5.4',
|
||||
},
|
||||
cache: {
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: '2026-01-04T00:00:00.000Z',
|
||||
source: 'fresh',
|
||||
},
|
||||
});
|
||||
providerModelsService.getCurrentActiveModel = async () => {
|
||||
getCurrentActiveModelCalls += 1;
|
||||
return {
|
||||
model: 'gpt-5.3-codex',
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executeModelsCommand([], {
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
});
|
||||
|
||||
assert.equal(result.type, 'builtin');
|
||||
assert.equal(result.action, 'models');
|
||||
assert.equal(result.data.current.provider, 'codex');
|
||||
assert.equal(result.data.current.model, 'gpt-5.4');
|
||||
assert.deepEqual(Object.keys(result.data.available), ['codex']);
|
||||
assert.deepEqual(result.data.available.codex, result.data.availableModels);
|
||||
assert.ok(result.data.availableModels.includes('gpt-5.4'));
|
||||
assert.equal(result.data.available.claude, undefined);
|
||||
assert.equal(result.data.available.cursor, undefined);
|
||||
assert.equal(getCurrentActiveModelCalls, 0);
|
||||
} finally {
|
||||
providerModelsService.getProviderModels = originalGetProviderModels;
|
||||
providerModelsService.getCurrentActiveModel = originalGetCurrentActiveModel;
|
||||
}
|
||||
});
|
||||
|
||||
test('models command falls back to claude for unsupported providers', async () => {
|
||||
const originalGetProviderModels = providerModelsService.getProviderModels;
|
||||
const originalGetCurrentActiveModel = providerModelsService.getCurrentActiveModel;
|
||||
|
||||
providerModelsService.getProviderModels = async () => ({
|
||||
models: {
|
||||
OPTIONS: [{ value: 'default', label: 'Default (recommended)' }],
|
||||
DEFAULT: 'default',
|
||||
},
|
||||
cache: {
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: '2026-01-04T00:00:00.000Z',
|
||||
source: 'fresh',
|
||||
},
|
||||
});
|
||||
providerModelsService.getCurrentActiveModel = async () => ({
|
||||
model: 'default',
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await executeModelsCommand([], {
|
||||
provider: 'unknown-provider',
|
||||
});
|
||||
|
||||
assert.equal(result.data.current.provider, 'claude');
|
||||
assert.deepEqual(Object.keys(result.data.available), ['claude']);
|
||||
} finally {
|
||||
providerModelsService.getProviderModels = originalGetProviderModels;
|
||||
providerModelsService.getCurrentActiveModel = originalGetCurrentActiveModel;
|
||||
}
|
||||
});
|
||||
@@ -7,11 +7,7 @@ import type {
|
||||
ProviderSkill,
|
||||
ProviderSkillListOptions,
|
||||
ProviderAuthStatus,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderMcpServer,
|
||||
ProviderSessionActiveModelChange,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
@@ -24,7 +20,6 @@ import type {
|
||||
*/
|
||||
export interface IProvider {
|
||||
readonly id: LLMProvider;
|
||||
readonly models: IProviderModels;
|
||||
readonly mcp: IProviderMcp;
|
||||
readonly auth: IProviderAuth;
|
||||
readonly skills: IProviderSkills;
|
||||
@@ -32,46 +27,6 @@ export interface IProvider {
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER MODEL INTERFACE ------------
|
||||
/**
|
||||
* Model catalog contract for one provider.
|
||||
*
|
||||
* Implementations are responsible for resolving the provider's currently
|
||||
* supported models and converting them into the shared
|
||||
* `ProviderModelsDefinition` shape used by backend routes and frontend model
|
||||
* pickers. The `DEFAULT` field should be the most appropriate default selection
|
||||
* for that provider at the time the catalog is read.
|
||||
*/
|
||||
export interface IProviderModels {
|
||||
/**
|
||||
* Returns the provider's currently supported model catalog.
|
||||
*/
|
||||
getSupportedModels(): Promise<ProviderModelsDefinition>;
|
||||
|
||||
/**
|
||||
* Returns the currently active model for one session or provider runtime.
|
||||
*
|
||||
* Implementations must use the provider-specific lookup mechanism approved
|
||||
* for that provider and fall back only to the provider catalog default when
|
||||
* no active model can be resolved.
|
||||
*/
|
||||
getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel>;
|
||||
|
||||
/**
|
||||
* Persists a session-scoped model override that the next resumed turn should
|
||||
* honor for this provider.
|
||||
*
|
||||
* This does not require the provider to mutate an already running remote
|
||||
* session in-place. Instead, adapters store the user's explicit model choice
|
||||
* so the backend resume path can add the correct provider-native model option
|
||||
* on the next CLI/SDK invocation for the same session.
|
||||
*/
|
||||
changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange>;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER AUTH INTERFACE ------------
|
||||
/**
|
||||
|
||||
@@ -65,94 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
|
||||
* Use this as the source of truth whenever a function or payload needs to identify
|
||||
* a specific LLM integration.
|
||||
*/
|
||||
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
|
||||
|
||||
/**
|
||||
* One selectable model row (matches the documentation `public/modelConstants.js` option shape).
|
||||
*/
|
||||
export type ProviderModelOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider model catalog returned by `GET /api/providers/:provider/models`.
|
||||
*/
|
||||
export type ProviderModelsDefinition = {
|
||||
OPTIONS: ProviderModelOption[];
|
||||
DEFAULT: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cache metadata returned alongside one provider model catalog.
|
||||
*
|
||||
* `updatedAt` is when the current cached snapshot was last refreshed from the
|
||||
* provider itself. `expiresAt` is the backend cache expiry timestamp, and
|
||||
* `source` tells callers whether the current response came from in-memory cache,
|
||||
* persisted disk cache, or a fresh provider fetch.
|
||||
*/
|
||||
export type ProviderModelsCacheInfo = {
|
||||
updatedAt: string;
|
||||
expiresAt: string;
|
||||
source: 'memory' | 'disk' | 'fresh';
|
||||
};
|
||||
|
||||
/**
|
||||
* Full provider model lookup result returned by the backend service layer.
|
||||
*
|
||||
* Use this shape when a caller needs both the selectable model catalog and the
|
||||
* cache metadata that explains how current the catalog is.
|
||||
*/
|
||||
export type ProviderModelsResult = {
|
||||
models: ProviderModelsDefinition;
|
||||
cache: ProviderModelsCacheInfo;
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER ACTIVE MODEL TYPES ------------
|
||||
/**
|
||||
* Provider-neutral result for the model that is actively driving a session or
|
||||
* provider runtime at the time of lookup.
|
||||
*
|
||||
* `model` must always be populated. Provider adapters should use the
|
||||
* provider-specific lookup method requested by the caller, and only fall back
|
||||
* to the provider catalog `DEFAULT` value when the active model cannot be read.
|
||||
*/
|
||||
export type ProviderCurrentActiveModel = {
|
||||
model: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Input payload used when one session needs to use a different model on its
|
||||
* next resumed turn.
|
||||
*
|
||||
* This is a backend-owned session override, not a claim that the provider has
|
||||
* already switched the currently running session in-place. Provider adapters
|
||||
* persist this request so the next CLI/SDK resume can inject the chosen model
|
||||
* using the provider-specific mechanism supported by that runtime.
|
||||
*/
|
||||
export type ProviderChangeActiveModelInput = {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider-neutral session model-change state.
|
||||
*
|
||||
* `supported` indicates whether the provider adapter supports the app's
|
||||
* session-scoped resume override flow. `changed` is the persisted boolean the
|
||||
* resume layer checks before forcing a model on the next resumed turn. When
|
||||
* `changed` is `false`, `model` is `null` and the runtime should use the
|
||||
* normal request/default model selection path.
|
||||
*/
|
||||
export type ProviderSessionActiveModelChange = {
|
||||
provider: LLMProvider;
|
||||
sessionId: string;
|
||||
supported: boolean;
|
||||
changed: boolean;
|
||||
model: string | null;
|
||||
};
|
||||
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor';
|
||||
|
||||
/**
|
||||
* Message/event variants emitted by provider adapters and normalized transports.
|
||||
|
||||
@@ -22,13 +22,7 @@ import type {
|
||||
AnyRecord,
|
||||
ApiSuccessShape,
|
||||
AppErrorOptions,
|
||||
LLMProvider,
|
||||
NormalizedMessage,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
ProviderSkillSource,
|
||||
WorkspacePathValidationResult,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
@@ -419,231 +413,6 @@ export const readStringRecord = (value: unknown): Record<string, string> | undef
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER MODEL LOOKUP UTILITIES ------------
|
||||
/**
|
||||
* Builds the standard "default current model" result used when a provider
|
||||
* cannot resolve a session-backed active model.
|
||||
*
|
||||
* Provider model adapters should call this after loading their supported model
|
||||
* catalog so the fallback stays aligned with the provider's current `DEFAULT`
|
||||
* selection instead of drifting to a hard-coded duplicate.
|
||||
*/
|
||||
export function buildDefaultProviderCurrentActiveModel(
|
||||
models: ProviderModelsDefinition,
|
||||
): ProviderCurrentActiveModel {
|
||||
return {
|
||||
model: models.DEFAULT,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SESSION MODEL CHANGE UTILITIES ------------
|
||||
type ProviderSessionActiveModelChangeCacheEntry = ProviderSessionActiveModelChange & {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type ProviderSessionActiveModelChangeCacheFile = {
|
||||
version: number;
|
||||
entries: Record<string, ProviderSessionActiveModelChangeCacheEntry>;
|
||||
};
|
||||
|
||||
const PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Resolves the backend-owned cache file used for session-scoped resume model
|
||||
* overrides.
|
||||
*
|
||||
* The file lives under `~/.cloudcli` because these overrides are an application
|
||||
* concern rather than a provider-native config file. Providers, routes, and
|
||||
* runtime command launchers should all use this helper instead of re-creating
|
||||
* the path so the storage location stays consistent.
|
||||
*/
|
||||
export function getProviderSessionActiveModelChangesPath(): string {
|
||||
return path.join(os.homedir(), '.cloudcli', 'provider-session-active-model-changes.json');
|
||||
}
|
||||
|
||||
const buildProviderSessionActiveModelChangeKey = (
|
||||
provider: LLMProvider,
|
||||
sessionId: string,
|
||||
): string => `${provider}:${sessionId}`;
|
||||
|
||||
const isProviderSessionActiveModelChangeCacheEntry = (
|
||||
value: unknown,
|
||||
): value is ProviderSessionActiveModelChangeCacheEntry => {
|
||||
const record = readObjectRecord(value);
|
||||
return Boolean(
|
||||
record
|
||||
&& typeof record.provider === 'string'
|
||||
&& typeof record.sessionId === 'string'
|
||||
&& typeof record.supported === 'boolean'
|
||||
&& typeof record.changed === 'boolean'
|
||||
&& (typeof record.model === 'string' || record.model === null)
|
||||
&& typeof record.updatedAt === 'string',
|
||||
);
|
||||
};
|
||||
|
||||
const readProviderSessionActiveModelChangeCacheFile = async (
|
||||
filePath: string,
|
||||
): Promise<ProviderSessionActiveModelChangeCacheFile> => {
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const parsed = readObjectRecord(JSON.parse(raw));
|
||||
if (
|
||||
!parsed
|
||||
|| parsed.version !== PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION
|
||||
|| !readObjectRecord(parsed.entries)
|
||||
) {
|
||||
return {
|
||||
version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
|
||||
entries: {},
|
||||
};
|
||||
}
|
||||
|
||||
const entries = Object.fromEntries(
|
||||
Object.entries(parsed.entries).filter((entry): entry is [string, ProviderSessionActiveModelChangeCacheEntry] =>
|
||||
isProviderSessionActiveModelChangeCacheEntry(entry[1]),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
|
||||
entries,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
|
||||
entries: {},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const writeProviderSessionActiveModelChangeCacheFile = async (
|
||||
filePath: string,
|
||||
payload: ProviderSessionActiveModelChangeCacheFile,
|
||||
): Promise<void> => {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
const buildUnsupportedProviderSessionActiveModelChange = (
|
||||
provider: LLMProvider,
|
||||
sessionId: string,
|
||||
): ProviderSessionActiveModelChange => ({
|
||||
provider,
|
||||
sessionId,
|
||||
supported: false,
|
||||
changed: false,
|
||||
model: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Reads the persisted session model-change state for one provider session.
|
||||
*
|
||||
* Runtime resume paths use this to decide whether they should inject a
|
||||
* provider-specific model argument/thread option for the next resumed turn.
|
||||
* Missing cache entries are normalized to `{ changed: false }` so callers can
|
||||
* treat absence as "use the ordinary model selection flow".
|
||||
*/
|
||||
export async function readProviderSessionActiveModelChange(
|
||||
provider: LLMProvider,
|
||||
sessionId: string,
|
||||
options: {
|
||||
filePath?: string;
|
||||
supported?: boolean;
|
||||
} = {},
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
|
||||
}
|
||||
|
||||
const supported = options.supported ?? true;
|
||||
if (!supported) {
|
||||
return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
|
||||
}
|
||||
|
||||
const filePath = options.filePath ?? getProviderSessionActiveModelChangesPath();
|
||||
const cacheFile = await readProviderSessionActiveModelChangeCacheFile(filePath);
|
||||
const cacheEntry = cacheFile.entries[
|
||||
buildProviderSessionActiveModelChangeKey(provider, normalizedSessionId)
|
||||
];
|
||||
|
||||
if (!cacheEntry || !cacheEntry.changed || !cacheEntry.model?.trim()) {
|
||||
return {
|
||||
provider,
|
||||
sessionId: normalizedSessionId,
|
||||
supported: true,
|
||||
changed: false,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
sessionId: normalizedSessionId,
|
||||
supported: true,
|
||||
changed: true,
|
||||
model: cacheEntry.model.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a session model-change request for one provider.
|
||||
*
|
||||
* Provider adapters call this when the frontend explicitly selects a different
|
||||
* model for an existing session. The stored `changed: true` flag is the single
|
||||
* source of truth used later by resume paths to decide whether they should add
|
||||
* a provider-native model override on the next invocation.
|
||||
*/
|
||||
export async function writeProviderSessionActiveModelChange(
|
||||
provider: LLMProvider,
|
||||
input: ProviderChangeActiveModelInput,
|
||||
options: {
|
||||
filePath?: string;
|
||||
supported?: boolean;
|
||||
} = {},
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
const normalizedSessionId = input.sessionId.trim();
|
||||
const normalizedModel = input.model.trim();
|
||||
const supported = options.supported ?? true;
|
||||
|
||||
if (!supported) {
|
||||
return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
|
||||
}
|
||||
|
||||
if (!normalizedSessionId || !normalizedModel) {
|
||||
return {
|
||||
provider,
|
||||
sessionId: normalizedSessionId,
|
||||
supported: true,
|
||||
changed: false,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
const filePath = options.filePath ?? getProviderSessionActiveModelChangesPath();
|
||||
const cacheFile = await readProviderSessionActiveModelChangeCacheFile(filePath);
|
||||
cacheFile.entries[buildProviderSessionActiveModelChangeKey(provider, normalizedSessionId)] = {
|
||||
provider,
|
||||
sessionId: normalizedSessionId,
|
||||
supported: true,
|
||||
changed: true,
|
||||
model: normalizedModel,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeProviderSessionActiveModelChangeCacheFile(filePath, cacheFile);
|
||||
|
||||
return {
|
||||
provider,
|
||||
sessionId: normalizedSessionId,
|
||||
supported: true,
|
||||
changed: true,
|
||||
model: normalizedModel,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- WEBSOCKET PAYLOAD PARSING UTILITIES ------------
|
||||
/**
|
||||
@@ -737,67 +506,6 @@ export const writeJsonConfig = async (filePath: string, data: Record<string, unk
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SKILL FILE UTILITIES ------------
|
||||
async function hasGitMarker(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
const gitMarkerStats = await stat(path.join(dirPath, '.git'));
|
||||
return gitMarkerStats.isDirectory() || gitMarkerStats.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the highest git worktree root visible from a starting directory.
|
||||
*
|
||||
* Provider skill systems such as Codex and OpenCode walk upward through parent
|
||||
* folders when resolving repository/project skills. Use this helper when a
|
||||
* provider needs the topmost `.git` marker instead of only the nearest one, so
|
||||
* monorepos and nested package folders discover shared root-level skills once.
|
||||
*/
|
||||
export async function findTopmostGitRoot(startPath: string): Promise<string | null> {
|
||||
let currentPath = path.resolve(startPath);
|
||||
let topmostGitRoot: string | null = null;
|
||||
|
||||
while (true) {
|
||||
if (await hasGitMarker(currentPath)) {
|
||||
topmostGitRoot = currentPath;
|
||||
}
|
||||
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentPath = parentPath;
|
||||
}
|
||||
|
||||
return topmostGitRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one provider skill source after normalizing and de-duplicating its root.
|
||||
*
|
||||
* Provider skill lookup rules often point at overlapping folders (for example a
|
||||
* workspace folder can also be the git root). Use this helper while building a
|
||||
* provider's `ProviderSkillSource[]` so the shared skills scanner reads each
|
||||
* physical root once and still preserves provider-specific scope/command data.
|
||||
*/
|
||||
export function addUniqueProviderSkillSource(
|
||||
sources: ProviderSkillSource[],
|
||||
seenRootDirs: Set<string>,
|
||||
source: ProviderSkillSource,
|
||||
): void {
|
||||
const normalizedRootDir = path.resolve(source.rootDir);
|
||||
if (seenRootDirs.has(normalizedRootDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seenRootDirs.add(normalizedRootDir);
|
||||
sources.push({ ...source, rootDir: normalizedRootDir });
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SKILL MARKDOWN UTILITIES ------------
|
||||
/**
|
||||
* Finds direct child skill markdown files under a provider skill root.
|
||||
*
|
||||
@@ -908,98 +616,6 @@ export function normalizeSessionName(rawValue: string | undefined, fallback: str
|
||||
return normalized.slice(0, 120);
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SESSION VALUE NORMALIZATION UTILITIES ------------
|
||||
/**
|
||||
* Converts provider-native timestamps into ISO strings.
|
||||
*
|
||||
* Provider CLIs commonly persist epoch timestamps as milliseconds, seconds, or
|
||||
* already-formatted date strings. Use this helper when normalizing session
|
||||
* metadata or transcript events so every provider writes the same ISO timestamp
|
||||
* shape to API responses and database rows.
|
||||
*/
|
||||
export function normalizeProviderTimestamp(value: unknown): string {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
const millis = value < 1_000_000_000_000 ? value * 1000 : value;
|
||||
return new Date(millis).toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return normalizeProviderTimestamp(parsed);
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return date.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a JSON string or narrows an existing object into a plain record.
|
||||
*
|
||||
* Use this when provider databases store structured JSON inside text columns.
|
||||
* Invalid JSON, arrays, and primitive values return `null` so callers can skip
|
||||
* malformed optional metadata without hiding the rest of a session transcript.
|
||||
*/
|
||||
export function readJsonRecord(value: unknown): AnyRecord | null {
|
||||
if (typeof value !== 'string') {
|
||||
return readObjectRecord(value);
|
||||
}
|
||||
|
||||
try {
|
||||
return readObjectRecord(JSON.parse(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- OPENCODE SESSION STORAGE UTILITIES ------------
|
||||
/**
|
||||
* Resolves the OpenCode SQLite session database path.
|
||||
*
|
||||
* OpenCode stores session, message, part, and project metadata in one shared
|
||||
* `opencode.db` file under its XDG data directory. Provider readers and
|
||||
* synchronizers should use this path for read-only access and should never store
|
||||
* it as a deletable transcript path for an individual app session row.
|
||||
*/
|
||||
export function getOpenCodeDatabasePath(): string {
|
||||
return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- SAFE DIRECTORY NAME UTILITIES ------------
|
||||
/**
|
||||
* Validates that a user or provider supplied identifier can safely be treated
|
||||
* as one leaf directory name under an existing root folder.
|
||||
*
|
||||
* Use this before composing paths like `<root>/<session-id>/file.db>` to block
|
||||
* path traversal and accidental nested paths. The returned string is trimmed but
|
||||
* otherwise unchanged so callers can still match the provider's on-disk naming.
|
||||
*/
|
||||
export function sanitizeLeafDirectoryName(inputName: string, label = 'directory name'): string {
|
||||
const normalized = inputName.trim();
|
||||
if (!normalized) {
|
||||
throw new Error(`${label} is required.`);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('..')
|
||||
|| normalized.includes(path.posix.sep)
|
||||
|| normalized.includes(path.win32.sep)
|
||||
|| normalized !== path.basename(normalized)
|
||||
) {
|
||||
throw new Error(`Invalid ${label} "${inputName}".`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- SESSION SYNCHRONIZER FILESYSTEM HELPERS ------------
|
||||
/**
|
||||
|
||||
107
shared/modelConstants.js
Normal file
107
shared/modelConstants.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Centralized Model Definitions
|
||||
* Single source of truth for all supported AI models
|
||||
*/
|
||||
|
||||
/**
|
||||
* Claude (Anthropic) Models
|
||||
*
|
||||
* Note: Claude uses two different formats:
|
||||
* - SDK format ('sonnet', 'opus') - used by the UI and claude-sdk.js
|
||||
* - API format ('claude-sonnet-4.5') - used by slash commands for display
|
||||
*/
|
||||
export const CLAUDE_MODELS = {
|
||||
// Models in SDK format (what the actual SDK accepts)
|
||||
OPTIONS: [
|
||||
{ value: "opus", label: "Opus" },
|
||||
{ value: "sonnet", label: "Sonnet" },
|
||||
{ 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]" },
|
||||
],
|
||||
|
||||
DEFAULT: "opus",
|
||||
};
|
||||
|
||||
/**
|
||||
* Cursor Models
|
||||
*/
|
||||
export const CURSOR_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "opus-4.6-thinking", label: "Claude 4.6 Opus (Thinking)" },
|
||||
{ value: "gpt-5.3-codex", label: "GPT-5.3" },
|
||||
{ value: "gpt-5.2-high", label: "GPT-5.2 High" },
|
||||
{ value: "gemini-3-pro", label: "Gemini 3 Pro" },
|
||||
{ value: "opus-4.5-thinking", label: "Claude 4.5 Opus (Thinking)" },
|
||||
{ value: "gpt-5.2", label: "GPT-5.2" },
|
||||
{ value: "gpt-5.1", label: "GPT-5.1" },
|
||||
{ value: "gpt-5.1-high", label: "GPT-5.1 High" },
|
||||
{ value: "composer-1", label: "Composer 1" },
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "sonnet-4.5", label: "Claude 4.5 Sonnet" },
|
||||
{ value: "sonnet-4.5-thinking", label: "Claude 4.5 Sonnet (Thinking)" },
|
||||
{ value: "opus-4.5", label: "Claude 4.5 Opus" },
|
||||
{ value: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
|
||||
{ value: "gpt-5.1-codex-high", label: "GPT-5.1 Codex High" },
|
||||
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
||||
{ value: "gpt-5.1-codex-max-high", label: "GPT-5.1 Codex Max High" },
|
||||
{ value: "opus-4.1", label: "Claude 4.1 Opus" },
|
||||
{ value: "grok", label: "Grok" },
|
||||
],
|
||||
|
||||
DEFAULT: "gpt-5.3-codex",
|
||||
};
|
||||
|
||||
/**
|
||||
* Codex (OpenAI) Models
|
||||
*/
|
||||
export const CODEX_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "gpt-5.5", label: "GPT-5.5" },
|
||||
{ value: "gpt-5.4", label: "GPT-5.4" },
|
||||
{ value: "gpt-5.4-mini", label: "GPT-5.4 mini" },
|
||||
{ value: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
|
||||
{ value: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
|
||||
{ value: "gpt-5.2", label: "GPT-5.2" },
|
||||
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
||||
{ value: "o3", label: "O3" },
|
||||
{ value: "o4-mini", label: "O4-mini" },
|
||||
],
|
||||
|
||||
DEFAULT: "gpt-5.4",
|
||||
};
|
||||
|
||||
/**
|
||||
* Gemini Models
|
||||
*/
|
||||
export const GEMINI_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
|
||||
{ value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
|
||||
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
|
||||
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
||||
{ value: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
|
||||
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
|
||||
{
|
||||
value: "gemini-2.0-flash-thinking-exp",
|
||||
label: "Gemini 2.0 Flash Thinking",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "gemini-3.1-pro-preview",
|
||||
};
|
||||
|
||||
/**
|
||||
* Ordered provider registry. Display order in selection UIs.
|
||||
*/
|
||||
export const PROVIDERS = [
|
||||
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS },
|
||||
{ id: "codex", name: "OpenAI", models: CODEX_MODELS },
|
||||
{ id: "gemini", name: "Google", models: GEMINI_MODELS },
|
||||
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS },
|
||||
];
|
||||
@@ -20,13 +20,14 @@ import type {
|
||||
PendingPermissionRequest,
|
||||
PermissionMode,
|
||||
} from '../types/types';
|
||||
import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import { escapeRegExp } from '../utils/chatFormatting';
|
||||
|
||||
import { useFileMentions } from './useFileMentions';
|
||||
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -41,7 +42,6 @@ interface UseChatComposerStateArgs {
|
||||
claudeModel: string;
|
||||
codexModel: string;
|
||||
geminiModel: string;
|
||||
opencodeModel: string;
|
||||
isLoading: boolean;
|
||||
canAbortSession: boolean;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
@@ -55,6 +55,8 @@ interface UseChatComposerStateArgs {
|
||||
pendingViewSessionRef: { current: PendingViewSession | null };
|
||||
scrollToBottom: () => void;
|
||||
addMessage: (msg: ChatMessage) => void;
|
||||
clearMessages: () => void;
|
||||
rewindMessages: (count: number) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
@@ -76,76 +78,6 @@ interface CommandExecutionResult {
|
||||
hasFileIncludes?: boolean;
|
||||
}
|
||||
|
||||
export type ModelCommandData = {
|
||||
current?: {
|
||||
provider?: string;
|
||||
providerLabel?: string;
|
||||
model?: string;
|
||||
};
|
||||
available?: Partial<Record<LLMProvider, string[]>>;
|
||||
availableModels?: string[];
|
||||
availableOptions?: Array<{
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}>;
|
||||
defaultModel?: string;
|
||||
cache?: ProviderModelsCacheInfo;
|
||||
};
|
||||
|
||||
export type CostCommandData = {
|
||||
tokenUsage?: {
|
||||
used?: number;
|
||||
total?: number;
|
||||
percentage?: number;
|
||||
};
|
||||
cost?: {
|
||||
input?: string;
|
||||
output?: string;
|
||||
total?: string;
|
||||
};
|
||||
tokenBreakdown?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache?: number;
|
||||
};
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type StatusCommandData = {
|
||||
version?: string;
|
||||
packageName?: string;
|
||||
uptime?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
nodeVersion?: string;
|
||||
platform?: string;
|
||||
pid?: number;
|
||||
memoryUsage?: {
|
||||
rssMb?: number;
|
||||
heapUsedMb?: number;
|
||||
heapTotalMb?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type HelpCommandData = {
|
||||
content?: string;
|
||||
format?: string;
|
||||
commands?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CommandModalKind = 'help' | 'models' | 'cost' | 'status';
|
||||
|
||||
export type CommandModalPayload = {
|
||||
kind: CommandModalKind;
|
||||
data: HelpCommandData | ModelCommandData | CostCommandData | StatusCommandData;
|
||||
};
|
||||
|
||||
const createFakeSubmitEvent = () => {
|
||||
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
||||
};
|
||||
@@ -179,7 +111,6 @@ export function useChatComposerState({
|
||||
claudeModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
@@ -193,6 +124,8 @@ export function useChatComposerState({
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
@@ -212,7 +145,6 @@ export function useChatComposerState({
|
||||
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||
const [thinkingMode, setThinkingMode] = useState('none');
|
||||
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputHighlightRef = useRef<HTMLDivElement>(null);
|
||||
@@ -226,33 +158,35 @@ export function useChatComposerState({
|
||||
(result: CommandExecutionResult) => {
|
||||
const { action, data } = result;
|
||||
switch (action) {
|
||||
case 'clear':
|
||||
clearMessages();
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
setCommandModalPayload({
|
||||
kind: 'help',
|
||||
data: (data || {}) as HelpCommandData,
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: data.content,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'models':
|
||||
setCommandModalPayload({
|
||||
kind: 'models',
|
||||
data: (data || {}) as ModelCommandData,
|
||||
case 'model':
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'cost': {
|
||||
setCommandModalPayload({
|
||||
kind: 'cost',
|
||||
data: (data || {}) as CostCommandData,
|
||||
});
|
||||
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
|
||||
addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
setCommandModalPayload({
|
||||
kind: 'status',
|
||||
data: (data || {}) as StatusCommandData,
|
||||
});
|
||||
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
|
||||
addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -279,17 +213,30 @@ export function useChatComposerState({
|
||||
onShowSettings?.();
|
||||
break;
|
||||
|
||||
case 'rewind':
|
||||
if (data.error) {
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: `Warning: ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else {
|
||||
rewindMessages(data.steps * 2);
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: `Rewound ${data.steps} step(s). ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown built-in command action:', action);
|
||||
}
|
||||
},
|
||||
[onFileOpen, onShowSettings, addMessage],
|
||||
[onFileOpen, onShowSettings, addMessage, clearMessages, rewindMessages],
|
||||
);
|
||||
|
||||
const closeCommandModal = useCallback(() => {
|
||||
setCommandModalPayload(null);
|
||||
}, []);
|
||||
|
||||
const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
|
||||
const { content, hasBashCommands } = result;
|
||||
|
||||
@@ -338,15 +285,7 @@ export function useChatComposerState({
|
||||
projectId: selectedProject.projectId,
|
||||
sessionId: currentSessionId,
|
||||
provider,
|
||||
model: provider === 'cursor'
|
||||
? cursorModel
|
||||
: provider === 'codex'
|
||||
? codexModel
|
||||
: provider === 'gemini'
|
||||
? geminiModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: claudeModel,
|
||||
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
|
||||
tokenUsage: tokenBudget,
|
||||
};
|
||||
|
||||
@@ -398,7 +337,6 @@ export function useChatComposerState({
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
handleBuiltInCommand,
|
||||
handleCustomCommand,
|
||||
input,
|
||||
@@ -535,26 +473,13 @@ export function useChatComposerState({
|
||||
}
|
||||
|
||||
// Intercept slash commands only when "/" is the first input character.
|
||||
// Also accept exact "help" as a convenience alias for users who expect CLI-style help.
|
||||
const commandInput = currentInput.trimEnd();
|
||||
const isHelpAlias = commandInput.trim().toLowerCase() === 'help';
|
||||
if (commandInput.startsWith('/') || isHelpAlias) {
|
||||
if (commandInput.startsWith('/')) {
|
||||
const firstSpace = commandInput.indexOf(' ');
|
||||
const commandName = isHelpAlias
|
||||
? '/help'
|
||||
: firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput;
|
||||
const matchedCommand =
|
||||
slashCommands.find((cmd: SlashCommand) => cmd.name === commandName) ||
|
||||
(commandName === '/help'
|
||||
? ({
|
||||
name: '/help',
|
||||
description: 'Show help documentation for Claude Code',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' },
|
||||
} as SlashCommand)
|
||||
: undefined);
|
||||
const commandName = firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput;
|
||||
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
|
||||
if (matchedCommand && matchedCommand.type !== 'skill') {
|
||||
executeCommand(matchedCommand, isHelpAlias ? '/help' : commandInput);
|
||||
executeCommand(matchedCommand, commandInput);
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
setAttachedImages([]);
|
||||
@@ -630,9 +555,13 @@ export function useChatComposerState({
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
|
||||
if (!effectiveSessionId && !selectedSession?.id) {
|
||||
// This tracks only that a request is in flight before the provider has
|
||||
// emitted its real session id; routing still waits for session_created.
|
||||
pendingViewSessionRef.current = { startedAt: Date.now() };
|
||||
if (typeof window !== 'undefined') {
|
||||
// Reset stale pending IDs from previous interrupted runs before creating a new one.
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
}
|
||||
// For new sessions we intentionally keep this as `null` until the backend
|
||||
// emits `session_created` with the canonical provider session id.
|
||||
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
|
||||
}
|
||||
if (effectiveSessionId) {
|
||||
onSessionActive?.(effectiveSessionId);
|
||||
@@ -648,8 +577,6 @@ export function useChatComposerState({
|
||||
? 'codex-settings'
|
||||
: provider === 'gemini'
|
||||
? 'gemini-settings'
|
||||
: provider === 'opencode'
|
||||
? 'opencode-settings'
|
||||
: 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
@@ -717,20 +644,6 @@ export function useChatComposerState({
|
||||
toolsSettings,
|
||||
},
|
||||
});
|
||||
} else if (provider === 'opencode') {
|
||||
sendMessage({
|
||||
type: 'opencode-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: resolvedProjectPath,
|
||||
projectPath: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: opencodeModel,
|
||||
sessionSummary,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendMessage({
|
||||
type: 'claude-command',
|
||||
@@ -773,7 +686,6 @@ export function useChatComposerState({
|
||||
cursorModel,
|
||||
executeCommand,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
@@ -944,11 +856,15 @@ export function useChatComposerState({
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
|
||||
const cursorSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
|
||||
|
||||
const candidateSessionIds = [
|
||||
currentSessionId,
|
||||
pendingViewSessionRef.current?.sessionId || null,
|
||||
pendingSessionId,
|
||||
provider === 'cursor' ? cursorSessionId : null,
|
||||
selectedSession?.id || null,
|
||||
];
|
||||
@@ -966,7 +882,7 @@ export function useChatComposerState({
|
||||
sessionId: targetSessionId,
|
||||
provider,
|
||||
});
|
||||
}, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]);
|
||||
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
|
||||
|
||||
const handleGrantToolPermission = useCallback(
|
||||
(suggestion: { entry: string; toolName: string }) => {
|
||||
@@ -1064,7 +980,5 @@ export function useChatComposerState({
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
|
||||
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
|
||||
import type {
|
||||
ProjectSession,
|
||||
LLMProvider,
|
||||
Project,
|
||||
ProviderModelsCacheInfo,
|
||||
ProviderModelsDefinition,
|
||||
} from '../../../types/app';
|
||||
|
||||
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
||||
claude: 'opus',
|
||||
cursor: 'gpt-5.3-codex',
|
||||
codex: 'gpt-5.4',
|
||||
gemini: 'gemini-3.1-pro-preview',
|
||||
opencode: 'anthropic/claude-sonnet-4-5',
|
||||
};
|
||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
|
||||
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
|
||||
if (provider === 'codex') {
|
||||
@@ -24,242 +11,33 @@ const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[]
|
||||
if (provider === 'claude') {
|
||||
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
}
|
||||
if (provider === 'opencode') {
|
||||
return ['default'];
|
||||
}
|
||||
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
};
|
||||
|
||||
interface UseChatProviderStateArgs {
|
||||
selectedSession: ProjectSession | null;
|
||||
selectedProject: Project | null;
|
||||
}
|
||||
|
||||
type ProviderModelsApiResponse = {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
models?: ProviderModelsDefinition;
|
||||
cache?: ProviderModelsCacheInfo;
|
||||
};
|
||||
};
|
||||
|
||||
type ChangeActiveModelApiResponse = {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
provider?: LLMProvider;
|
||||
sessionId?: string;
|
||||
supported?: boolean;
|
||||
changed?: boolean;
|
||||
model?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) {
|
||||
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
|
||||
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
|
||||
const [provider, setProvider] = useState<LLMProvider>(() => {
|
||||
return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
||||
});
|
||||
const [cursorModel, setCursorModel] = useState<string>(() => {
|
||||
return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor;
|
||||
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
|
||||
});
|
||||
const [claudeModel, setClaudeModel] = useState<string>(() => {
|
||||
return localStorage.getItem('claude-model') || FALLBACK_DEFAULT_MODEL.claude;
|
||||
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
|
||||
});
|
||||
const [codexModel, setCodexModel] = useState<string>(() => {
|
||||
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
|
||||
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
||||
});
|
||||
const [geminiModel, setGeminiModel] = useState<string>(() => {
|
||||
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
|
||||
return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT;
|
||||
});
|
||||
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
|
||||
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
|
||||
});
|
||||
|
||||
const [providerModelCatalog, setProviderModelCatalog] = useState<
|
||||
Partial<Record<LLMProvider, ProviderModelsDefinition>>
|
||||
>({});
|
||||
const [providerModelCacheCatalog, setProviderModelCacheCatalog] = useState<
|
||||
Partial<Record<LLMProvider, ProviderModelsCacheInfo>>
|
||||
>({});
|
||||
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
|
||||
const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false);
|
||||
|
||||
const lastProviderRef = useRef(provider);
|
||||
const providerModelsRequestIdRef = useRef(0);
|
||||
|
||||
const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => {
|
||||
if (targetProvider === 'claude') {
|
||||
setClaudeModel(model);
|
||||
localStorage.setItem('claude-model', model);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetProvider === 'cursor') {
|
||||
setCursorModel(model);
|
||||
localStorage.setItem('cursor-model', model);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetProvider === 'codex') {
|
||||
setCodexModel(model);
|
||||
localStorage.setItem('codex-model', model);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetProvider === 'gemini') {
|
||||
setGeminiModel(model);
|
||||
localStorage.setItem('gemini-model', model);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenCodeModel(model);
|
||||
localStorage.setItem('opencode-model', model);
|
||||
}, []);
|
||||
|
||||
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
|
||||
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
const requestId = providerModelsRequestIdRef.current + 1;
|
||||
providerModelsRequestIdRef.current = requestId;
|
||||
const isHardRefresh = options.bypassCache === true;
|
||||
|
||||
if (isHardRefresh) {
|
||||
setProviderModelsRefreshing(true);
|
||||
} else {
|
||||
setProviderModelsLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
providers.map(async (p) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.bypassCache) {
|
||||
params.set('bypassCache', 'true');
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const response = await authenticatedFetch(`/api/providers/${p}/models${queryString ? `?${queryString}` : ''}`);
|
||||
const body = (await response.json()) as ProviderModelsApiResponse;
|
||||
if (!body.success || !body.data?.models || !body.data?.cache) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return body.data;
|
||||
}),
|
||||
);
|
||||
|
||||
if (providerModelsRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
|
||||
const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {};
|
||||
|
||||
providers.forEach((p, i) => {
|
||||
const entry = results[i];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextCatalog[p] = entry.models;
|
||||
nextCacheCatalog[p] = entry.cache;
|
||||
});
|
||||
|
||||
setProviderModelCatalog(nextCatalog);
|
||||
setProviderModelCacheCatalog(nextCacheCatalog);
|
||||
} catch (error) {
|
||||
console.error('Error loading provider models:', error);
|
||||
} finally {
|
||||
if (providerModelsRequestIdRef.current === requestId) {
|
||||
setProviderModelsLoading(false);
|
||||
setProviderModelsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProviderModels();
|
||||
}, [loadProviderModels]);
|
||||
|
||||
const pickStoredOrCurrent = (
|
||||
storageKey: string,
|
||||
current: string,
|
||||
def: ProviderModelsDefinition,
|
||||
): string => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored && def.OPTIONS.some((o) => o.value === stored)) {
|
||||
return stored;
|
||||
}
|
||||
if (current && def.OPTIONS.some((o) => o.value === current)) {
|
||||
return current;
|
||||
}
|
||||
return def.DEFAULT;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const claude = providerModelCatalog.claude;
|
||||
if (claude) {
|
||||
const next = pickStoredOrCurrent('claude-model', claudeModel, claude);
|
||||
if (next !== claudeModel) {
|
||||
setClaudeModel(next);
|
||||
}
|
||||
if (localStorage.getItem('claude-model') !== next) {
|
||||
localStorage.setItem('claude-model', next);
|
||||
}
|
||||
}
|
||||
}, [providerModelCatalog.claude, claudeModel]);
|
||||
|
||||
useEffect(() => {
|
||||
const cursor = providerModelCatalog.cursor;
|
||||
if (cursor) {
|
||||
const next = pickStoredOrCurrent('cursor-model', cursorModel, cursor);
|
||||
if (next !== cursorModel) {
|
||||
setCursorModel(next);
|
||||
}
|
||||
if (localStorage.getItem('cursor-model') !== next) {
|
||||
localStorage.setItem('cursor-model', next);
|
||||
}
|
||||
}
|
||||
}, [providerModelCatalog.cursor, cursorModel]);
|
||||
|
||||
useEffect(() => {
|
||||
const codex = providerModelCatalog.codex;
|
||||
if (codex) {
|
||||
const next = pickStoredOrCurrent('codex-model', codexModel, codex);
|
||||
if (next !== codexModel) {
|
||||
setCodexModel(next);
|
||||
}
|
||||
if (localStorage.getItem('codex-model') !== next) {
|
||||
localStorage.setItem('codex-model', next);
|
||||
}
|
||||
}
|
||||
}, [providerModelCatalog.codex, codexModel]);
|
||||
|
||||
useEffect(() => {
|
||||
const gemini = providerModelCatalog.gemini;
|
||||
if (gemini) {
|
||||
const next = pickStoredOrCurrent('gemini-model', geminiModel, gemini);
|
||||
if (next !== geminiModel) {
|
||||
setGeminiModel(next);
|
||||
}
|
||||
if (localStorage.getItem('gemini-model') !== next) {
|
||||
localStorage.setItem('gemini-model', next);
|
||||
}
|
||||
}
|
||||
}, [providerModelCatalog.gemini, geminiModel]);
|
||||
|
||||
useEffect(() => {
|
||||
const opencode = providerModelCatalog.opencode;
|
||||
if (opencode) {
|
||||
const next = pickStoredOrCurrent('opencode-model', opencodeModel, opencode);
|
||||
if (next !== opencodeModel) {
|
||||
setOpenCodeModel(next);
|
||||
}
|
||||
if (localStorage.getItem('opencode-model') !== next) {
|
||||
localStorage.setItem('opencode-model', next);
|
||||
}
|
||||
}
|
||||
}, [providerModelCatalog.opencode, opencodeModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSession?.id) {
|
||||
@@ -329,41 +107,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
}
|
||||
}, [permissionMode, provider, selectedSession?.id]);
|
||||
|
||||
const selectProviderModel = useCallback(async (
|
||||
targetProvider: LLMProvider,
|
||||
model: string,
|
||||
sessionId?: string | null,
|
||||
) => {
|
||||
const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
|
||||
if (!normalizedSessionId) {
|
||||
setStoredProviderModel(targetProvider, model);
|
||||
return {
|
||||
scope: 'default' as const,
|
||||
changed: false,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
`/api/providers/${targetProvider}/sessions/${encodeURIComponent(normalizedSessionId)}/active-model`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model }),
|
||||
},
|
||||
);
|
||||
|
||||
const body = (await response.json()) as ChangeActiveModelApiResponse;
|
||||
if (!response.ok || !body.success || !body.data?.supported) {
|
||||
throw new Error('Unable to change the active model for this session.');
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'session' as const,
|
||||
changed: body.data.changed === true,
|
||||
model: body.data.model || model,
|
||||
};
|
||||
}, [setStoredProviderModel]);
|
||||
|
||||
return {
|
||||
provider,
|
||||
setProvider,
|
||||
@@ -375,18 +118,10 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
permissionMode,
|
||||
setPermissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
cyclePermissionMode,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsLoading,
|
||||
providerModelsRefreshing,
|
||||
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
|
||||
selectProviderModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -62,7 +63,6 @@ interface UseChatRealtimeHandlersArgs {
|
||||
streamTimerRef: MutableRefObject<number | null>;
|
||||
accumulatedStreamRef: MutableRefObject<string>;
|
||||
onSessionInactive?: (sessionId?: string | null) => void;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
||||
@@ -89,7 +89,6 @@ export function useChatRealtimeHandlers({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
@@ -105,7 +104,7 @@ export function useChatRealtimeHandlers({
|
||||
lastProcessedMessageRef.current = latestMessage;
|
||||
|
||||
const activeViewSessionId =
|
||||
selectedSession?.id || currentSessionId || null;
|
||||
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Legacy messages (no `kind` field) — handle and return */
|
||||
@@ -152,12 +151,10 @@ export function useChatRealtimeHandlers({
|
||||
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
|
||||
|
||||
if (msg.isProcessing) {
|
||||
onSessionActive?.(statusSessionId);
|
||||
onSessionProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
|
||||
return;
|
||||
}
|
||||
|
||||
onSessionInactive?.(statusSessionId);
|
||||
onSessionNotProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) {
|
||||
@@ -238,21 +235,15 @@ export function useChatRealtimeHandlers({
|
||||
if (!currentSessionId) {
|
||||
console.log('Session created with ID:', newSessionId);
|
||||
console.log('Existing session ID:', currentSessionId);
|
||||
sessionStorage.setItem('pendingSessionId', newSessionId);
|
||||
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
|
||||
pendingViewSessionRef.current.sessionId = newSessionId;
|
||||
}
|
||||
setCurrentSessionId(newSessionId);
|
||||
setPendingPermissionRequests((prev) =>
|
||||
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
||||
);
|
||||
}
|
||||
pendingViewSessionRef.current = null;
|
||||
onSessionActive?.(newSessionId);
|
||||
onSessionProcessing?.(newSessionId);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
});
|
||||
onNavigateToSession?.(newSessionId);
|
||||
break;
|
||||
}
|
||||
@@ -275,7 +266,6 @@ export function useChatRealtimeHandlers({
|
||||
setPendingPermissionRequests([]);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
|
||||
// Handle aborted case
|
||||
if (msg.aborted) {
|
||||
@@ -289,10 +279,16 @@ export function useChatRealtimeHandlers({
|
||||
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
||||
? msg.actualSessionId
|
||||
: null;
|
||||
const pendingSessionId = sessionStorage.getItem('pendingSessionId');
|
||||
const completedSuccessfully = msg.exitCode === undefined || msg.exitCode === 0;
|
||||
const isVisibleSession =
|
||||
Boolean(
|
||||
sid
|
||||
&& sid === activeViewSessionId,
|
||||
&& (
|
||||
sid === activeViewSessionId
|
||||
|| sid === pendingSessionId
|
||||
|| pendingViewSessionRef.current?.sessionId === sid
|
||||
),
|
||||
);
|
||||
|
||||
if (actualSessionId && sid && actualSessionId !== sid) {
|
||||
@@ -300,6 +296,17 @@ export function useChatRealtimeHandlers({
|
||||
|
||||
if (isVisibleSession) {
|
||||
setCurrentSessionId(actualSessionId);
|
||||
|
||||
if (pendingViewSessionRef.current) {
|
||||
const pendingSession = pendingViewSessionRef.current.sessionId;
|
||||
if (!pendingSession || pendingSession === sid) {
|
||||
pendingViewSessionRef.current.sessionId = actualSessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (completedSuccessfully && pendingSessionId === sid) {
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
}
|
||||
|
||||
if (isVisibleSession) {
|
||||
@@ -309,6 +316,16 @@ export function useChatRealtimeHandlers({
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear pending session
|
||||
if (pendingSessionId && !currentSessionId && completedSuccessfully) {
|
||||
const resolvedSessionId = actualSessionId || pendingSessionId;
|
||||
setCurrentSessionId(resolvedSessionId);
|
||||
if (actualSessionId) {
|
||||
onNavigateToSession?.(resolvedSessionId, { replace: true });
|
||||
}
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -318,7 +335,6 @@ export function useChatRealtimeHandlers({
|
||||
setClaudeStatus(null);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -383,7 +399,6 @@ export function useChatRealtimeHandlers({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
|
||||
@@ -13,6 +13,7 @@ const MESSAGES_PER_PAGE = 20;
|
||||
const INITIAL_VISIBLE_MESSAGES = 100;
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -159,7 +160,7 @@ export function useChatSessionState({
|
||||
* Why this is essential:
|
||||
* - Chat keeps local state that is not fully derived from `selectedSession`:
|
||||
* `currentSessionId`, `pendingUserMessage`, streaming/status flags, message
|
||||
* pagination/scroll bookkeeping, and provider-specific sessionStorage keys.
|
||||
* pagination/scroll bookkeeping, and pending session IDs in sessionStorage.
|
||||
* - If the user clicks New Session while already on the same route with no
|
||||
* selected session, parent state updates can be idempotent and this local
|
||||
* state would otherwise persist, making the click appear to "do nothing".
|
||||
@@ -176,6 +177,7 @@ export function useChatSessionState({
|
||||
setIsLoading(false);
|
||||
setCurrentSessionId(null);
|
||||
setPendingUserMessage(null);
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
@@ -394,12 +396,6 @@ export function useChatSessionState({
|
||||
// Main session loading effect — store-based
|
||||
useEffect(() => {
|
||||
if (!selectedSession || !selectedProject) {
|
||||
// A new provider run can be in flight before the router has a canonical
|
||||
// selectedSession. Keep the processing banner alive until complete/error.
|
||||
if (pendingViewSessionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setClaudeStatus(null);
|
||||
@@ -541,6 +537,10 @@ export function useChatSessionState({
|
||||
}
|
||||
}, [selectedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSession?.id) pendingViewSessionRef.current = null;
|
||||
}, [pendingViewSessionRef, selectedSession?.id]);
|
||||
|
||||
// Scroll to search target
|
||||
useEffect(() => {
|
||||
if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return;
|
||||
|
||||
@@ -14,10 +14,10 @@ import { useSessionStore } from '../../../stores/useSessionStore';
|
||||
|
||||
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
|
||||
import ChatComposer from './subcomponents/ChatComposer';
|
||||
import CommandResultModal from './subcomponents/CommandResultModal';
|
||||
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -72,26 +72,19 @@ function ChatInterface({
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
permissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
cyclePermissionMode,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsLoading,
|
||||
providerModelsRefreshing,
|
||||
hardRefreshProviderModels,
|
||||
selectProviderModel,
|
||||
} = useChatProviderState({
|
||||
selectedSession,
|
||||
selectedProject,
|
||||
});
|
||||
|
||||
const {
|
||||
chatMessages,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
currentSessionId,
|
||||
@@ -177,9 +170,7 @@ function ChatInterface({
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused: _isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
isInputFocused,
|
||||
} = useChatComposerState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
@@ -191,7 +182,6 @@ function ChatInterface({
|
||||
claudeModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
@@ -205,6 +195,8 @@ function ChatInterface({
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
@@ -242,7 +234,6 @@ function ChatInterface({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
@@ -289,8 +280,6 @@ function ChatInterface({
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude');
|
||||
|
||||
return (
|
||||
@@ -329,10 +318,6 @@ function ChatInterface({
|
||||
setCodexModel={setCodexModel}
|
||||
geminiModel={geminiModel}
|
||||
setGeminiModel={setGeminiModel}
|
||||
opencodeModel={opencodeModel}
|
||||
setOpenCodeModel={setOpenCodeModel}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelsLoading={providerModelsLoading}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
@@ -421,8 +406,6 @@ function ChatInterface({
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude'),
|
||||
})}
|
||||
isTextareaExpanded={isTextareaExpanded}
|
||||
@@ -431,17 +414,6 @@ function ChatInterface({
|
||||
</div>
|
||||
|
||||
<QuickSettingsPanel />
|
||||
|
||||
<CommandResultModal
|
||||
payload={commandModalPayload}
|
||||
onClose={closeCommandModal}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelCacheCatalog={providerModelCacheCatalog}
|
||||
providerModelsRefreshing={providerModelsRefreshing}
|
||||
onHardRefreshProviderModels={hardRefreshProviderModels}
|
||||
currentSessionId={currentSessionId || selectedSession?.id || null}
|
||||
onSelectProviderModel={selectProviderModel}
|
||||
/>
|
||||
</PermissionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||
|
||||
import type { ChatMessage } from '../../types/types';
|
||||
import type {
|
||||
Project,
|
||||
ProjectSession,
|
||||
LLMProvider,
|
||||
ProviderModelsDefinition,
|
||||
} from '../../../../types/app';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||
|
||||
import MessageComponent from './MessageComponent';
|
||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||
|
||||
@@ -33,10 +26,6 @@ interface ChatMessagesPaneProps {
|
||||
setCodexModel: (model: string) => void;
|
||||
geminiModel: string;
|
||||
setGeminiModel: (model: string) => void;
|
||||
opencodeModel: string;
|
||||
setOpenCodeModel: (model: string) => void;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelsLoading: boolean;
|
||||
tasksEnabled: boolean;
|
||||
isTaskMasterInstalled: boolean | null;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
@@ -82,10 +71,6 @@ export default function ChatMessagesPane({
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
providerModelCatalog,
|
||||
providerModelsLoading,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
@@ -169,10 +154,6 @@ export default function ChatMessagesPane({
|
||||
setCodexModel={setCodexModel}
|
||||
geminiModel={geminiModel}
|
||||
setGeminiModel={setGeminiModel}
|
||||
opencodeModel={opencodeModel}
|
||||
setOpenCodeModel={setOpenCodeModel}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelsLoading={providerModelsLoading}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
|
||||
@@ -29,7 +29,6 @@ const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
||||
codex: 'messageTypes.codex',
|
||||
cursor: 'messageTypes.cursor',
|
||||
gemini: 'messageTypes.gemini',
|
||||
opencode: 'messageTypes.opencode',
|
||||
};
|
||||
|
||||
function formatElapsedTime(totalSeconds: number) {
|
||||
|
||||
@@ -1,731 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
BadgeCheck,
|
||||
Check,
|
||||
CircleHelp,
|
||||
Clipboard,
|
||||
Coins,
|
||||
Command as CommandIcon,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Package,
|
||||
Search,
|
||||
Server,
|
||||
Sparkles,
|
||||
TerminalSquare,
|
||||
Timer,
|
||||
RefreshCw,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui';
|
||||
import type { LLMProvider, ProviderModelsCacheInfo, ProviderModelsDefinition } from '../../../../types/app';
|
||||
import type {
|
||||
CommandModalPayload,
|
||||
CostCommandData,
|
||||
HelpCommandData,
|
||||
ModelCommandData,
|
||||
StatusCommandData,
|
||||
} from '../../hooks/useChatComposerState';
|
||||
|
||||
type CommandResultModalProps = {
|
||||
payload: CommandModalPayload | null;
|
||||
onClose: () => void;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
|
||||
providerModelsRefreshing: boolean;
|
||||
onHardRefreshProviderModels: () => void;
|
||||
currentSessionId: string | null;
|
||||
onSelectProviderModel: (
|
||||
provider: LLMProvider,
|
||||
model: string,
|
||||
sessionId?: string | null,
|
||||
) => Promise<{
|
||||
scope: 'default' | 'session';
|
||||
changed: boolean;
|
||||
model: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CommandEntry = {
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
type ModelOption = {
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const formatUpdatedAt = (value?: string) => {
|
||||
if (!value) {
|
||||
return 'Not cached yet';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return 'Not cached yet';
|
||||
}
|
||||
|
||||
return parsed.toLocaleString();
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
|
||||
const FALLBACK_COMMANDS: CommandEntry[] = [
|
||||
{ name: '/models', description: 'Browse available models for the active provider.' },
|
||||
{ name: '/cost', description: 'Review context usage and estimated token spend.' },
|
||||
{ name: '/status', description: 'Inspect runtime, version, provider, and environment status.' },
|
||||
{ name: '/memory', description: 'Open the project CLAUDE.md memory file.' },
|
||||
{ name: '/config', description: 'Open settings and configuration.' },
|
||||
{ name: '/help', description: 'Show command documentation and syntax.' },
|
||||
];
|
||||
|
||||
const getProviderLabel = (provider: string | undefined, fallback = 'Unknown') => {
|
||||
if (!provider) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return PROVIDER_LABELS[provider] || provider;
|
||||
};
|
||||
|
||||
const clampPercentage = (value: number) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(100, value));
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '0';
|
||||
}
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number | string | undefined) => {
|
||||
const numeric = Number(value ?? 0);
|
||||
return `$${Number.isFinite(numeric) ? numeric.toFixed(4) : '0.0000'}`;
|
||||
};
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
tone = 'neutral',
|
||||
compact = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: typeof Activity;
|
||||
tone?: 'neutral' | 'primary' | 'success';
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'primary'
|
||||
? 'border-primary/35 bg-primary/10 text-primary'
|
||||
: tone === 'success'
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
|
||||
: 'border-border/70 bg-background/75 text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group rounded-2xl border border-border/70 bg-background/75 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-md ${
|
||||
compact ? 'p-3' : 'p-4'
|
||||
}`}
|
||||
>
|
||||
<div className={`inline-flex rounded-xl border ${compact ? 'mb-2 p-1.5' : 'mb-3 p-2'} ${toneClass}`}>
|
||||
<Icon className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
|
||||
</div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">{label}</p>
|
||||
<p className={`${compact ? 'mt-0.5 text-[13px]' : 'mt-1 text-sm'} break-all font-semibold text-foreground`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchField({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-10 rounded-xl border-border/70 bg-background/75 pl-9 pr-3 shadow-none focus-visible:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpContent({ data }: { data: HelpCommandData }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const commands = (Array.isArray(data.commands) && data.commands.length > 0
|
||||
? data.commands
|
||||
: FALLBACK_COMMANDS) as CommandEntry[];
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return commands;
|
||||
}
|
||||
|
||||
return commands.filter((command) => {
|
||||
const haystack = `${command.name} ${command.description || ''} ${command.namespace || ''}`.toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
});
|
||||
}, [commands, query]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 gap-4 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<SearchField value={query} onChange={setQuery} placeholder="Filter commands..." />
|
||||
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{filteredCommands.map((command, index) => (
|
||||
<div
|
||||
key={`${command.namespace || 'builtin'}-${command.name}`}
|
||||
className="settings-content-enter rounded-2xl border border-border/70 bg-background/75 p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/30 hover:bg-muted/25"
|
||||
style={{ animationDelay: `${Math.min(index * 18, 160)}ms` }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<code className="rounded-lg border border-primary/20 bg-primary/10 px-2 py-1 text-xs font-semibold text-primary">
|
||||
{command.name}
|
||||
</code>
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px] capitalize">
|
||||
{command.namespace || 'builtin'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-5 text-muted-foreground">
|
||||
{command.description || 'No description available.'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredCommands.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No commands match that filter.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<TerminalSquare className="h-4 w-4 text-primary" />
|
||||
Syntax
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p><code className="text-foreground">/command arg1 arg2</code></p>
|
||||
<p><code className="text-foreground">$ARGUMENTS</code> passes all args.</p>
|
||||
<p><code className="text-foreground">$1</code>, <code className="text-foreground">$2</code> pass positional args.</p>
|
||||
<p><code className="text-foreground">@file</code> includes file contents.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-primary/25 bg-primary/10 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
Quick tip
|
||||
</div>
|
||||
<p className="text-sm leading-5 text-muted-foreground">
|
||||
Type <code className="text-foreground">/</code> in the composer to open the command palette, then use arrows and Enter to run a command.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelsContent({
|
||||
data,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsRefreshing,
|
||||
onHardRefreshProviderModels,
|
||||
currentSessionId,
|
||||
onSelectProviderModel,
|
||||
}: {
|
||||
data: ModelCommandData;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
|
||||
providerModelsRefreshing: boolean;
|
||||
onHardRefreshProviderModels: () => void;
|
||||
currentSessionId: string | null;
|
||||
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [copiedModel, setCopiedModel] = useState<string | null>(null);
|
||||
const [changingModel, setChangingModel] = useState<string | null>(null);
|
||||
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
|
||||
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
|
||||
const currentProvider = (data?.current?.provider || 'claude') as LLMProvider;
|
||||
const currentModel = data?.current?.model || 'Unknown';
|
||||
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
|
||||
const liveDefinition = providerModelCatalog[currentProvider];
|
||||
const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache;
|
||||
const availableOptions = useMemo<ModelOption[]>(() => {
|
||||
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
|
||||
return liveDefinition.OPTIONS;
|
||||
}
|
||||
|
||||
if (Array.isArray(data?.availableOptions) && data.availableOptions.length > 0) {
|
||||
return data.availableOptions;
|
||||
}
|
||||
|
||||
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
|
||||
return availableModels.map((model) => ({ value: model, label: model }));
|
||||
}, [data, liveDefinition]);
|
||||
const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel;
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return availableOptions;
|
||||
}
|
||||
|
||||
return availableOptions.filter((option) => {
|
||||
const haystack = `${option.value} ${option.label || ''} ${option.description || ''}`.toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
});
|
||||
}, [availableOptions, query]);
|
||||
|
||||
const activeOption = availableOptions.find((option) => option.value === currentModel);
|
||||
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
|
||||
|
||||
const copyModel = (model: string) => {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(model).catch(() => undefined);
|
||||
}
|
||||
setCopiedModel(model);
|
||||
window.setTimeout(() => {
|
||||
setCopiedModel((current) => (current === model ? null : current));
|
||||
}, 1300);
|
||||
};
|
||||
|
||||
const handleSelectModel = async (model: string) => {
|
||||
setChangingModel(model);
|
||||
try {
|
||||
const result = await onSelectProviderModel(currentProvider, model, currentSessionId);
|
||||
if (result.scope === 'session') {
|
||||
setPendingSessionModel(result.model);
|
||||
setSelectionNotice(`Next response will resume with ${result.model}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingSessionModel(null);
|
||||
setSelectionNotice(`Default ${providerLabel} model set to ${result.model}.`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to change the model right now.';
|
||||
setSelectionNotice(message);
|
||||
} finally {
|
||||
setChangingModel(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-2.5">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-2.5">
|
||||
<div className="grid gap-2.5 lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.7fr)_minmax(15rem,0.9fr)] lg:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded-lg border border-primary/20 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
{providerLabel}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
|
||||
{availableOptions.length} models
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</p>
|
||||
<p className="mt-1 break-all font-mono text-[0.98rem] font-semibold leading-5 text-foreground sm:text-[1.05rem]">
|
||||
{currentModel}
|
||||
</p>
|
||||
{activeOption?.label && activeOption.label !== currentModel && (
|
||||
<p className="mt-1 text-[11px] font-medium text-foreground/85">{activeOption.label}</p>
|
||||
)}
|
||||
{activeOption?.description && (
|
||||
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">{activeOption.description}</p>
|
||||
)}
|
||||
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
||||
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Next response: {pendingSessionModel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
|
||||
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
|
||||
Catalog Refresh
|
||||
</p>
|
||||
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
|
||||
All providers
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
|
||||
or when a new model is missing.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onHardRefreshProviderModels}
|
||||
disabled={providerModelsRefreshing}
|
||||
className="mt-2 h-8 w-full rounded-xl px-3"
|
||||
>
|
||||
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
|
||||
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
|
||||
{hasConcreteSessionId
|
||||
? 'Selecting a model stores a session override and applies it on the next response for this session.'
|
||||
: 'Selecting a model updates the default model used for new turns in this provider.'}
|
||||
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-3xl border border-border/70 bg-muted/15 p-3 sm:p-4">
|
||||
<div className="mb-2.5 grid gap-2 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
||||
</div>
|
||||
<Badge variant="secondary" className="h-9 justify-center rounded-xl px-3 font-mono text-xs">
|
||||
{filteredOptions.length} shown
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{filteredOptions.length > 0 ? (
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{filteredOptions.map((option, index) => {
|
||||
const isCurrent = option.value === currentModel;
|
||||
const wasCopied = copiedModel === option.value;
|
||||
const isPendingSelection = option.value === pendingSessionModel;
|
||||
const isChanging = option.value === changingModel;
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`settings-content-enter group flex min-h-[4.5rem] items-start gap-3 rounded-2xl border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${
|
||||
isCurrent
|
||||
? 'border-primary/45 bg-primary/10'
|
||||
: isPendingSelection
|
||||
? 'border-emerald-500/35 bg-emerald-500/10'
|
||||
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
||||
}`}
|
||||
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectModel(option.value)}
|
||||
disabled={Boolean(changingModel)}
|
||||
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Use model ${option.value}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
||||
{isCurrent && <BadgeCheck className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</span>
|
||||
{option.label && option.label !== option.value && (
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
|
||||
)}
|
||||
{option.description && (
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>
|
||||
)}
|
||||
{isCurrent && <span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>}
|
||||
{isPendingSelection && !isCurrent && (
|
||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-400">
|
||||
Next response selection
|
||||
</span>
|
||||
)}
|
||||
{isChanging && (
|
||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Applying...
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyModel(option.value)}
|
||||
className="rounded-lg border border-border/70 bg-muted/30 p-2 text-muted-foreground transition-colors group-hover:text-primary"
|
||||
aria-label={`Copy model id ${option.value}`}
|
||||
>
|
||||
{wasCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No models match that search.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CostContent({ data }: { data: CostCommandData }) {
|
||||
const used = Number(data.tokenUsage?.used ?? 0);
|
||||
const total = Number(data.tokenUsage?.total ?? 0);
|
||||
const percentage = clampPercentage(Number(data.tokenUsage?.percentage ?? 0));
|
||||
const model = data.model || 'Unknown';
|
||||
const provider = getProviderLabel(data.provider, data.provider || 'Unknown');
|
||||
const inputTokens = Number(data.tokenBreakdown?.input ?? 0);
|
||||
const outputTokens = Number(data.tokenBreakdown?.output ?? 0);
|
||||
const cacheTokens = Number(data.tokenBreakdown?.cache ?? 0);
|
||||
const totalCost = Number(data.cost?.total ?? 0);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[18rem_1fr]">
|
||||
<div className="rounded-3xl border border-primary/25 bg-primary/10 p-5 text-center">
|
||||
<div
|
||||
className="mx-auto grid h-40 w-40 place-items-center rounded-full p-2 shadow-inner"
|
||||
style={{
|
||||
background: `conic-gradient(hsl(var(--primary)) ${percentage * 3.6}deg, hsl(var(--muted)) 0deg)`,
|
||||
}}
|
||||
>
|
||||
<div className="grid h-full w-full place-items-center rounded-full border border-border/70 bg-popover">
|
||||
<div>
|
||||
<p className="font-mono text-3xl font-semibold text-foreground">{percentage.toFixed(1)}%</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">context</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{formatNumber(used)} of {formatNumber(total)} tokens used
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MetricCard label="Input" value={formatCurrency(data.cost?.input)} icon={Zap} />
|
||||
<MetricCard label="Output" value={formatCurrency(data.cost?.output)} icon={Activity} />
|
||||
<MetricCard label="Total" value={formatCurrency(totalCost)} icon={Coins} tone="primary" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MetricCard label="Input tokens" value={formatNumber(inputTokens)} icon={CommandIcon} />
|
||||
<MetricCard label="Output tokens" value={formatNumber(outputTokens)} icon={TerminalSquare} />
|
||||
<MetricCard label="Cache tokens" value={formatNumber(cacheTokens)} icon={Package} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Provider</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{provider}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Model</p>
|
||||
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
Cost is an estimate based on the available token counters and default provider rates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusContent({ data }: { data: StatusCommandData }) {
|
||||
const memoryRssMb = data.memoryUsage?.rssMb;
|
||||
const rows = [
|
||||
{ label: 'Package', value: data.packageName || 'claude-code-ui', icon: Package },
|
||||
{ label: 'Version', value: data.version || 'Unknown', icon: BadgeCheck, tone: 'success' as const },
|
||||
{ label: 'Uptime', value: data.uptime || 'Unknown', icon: Timer },
|
||||
{ label: 'Provider', value: getProviderLabel(data.provider, data.provider || 'Unknown'), icon: Server, tone: 'primary' as const },
|
||||
{ label: 'Model', value: data.model || 'Unknown', icon: Cpu },
|
||||
{ label: 'Node.js', value: data.nodeVersion || 'Unknown', icon: TerminalSquare },
|
||||
{ label: 'Platform', value: data.platform || 'Unknown', icon: Activity },
|
||||
{ label: 'Memory', value: typeof memoryRssMb === 'number' ? `${memoryRssMb} MB RSS` : 'Unknown', icon: Gauge },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-3xl border border-emerald-500/25 bg-emerald-500/10 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Runtime online</p>
|
||||
<p className="text-xs text-muted-foreground">Process {data.pid ? `#${data.pid}` : 'status'} is responding.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="rounded-full bg-emerald-500 text-white hover:bg-emerald-500">Healthy</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{rows.map((row) => (
|
||||
<MetricCard key={row.label} label={row.label} value={String(row.value)} icon={row.icon} tone={row.tone} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CommandResultModal({
|
||||
payload,
|
||||
onClose,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsRefreshing,
|
||||
onHardRefreshProviderModels,
|
||||
currentSessionId,
|
||||
onSelectProviderModel,
|
||||
}: CommandResultModalProps) {
|
||||
const isOpen = Boolean(payload);
|
||||
const kind = payload?.kind;
|
||||
const isModelsModal = kind === 'models';
|
||||
|
||||
const modalMeta = {
|
||||
help: {
|
||||
eyebrow: 'Command center',
|
||||
title: 'Help & Shortcuts',
|
||||
subtitle: 'Search built-ins, syntax patterns, and command usage without leaving the chat.',
|
||||
icon: CircleHelp,
|
||||
},
|
||||
models: {
|
||||
eyebrow: 'Model inventory',
|
||||
title: 'Available Models',
|
||||
subtitle: 'Browse, search, and copy model IDs for the active provider.',
|
||||
icon: Cpu,
|
||||
},
|
||||
cost: {
|
||||
eyebrow: 'Session telemetry',
|
||||
title: 'Usage & Cost',
|
||||
subtitle: 'Token budget, context pressure, and estimated spend for this session.',
|
||||
icon: Coins,
|
||||
},
|
||||
status: {
|
||||
eyebrow: 'Runtime health',
|
||||
title: 'System Status',
|
||||
subtitle: 'Version, provider, runtime, and environment details in one place.',
|
||||
icon: Activity,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const activeMeta = kind ? modalMeta[kind] : null;
|
||||
const HeaderIcon = activeMeta?.icon || Sparkles;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="flex h-[min(92dvh,48rem)] w-[calc(100vw-1rem)] max-w-5xl flex-col overflow-hidden rounded-3xl border-border/80 bg-popover/95 p-0 shadow-2xl backdrop-blur-xl sm:w-[min(94vw,64rem)]">
|
||||
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
|
||||
|
||||
<div
|
||||
className={`relative shrink-0 overflow-hidden border-b border-border/70 bg-gradient-to-br from-primary/15 via-background to-muted/40 ${
|
||||
isModelsModal ? 'px-4 pb-3 pt-3 sm:px-5 sm:pb-4 sm:pt-4' : 'px-4 pb-4 pt-4 sm:px-6 sm:pb-5 sm:pt-5'
|
||||
}`}
|
||||
>
|
||||
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-primary/20 blur-3xl" />
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-1/2 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.16),transparent_58%)]" />
|
||||
|
||||
<div className="relative flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3 sm:items-center">
|
||||
<div
|
||||
className={`rounded-2xl border border-primary/30 bg-primary/10 text-primary shadow-sm ${
|
||||
isModelsModal ? 'p-2.5' : 'p-3'
|
||||
}`}
|
||||
>
|
||||
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-bold uppercase tracking-[0.22em] text-primary">
|
||||
{activeMeta?.eyebrow}
|
||||
</p>
|
||||
<p className={`mt-1 font-semibold tracking-tight text-foreground ${isModelsModal ? 'text-xl sm:text-2xl' : 'text-xl sm:text-2xl'}`}>
|
||||
{activeMeta?.title}
|
||||
</p>
|
||||
<p className={`mt-1 max-w-2xl ${isModelsModal ? 'text-sm leading-5 text-foreground/75' : 'text-sm leading-5 text-muted-foreground'}`}>
|
||||
{activeMeta?.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:bg-background/70 hover:text-foreground"
|
||||
aria-label="Close command result modal"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
|
||||
{payload?.kind === 'help' && <HelpContent data={payload.data as HelpCommandData} />}
|
||||
{payload?.kind === 'models' && (
|
||||
<ModelsContent
|
||||
data={payload.data as ModelCommandData}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelCacheCatalog={providerModelCacheCatalog}
|
||||
providerModelsRefreshing={providerModelsRefreshing}
|
||||
onHardRefreshProviderModels={onHardRefreshProviderModels}
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectProviderModel={onSelectProviderModel}
|
||||
/>
|
||||
)}
|
||||
{payload?.kind === 'cost' && <CostContent data={payload.data as CostCommandData} />}
|
||||
{payload?.kind === 'status' && <StatusContent data={payload.data as StatusCommandData} />}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-3 border-t border-border/70 bg-muted/20 px-4 py-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="h-3.5 w-3.5" />
|
||||
<span>Esc closes the modal.</span>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose} className="rounded-xl">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -176,19 +176,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{message.type === 'error'
|
||||
? t('messageTypes.error')
|
||||
: message.type === 'tool'
|
||||
? t('messageTypes.tool')
|
||||
: (provider === 'cursor'
|
||||
? t('messageTypes.cursor')
|
||||
: provider === 'codex'
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude'))}
|
||||
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,12 +3,15 @@ import { Check, ChevronDown } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
|
||||
import type {
|
||||
ProjectSession,
|
||||
LLMProvider,
|
||||
ProviderModelsDefinition,
|
||||
} from "../../../../types/app";
|
||||
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
CURSOR_MODELS,
|
||||
CODEX_MODELS,
|
||||
GEMINI_MODELS,
|
||||
PROVIDERS,
|
||||
} from "../../../../../shared/modelConstants";
|
||||
import type { ProjectSession, LLMProvider } from "../../../../types/app";
|
||||
import { NextTaskBanner } from "../../../task-master";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,14 +27,6 @@ import {
|
||||
Card,
|
||||
} from "../../../../shared/view/ui";
|
||||
|
||||
const PROVIDER_META: { id: LLMProvider; name: string }[] = [
|
||||
{ id: "claude", name: "Anthropic" },
|
||||
{ id: "codex", name: "OpenAI" },
|
||||
{ id: "gemini", name: "Google" },
|
||||
{ id: "cursor", name: "Cursor" },
|
||||
{ id: "opencode", name: "OpenCode" },
|
||||
];
|
||||
|
||||
const MOD_KEY =
|
||||
typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl";
|
||||
|
||||
@@ -49,10 +44,6 @@ type ProviderSelectionEmptyStateProps = {
|
||||
setCodexModel: (model: string) => void;
|
||||
geminiModel: string;
|
||||
setGeminiModel: (model: string) => void;
|
||||
opencodeModel: string;
|
||||
setOpenCodeModel: (model: string) => void;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelsLoading: boolean;
|
||||
tasksEnabled: boolean;
|
||||
isTaskMasterInstalled: boolean | null;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
@@ -62,15 +53,20 @@ type ProviderSelectionEmptyStateProps = {
|
||||
type ProviderGroup = {
|
||||
id: LLMProvider;
|
||||
name: string;
|
||||
models: { value: string; label: string; description?: string }[];
|
||||
models: { value: string; label: string }[];
|
||||
};
|
||||
|
||||
function getModelConfig(
|
||||
p: LLMProvider,
|
||||
catalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>,
|
||||
): ProviderModelsDefinition {
|
||||
const entry = catalog[p];
|
||||
return entry ?? { OPTIONS: [], DEFAULT: "" };
|
||||
const PROVIDER_GROUPS: ProviderGroup[] = PROVIDERS.map((p) => ({
|
||||
id: p.id as LLMProvider,
|
||||
name: p.name,
|
||||
models: p.models.OPTIONS,
|
||||
}));
|
||||
|
||||
function getModelConfig(p: LLMProvider) {
|
||||
if (p === "claude") return CLAUDE_MODELS;
|
||||
if (p === "codex") return CODEX_MODELS;
|
||||
if (p === "gemini") return GEMINI_MODELS;
|
||||
return CURSOR_MODELS;
|
||||
}
|
||||
|
||||
function getCurrentModel(
|
||||
@@ -79,12 +75,10 @@ function getCurrentModel(
|
||||
cu: string,
|
||||
co: string,
|
||||
g: string,
|
||||
o: string,
|
||||
) {
|
||||
if (p === "claude") return c;
|
||||
if (p === "codex") return co;
|
||||
if (p === "gemini") return g;
|
||||
if (p === "opencode") return o;
|
||||
return cu;
|
||||
}
|
||||
|
||||
@@ -92,7 +86,6 @@ function getProviderDisplayName(p: LLMProvider) {
|
||||
if (p === "claude") return "Claude";
|
||||
if (p === "cursor") return "Cursor";
|
||||
if (p === "codex") return "Codex";
|
||||
if (p === "opencode") return "OpenCode";
|
||||
return "Gemini";
|
||||
}
|
||||
|
||||
@@ -110,10 +103,6 @@ export default function ProviderSelectionEmptyState({
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
providerModelCatalog,
|
||||
providerModelsLoading,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
@@ -123,14 +112,10 @@ export default function ProviderSelectionEmptyState({
|
||||
const { isWindowsServer } = useServerPlatform();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const visibleProviderGroups = useMemo(() => {
|
||||
const groups: ProviderGroup[] = PROVIDER_META.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
models: providerModelCatalog[p.id]?.OPTIONS ?? [],
|
||||
}));
|
||||
return isWindowsServer ? groups.filter((p) => p.id !== "cursor") : groups;
|
||||
}, [isWindowsServer, providerModelCatalog]);
|
||||
const visibleProviderGroups = useMemo(
|
||||
() => (isWindowsServer ? PROVIDER_GROUPS.filter((p) => p.id !== "cursor") : PROVIDER_GROUPS),
|
||||
[isWindowsServer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWindowsServer && provider === "cursor") {
|
||||
@@ -149,16 +134,15 @@ export default function ProviderSelectionEmptyState({
|
||||
cursorModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
);
|
||||
|
||||
const currentModelLabel = useMemo(() => {
|
||||
const config = getModelConfig(provider, providerModelCatalog);
|
||||
const config = getModelConfig(provider);
|
||||
const found = config.OPTIONS.find(
|
||||
(o: { value: string; label: string }) => o.value === currentModel,
|
||||
);
|
||||
return found?.label || currentModel;
|
||||
}, [provider, currentModel, providerModelCatalog]);
|
||||
}, [provider, currentModel]);
|
||||
|
||||
const setModelForProvider = useCallback(
|
||||
(providerId: LLMProvider, modelValue: string) => {
|
||||
@@ -171,15 +155,12 @@ export default function ProviderSelectionEmptyState({
|
||||
} else if (providerId === "gemini") {
|
||||
setGeminiModel(modelValue);
|
||||
localStorage.setItem("gemini-model", modelValue);
|
||||
} else if (providerId === "opencode") {
|
||||
setOpenCodeModel(modelValue);
|
||||
localStorage.setItem("opencode-model", modelValue);
|
||||
} else {
|
||||
setCursorModel(modelValue);
|
||||
localStorage.setItem("cursor-model", modelValue);
|
||||
}
|
||||
},
|
||||
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
|
||||
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
@@ -241,9 +222,6 @@ export default function ProviderSelectionEmptyState({
|
||||
|
||||
<DialogContent className="max-w-md overflow-hidden p-0">
|
||||
<DialogTitle>Model Selector</DialogTitle>
|
||||
<div className="border-b border-border/60 bg-muted/20 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-foreground">Choose a model</p>
|
||||
</div>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("providerSelection.searchModels", {
|
||||
@@ -271,28 +249,16 @@ export default function ProviderSelectionEmptyState({
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{group.models.length === 0 && providerModelsLoading ? (
|
||||
<CommandItem disabled className="ml-4 border-l border-border/40 pl-4 text-muted-foreground">
|
||||
{t("providerSelection.loadingModels", { defaultValue: "Loading models…" })}
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{group.models.map((model) => {
|
||||
const isSelected = provider === group.id && currentModel === model.value;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${group.id}-${model.value}`}
|
||||
value={`${group.name} ${model.label} ${model.description || ''}`}
|
||||
value={`${group.name} ${model.label}`}
|
||||
onSelect={() => handleModelSelect(group.id, model.value)}
|
||||
className="ml-4 border-l border-border/40 pl-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{model.label}</div>
|
||||
{model.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{model.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-1 truncate">{model.label}</span>
|
||||
{isSelected && (
|
||||
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
@@ -321,10 +287,6 @@ export default function ProviderSelectionEmptyState({
|
||||
gemini: t("providerSelection.readyPrompt.gemini", {
|
||||
model: geminiModel,
|
||||
}),
|
||||
opencode: t("providerSelection.readyPrompt.opencode", {
|
||||
model: opencodeModel,
|
||||
defaultValue: "Ready with OpenCode {{model}}",
|
||||
}),
|
||||
}[provider]
|
||||
}
|
||||
</p>
|
||||
|
||||
@@ -14,7 +14,6 @@ interface SessionsResponse {
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
geminiSessions?: ProjectSession[];
|
||||
opencodeSessions?: ProjectSession[];
|
||||
}
|
||||
|
||||
export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
|
||||
@@ -34,7 +33,6 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea
|
||||
...(data.cursorSessions ?? []),
|
||||
...(data.codexSessions ?? []),
|
||||
...(data.geminiSessions ?? []),
|
||||
...(data.opencodeSessions ?? []),
|
||||
];
|
||||
return all.map<SessionResult>((s) => ({
|
||||
id: s.id,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
type OpenCodeLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const OpenCodeLogo = ({ className = 'w-5 h-5' }: OpenCodeLogoProps) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="OpenCode"
|
||||
className={className}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="2.5" y="2.5" width="19" height="19" rx="4" className="fill-foreground" />
|
||||
<path
|
||||
d="M8.1 8.1 4.9 12l3.2 3.9M15.9 8.1l3.2 3.9-3.2 3.9M13.2 6.9l-2.4 10.2"
|
||||
className="stroke-background"
|
||||
strokeWidth="1.9"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default OpenCodeLogo;
|
||||
@@ -3,7 +3,6 @@ import ClaudeLogo from './ClaudeLogo';
|
||||
import CodexLogo from './CodexLogo';
|
||||
import CursorLogo from './CursorLogo';
|
||||
import GeminiLogo from './GeminiLogo';
|
||||
import OpenCodeLogo from './OpenCodeLogo';
|
||||
|
||||
type SessionProviderLogoProps = {
|
||||
provider?: LLMProvider | string | null;
|
||||
@@ -26,9 +25,5 @@ export default function SessionProviderLogo({
|
||||
return <GeminiLogo className={className} />;
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
return <OpenCodeLogo className={className} />;
|
||||
}
|
||||
|
||||
return <ClaudeLogo className={className} />;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
|
||||
@@ -13,7 +12,6 @@ export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
|
||||
cursor: ['user', 'project'],
|
||||
codex: ['user', 'project'],
|
||||
gemini: ['user', 'project'],
|
||||
opencode: ['user', 'project'],
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
|
||||
@@ -21,7 +19,6 @@ export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
|
||||
cursor: ['stdio', 'http'],
|
||||
codex: ['stdio', 'http'],
|
||||
gemini: ['stdio', 'http', 'sse'],
|
||||
opencode: ['stdio', 'http'],
|
||||
};
|
||||
|
||||
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
|
||||
@@ -33,7 +30,6 @@ export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
|
||||
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
|
||||
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
||||
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||
@@ -41,7 +37,6 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||
cursor: false,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_MCP_FORM: McpFormState = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LLMProvider } from '../../../../types/app';
|
||||
import type { ProviderAuthStatusMap } from '../../../provider-auth/types';
|
||||
|
||||
import AgentConnectionCard from './AgentConnectionCard';
|
||||
|
||||
type AgentConnectionsStepProps = {
|
||||
@@ -37,13 +36,6 @@ const providerCards = [
|
||||
iconContainerClassName: 'bg-teal-100 dark:bg-teal-900/30',
|
||||
loginButtonClassName: 'bg-teal-600 hover:bg-teal-700',
|
||||
},
|
||||
{
|
||||
provider: 'opencode' as const,
|
||||
title: 'OpenCode',
|
||||
connectedClassName: 'bg-zinc-100 dark:bg-zinc-800/50 border-zinc-300 dark:border-zinc-600',
|
||||
iconContainerClassName: 'bg-zinc-100 dark:bg-zinc-800',
|
||||
loginButtonClassName: 'bg-zinc-800 hover:bg-zinc-900 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AgentConnectionsStep({
|
||||
|
||||
@@ -10,14 +10,13 @@ export type ProviderAuthStatus = {
|
||||
|
||||
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
|
||||
|
||||
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
|
||||
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
|
||||
claude: '/api/providers/claude/auth/status',
|
||||
cursor: '/api/providers/cursor/auth/status',
|
||||
codex: '/api/providers/codex/auth/status',
|
||||
gemini: '/api/providers/gemini/auth/status',
|
||||
opencode: '/api/providers/opencode/auth/status',
|
||||
};
|
||||
|
||||
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
|
||||
@@ -25,5 +24,4 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth
|
||||
cursor: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
codex: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
gemini: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
opencode: { authenticated: false, email: null, method: null, error: null, loading },
|
||||
});
|
||||
|
||||
@@ -37,10 +37,6 @@ const getProviderCommand = ({
|
||||
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
return 'opencode auth login';
|
||||
}
|
||||
|
||||
return 'gemini status';
|
||||
};
|
||||
|
||||
@@ -48,7 +44,6 @@ const getProviderTitle = (provider: LLMProvider) => {
|
||||
if (provider === 'claude') return 'Claude CLI Login';
|
||||
if (provider === 'cursor') return 'Cursor CLI Login';
|
||||
if (provider === 'codex') return 'Codex CLI Login';
|
||||
if (provider === 'opencode') return 'OpenCode CLI Login';
|
||||
return 'Gemini CLI Configuration';
|
||||
};
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
|
||||
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
|
||||
];
|
||||
|
||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
|
||||
|
||||
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
||||
|
||||
@@ -12,7 +12,7 @@ type AgentListItemProps = {
|
||||
|
||||
type AgentConfig = {
|
||||
name: string;
|
||||
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc';
|
||||
color: 'blue' | 'purple' | 'gray' | 'indigo';
|
||||
};
|
||||
|
||||
const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||
@@ -31,11 +31,7 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||
gemini: {
|
||||
name: 'Gemini',
|
||||
color: 'indigo',
|
||||
},
|
||||
opencode: {
|
||||
name: 'OpenCode',
|
||||
color: 'zinc',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
@@ -51,9 +47,6 @@ const colorClasses = {
|
||||
indigo: {
|
||||
dot: 'bg-indigo-500',
|
||||
},
|
||||
zinc: {
|
||||
dot: 'bg-zinc-500',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default function AgentListItem({
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function AgentsSettingsTab({
|
||||
const { isWindowsServer } = useServerPlatform();
|
||||
|
||||
const visibleAgents = useMemo<AgentProvider[]>(() => {
|
||||
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
if (isWindowsServer) {
|
||||
return all.filter((id) => id !== 'cursor');
|
||||
}
|
||||
@@ -57,17 +57,12 @@ export default function AgentsSettingsTab({
|
||||
authStatus: providerAuthStatus.gemini,
|
||||
onLogin: () => onProviderLogin('gemini'),
|
||||
},
|
||||
opencode: {
|
||||
authStatus: providerAuthStatus.opencode,
|
||||
onLogin: () => onProviderLogin('opencode'),
|
||||
},
|
||||
}), [
|
||||
onProviderLogin,
|
||||
providerAuthStatus.claude,
|
||||
providerAuthStatus.codex,
|
||||
providerAuthStatus.cursor,
|
||||
providerAuthStatus.gemini,
|
||||
providerAuthStatus.opencode,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,6 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
|
||||
export default function AgentSelectorSection({
|
||||
@@ -24,8 +23,7 @@ export default function AgentSelectorSection({
|
||||
const dotColor =
|
||||
agent === 'claude' ? 'bg-blue-500' :
|
||||
agent === 'cursor' ? 'bg-purple-500' :
|
||||
agent === 'gemini' ? 'bg-indigo-500' :
|
||||
agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60';
|
||||
agent === 'gemini' ? 'bg-indigo-500' : 'bg-foreground/60';
|
||||
|
||||
return (
|
||||
<Pill
|
||||
|
||||
@@ -54,15 +54,6 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
||||
subtextClass: 'text-indigo-700 dark:text-indigo-300',
|
||||
buttonClass: 'bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800',
|
||||
},
|
||||
opencode: {
|
||||
name: 'OpenCode',
|
||||
description: 'OpenCode CLI assistant',
|
||||
bgClass: 'bg-zinc-50 dark:bg-zinc-900/20',
|
||||
borderClass: 'border-zinc-200 dark:border-zinc-700',
|
||||
textClass: 'text-zinc-900 dark:text-zinc-100',
|
||||
subtextClass: 'text-zinc-700 dark:text-zinc-300',
|
||||
buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
|
||||
@@ -75,11 +66,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
|
||||
<SessionProviderLogo provider={agent} className="h-6 w-6" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground">{config.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(`agents.account.${agent}.description`, {
|
||||
defaultValue: config.description || `${config.name} CLI assistant`,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t(`agents.account.${agent}.description`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ export type SessionViewModel = {
|
||||
isCursorSession: boolean;
|
||||
isCodexSession: boolean;
|
||||
isGeminiSession: boolean;
|
||||
isOpenCodeSession: boolean;
|
||||
isActive: boolean;
|
||||
sessionName: string;
|
||||
sessionTime: string;
|
||||
|
||||
@@ -85,7 +85,6 @@ export const createSessionViewModel = (
|
||||
isCursorSession: session.__provider === 'cursor',
|
||||
isCodexSession: session.__provider === 'codex',
|
||||
isGeminiSession: session.__provider === 'gemini',
|
||||
isOpenCodeSession: session.__provider === 'opencode',
|
||||
isActive: diffInMinutes < 10,
|
||||
sessionName: getSessionName(session, t),
|
||||
sessionTime: getSessionTime(session),
|
||||
@@ -114,12 +113,7 @@ export const getAllSessions = (project: Project): SessionWithProvider[] => {
|
||||
__provider: 'gemini' as const,
|
||||
}));
|
||||
|
||||
const opencodeSessions = (project.opencodeSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'opencode' as const,
|
||||
}));
|
||||
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...opencodeSessions].sort(
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions].sort(
|
||||
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,8 +61,7 @@ const projectsHaveChanges = (
|
||||
return (
|
||||
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
|
||||
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||
|
||||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) ||
|
||||
serialize(nextProject.opencodeSessions) !== serialize(prevProject.opencodeSessions)
|
||||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions)
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -99,7 +98,6 @@ const getProjectSessions = (project: Project): ProjectSession[] => {
|
||||
...(project.codexSessions ?? []),
|
||||
...(project.cursorSessions ?? []),
|
||||
...(project.geminiSessions ?? []),
|
||||
...(project.opencodeSessions ?? []),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -147,7 +145,6 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
|
||||
cursorSessions: mergeSessionProviderLists(incomingProject.cursorSessions ?? [], previousProject.cursorSessions ?? []),
|
||||
codexSessions: mergeSessionProviderLists(incomingProject.codexSessions ?? [], previousProject.codexSessions ?? []),
|
||||
geminiSessions: mergeSessionProviderLists(incomingProject.geminiSessions ?? [], previousProject.geminiSessions ?? []),
|
||||
opencodeSessions: mergeSessionProviderLists(incomingProject.opencodeSessions ?? [], previousProject.opencodeSessions ?? []),
|
||||
};
|
||||
|
||||
const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount);
|
||||
@@ -163,7 +160,7 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
|
||||
|
||||
const mergeProjectSessionPage = (
|
||||
existingProject: Project,
|
||||
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>,
|
||||
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>,
|
||||
): Project => {
|
||||
const mergedProject: Project = {
|
||||
...existingProject,
|
||||
@@ -171,7 +168,6 @@ const mergeProjectSessionPage = (
|
||||
cursorSessions: mergeSessionProviderLists(existingProject.cursorSessions ?? [], sessionsPage.cursorSessions ?? []),
|
||||
codexSessions: mergeSessionProviderLists(existingProject.codexSessions ?? [], sessionsPage.codexSessions ?? []),
|
||||
geminiSessions: mergeSessionProviderLists(existingProject.geminiSessions ?? [], sessionsPage.geminiSessions ?? []),
|
||||
opencodeSessions: mergeSessionProviderLists(existingProject.opencodeSessions ?? [], sessionsPage.opencodeSessions ?? []),
|
||||
};
|
||||
|
||||
const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0);
|
||||
@@ -559,21 +555,6 @@ export function useProjectsState({
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const opencodeSession = project.opencodeSessions?.find((session) => session.id === sessionId);
|
||||
if (opencodeSession) {
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'opencode';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...opencodeSession, __provider: 'opencode' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Session id is in the URL but not yet present on any project payload (common
|
||||
@@ -602,8 +583,6 @@ export function useProjectsState({
|
||||
? 'codex'
|
||||
: providerFromStorage === 'gemini'
|
||||
? 'gemini'
|
||||
: providerFromStorage === 'opencode'
|
||||
? 'opencode'
|
||||
: 'claude';
|
||||
|
||||
setSelectedSession({
|
||||
@@ -686,14 +665,12 @@ export function useProjectsState({
|
||||
const cursorSessions = project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const codexSessions = project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const geminiSessions = project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const opencodeSessions = project.opencodeSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
|
||||
const removedFromProject = (
|
||||
sessions.length !== (project.sessions?.length ?? 0)
|
||||
|| cursorSessions.length !== (project.cursorSessions?.length ?? 0)
|
||||
|| codexSessions.length !== (project.codexSessions?.length ?? 0)
|
||||
|| geminiSessions.length !== (project.geminiSessions?.length ?? 0)
|
||||
|| opencodeSessions.length !== (project.opencodeSessions?.length ?? 0)
|
||||
);
|
||||
|
||||
if (!removedFromProject) {
|
||||
@@ -706,7 +683,6 @@ export function useProjectsState({
|
||||
cursorSessions,
|
||||
codexSessions,
|
||||
geminiSessions,
|
||||
opencodeSessions,
|
||||
};
|
||||
|
||||
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
|
||||
@@ -800,7 +776,7 @@ export function useProjectsState({
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>;
|
||||
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>;
|
||||
|
||||
let mergedProjectForSelection: Project | null = null;
|
||||
setProjects((previousProjects) =>
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
"claude": "Claude",
|
||||
"cursor": "Cursor",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"tools": {
|
||||
"settings": "Tool Settings",
|
||||
@@ -190,7 +189,6 @@
|
||||
"cursor": "Ready to use Cursor with {{model}}. Start typing your message below.",
|
||||
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
|
||||
"gemini": "Ready to use Gemini with {{model}}. Start typing your message below.",
|
||||
"opencode": "Ready to use OpenCode with {{model}}. Start typing your message below.",
|
||||
"default": "Select a provider above to begin"
|
||||
},
|
||||
"pressToSearch": "Press <kbd>{{shortcut}}</kbd> to search sessions, files, and commits"
|
||||
|
||||
@@ -322,9 +322,6 @@
|
||||
},
|
||||
"gemini": {
|
||||
"description": "Google Gemini AI assistant"
|
||||
},
|
||||
"opencode": {
|
||||
"description": "OpenCode CLI assistant"
|
||||
}
|
||||
},
|
||||
"connectionStatus": "Connection Status",
|
||||
@@ -419,8 +416,7 @@
|
||||
"description": {
|
||||
"claude": "Model Context Protocol servers provide additional tools and data sources to Claude",
|
||||
"cursor": "Model Context Protocol servers provide additional tools and data sources to Cursor",
|
||||
"codex": "Model Context Protocol servers provide additional tools and data sources to Codex",
|
||||
"opencode": "Model Context Protocol servers provide additional tools and data sources to OpenCode"
|
||||
"codex": "Model Context Protocol servers provide additional tools and data sources to Codex"
|
||||
},
|
||||
"addButton": "Add MCP Server",
|
||||
"empty": "No MCP servers configured",
|
||||
|
||||
@@ -1,21 +1,4 @@
|
||||
export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode';
|
||||
|
||||
export type ProviderModelOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type ProviderModelsDefinition = {
|
||||
OPTIONS: ProviderModelOption[];
|
||||
DEFAULT: string;
|
||||
};
|
||||
|
||||
export type ProviderModelsCacheInfo = {
|
||||
updatedAt: string;
|
||||
expiresAt: string;
|
||||
source: 'memory' | 'disk' | 'fresh';
|
||||
};
|
||||
export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
|
||||
|
||||
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
|
||||
|
||||
@@ -63,7 +46,6 @@ export interface Project {
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
geminiSessions?: ProjectSession[];
|
||||
opencodeSessions?: ProjectSession[];
|
||||
sessionMeta?: ProjectSessionMeta;
|
||||
taskmaster?: ProjectTaskmasterInfo;
|
||||
[key: string]: unknown;
|
||||
|
||||
Reference in New Issue
Block a user