mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-30 16:35:34 +08:00
Compare commits
8 Commits
fix/websoc
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d291d3efb | ||
|
|
6bf82a39bb | ||
|
|
3b79aab958 | ||
|
|
997cf9fd1a | ||
|
|
374e9de719 | ||
|
|
10f721cf14 | ||
|
|
631695ef73 | ||
|
|
039696c2de |
@@ -3,6 +3,13 @@
|
||||
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 [`shared/modelConstants.js`](shared/modelConstants.js))
|
||||
- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`public/modelConstants.js`](public/modelConstants.js))
|
||||
|
||||
|
||||
## Schnellstart
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
|
||||
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
|
||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`shared/modelConstants.js`에서 전체 지원 모델 확인)
|
||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`public/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 [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](public/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 (см. [`shared/modelConstants.js`](shared/modelConstants.js) для полного списка поддерживаемых моделей)
|
||||
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`public/modelConstants.js`](public/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 [`shared/modelConstants.js`](shared/modelConstants.js) dosyasına bak)
|
||||
- **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)
|
||||
|
||||
|
||||
## Hızlı Başlangıç
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
|
||||
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
|
||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`shared/modelConstants.js`](shared/modelConstants.js))
|
||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js))
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
@@ -157,7 +157,11 @@ export default tseslint.config(
|
||||
},
|
||||
{
|
||||
type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly
|
||||
pattern: ["server/shared/utils.{js,ts}", "server/shared/claude-cli-path.ts"], // classify the shared utils file so modules can depend on it explicitly
|
||||
pattern: [
|
||||
"server/shared/utils.{js,ts}",
|
||||
"server/shared/frontmatter.ts",
|
||||
"server/shared/claude-cli-path.ts",
|
||||
], // classify shared utility files so modules can depend on them explicitly
|
||||
mode: "file",
|
||||
},
|
||||
{
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.31.5",
|
||||
"version": "1.32.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.31.5",
|
||||
"version": "1.32.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.31.5",
|
||||
"version": "1.32.0",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "dist-server/server/index.js",
|
||||
@@ -10,6 +10,8 @@
|
||||
"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 { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '/shared/modelConstants.js';
|
||||
import { PROVIDERS } from './modelConstants.js';
|
||||
|
||||
// Dynamic URL replacement
|
||||
const apiUrl = window.location.origin;
|
||||
@@ -834,15 +834,14 @@ data: {"type":"done"}</code></pre>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const modelCell = document.getElementById('model-options-cell');
|
||||
if (modelCell) {
|
||||
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(', ');
|
||||
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>');
|
||||
|
||||
modelCell.innerHTML = `
|
||||
Model identifier for the AI provider:<br><br>
|
||||
<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>)
|
||||
${providerModels}
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
841
public/modelConstants.js
Normal file
841
public/modelConstants.js
Normal file
@@ -0,0 +1,841 @@
|
||||
/**
|
||||
* 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 [`shared/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/shared/modelConstants.js) for the full list of supported models)
|
||||
- **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)
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -17,7 +17,8 @@ import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
||||
import {
|
||||
createNotificationEvent,
|
||||
@@ -204,7 +205,7 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
|
||||
// Map model (default to sonnet)
|
||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
||||
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
||||
// Model logged at query start below
|
||||
|
||||
// Map system prompt configuration
|
||||
@@ -491,8 +492,17 @@ 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);
|
||||
const sdkOptions = mapCliOptionsToSDK({
|
||||
...options,
|
||||
model: resolvedModel || options.model,
|
||||
});
|
||||
|
||||
// Load MCP configuration
|
||||
const mcpServers = await loadMcpConfig(options.cwd);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -28,6 +29,7 @@ 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;
|
||||
@@ -52,9 +54,10 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Provide a prompt (works for both new and resumed sessions)
|
||||
baseArgs.push('-p', command);
|
||||
|
||||
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
||||
if (!sessionId && model) {
|
||||
baseArgs.push('--model', model);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Request streaming JSON when we are providing a prompt
|
||||
|
||||
@@ -9,6 +9,7 @@ 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)
|
||||
@@ -120,6 +121,11 @@ 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
|
||||
@@ -244,7 +250,7 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
}
|
||||
|
||||
// Add model for all sessions (both new and resumed)
|
||||
let modelToUse = options.model || 'gemini-2.5-flash';
|
||||
let modelToUse = resolvedModel || 'gemini-2.5-flash';
|
||||
args.push('--model', modelToUse);
|
||||
args.push('--output-format', 'stream-json');
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ import {
|
||||
isGeminiSessionActive,
|
||||
getActiveGeminiSessions,
|
||||
} from './gemini-cli.js';
|
||||
import {
|
||||
spawnOpenCode,
|
||||
abortOpenCodeSession,
|
||||
isOpenCodeSessionActive,
|
||||
getActiveOpenCodeSessions,
|
||||
} from './opencode-cli.js';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import {
|
||||
stripAnsiSequences,
|
||||
@@ -94,21 +100,25 @@ 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),
|
||||
@@ -1148,6 +1158,18 @@ 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,4 +1,5 @@
|
||||
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,6 +33,7 @@ type ProjectApiView = {
|
||||
cursorSessions: [];
|
||||
codexSessions: [];
|
||||
geminiSessions: [];
|
||||
opencodeSessions: [];
|
||||
sessionMeta: {
|
||||
hasMore: false;
|
||||
total: 0;
|
||||
@@ -84,6 +85,7 @@ 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', SessionSummary[]>;
|
||||
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
|
||||
|
||||
type SessionRepositoryRow = {
|
||||
provider: string;
|
||||
@@ -34,6 +34,7 @@ export type ProjectListItem = {
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -74,6 +75,7 @@ export type ProjectSessionsPageApiView = {
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -139,6 +141,7 @@ function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByPr
|
||||
cursor: [],
|
||||
codex: [],
|
||||
gemini: [],
|
||||
opencode: [],
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -253,6 +256,7 @@ 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,
|
||||
@@ -309,6 +313,7 @@ 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,
|
||||
@@ -341,6 +346,7 @@ 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,
|
||||
|
||||
354
server/modules/providers/README.md
Normal file
354
server/modules/providers/README.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# Providers Module Guide
|
||||
|
||||
This file documents the current provider contract in `server/modules/providers`.
|
||||
Keep it current whenever provider wiring, skill discovery, or session sync
|
||||
behavior changes. The goal is that a human or AI agent can add a new provider
|
||||
without guessing which files need to move.
|
||||
|
||||
## Current Provider Shape
|
||||
|
||||
Every provider wrapper exposes five facets:
|
||||
|
||||
- `auth`
|
||||
- `mcp`
|
||||
- `skills`
|
||||
- `sessions`
|
||||
- `sessionSynchronizer`
|
||||
|
||||
These correspond to the shared interfaces in `server/shared/interfaces.ts`:
|
||||
|
||||
- `IProviderAuth`
|
||||
- `IProviderMcp`
|
||||
- `IProviderSkills`
|
||||
- `IProviderSessions`
|
||||
- `IProviderSessionSynchronizer`
|
||||
|
||||
The services that consume them are:
|
||||
|
||||
- `providerAuthService`
|
||||
- `providerMcpService`
|
||||
- `providerSkillsService`
|
||||
- `sessionsService`
|
||||
- `sessionSynchronizerService`
|
||||
|
||||
Current provider ids in this repo are:
|
||||
|
||||
- `claude`
|
||||
- `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.
|
||||
|
||||
## Current File Layout
|
||||
|
||||
Each provider lives under its own folder in `server/modules/providers/list/`:
|
||||
|
||||
```text
|
||||
server/modules/providers/list/<provider>/
|
||||
<provider>.provider.ts
|
||||
<provider>-auth.provider.ts
|
||||
<provider>-mcp.provider.ts
|
||||
<provider>-skills.provider.ts
|
||||
<provider>-sessions.provider.ts
|
||||
<provider>-session-synchronizer.provider.ts
|
||||
```
|
||||
|
||||
The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
|
||||
`opencode`.
|
||||
|
||||
## What Each Facet Does
|
||||
|
||||
| Facet | Responsibility | Base / Service |
|
||||
| --- | --- | --- |
|
||||
| `auth` | Report install/auth state for the provider runtime | `IProviderAuth` -> `providerAuthService` |
|
||||
| `mcp` | Read, list, write, and remove provider-native MCP config | `McpProvider` -> `providerMcpService` |
|
||||
| `skills` | Discover provider-native skill markdown files | `SkillsProvider` -> `providerSkillsService` |
|
||||
| `sessions` | Normalize live events and fetch session history | `IProviderSessions` -> `sessionsService` |
|
||||
| `sessionSynchronizer` | Scan transcript artifacts and upsert session metadata | `IProviderSessionSynchronizer` -> `sessionSynchronizerService` |
|
||||
|
||||
`sessions` and `sessionSynchronizer` are separate concerns:
|
||||
|
||||
- `sessions` handles runtime event normalization and history fetches.
|
||||
- `sessionSynchronizer` handles file-backed session indexing into `sessionsDb`.
|
||||
|
||||
## How To Add A Provider
|
||||
|
||||
1. Add the provider id everywhere it is part of the contract.
|
||||
|
||||
- Update `server/shared/types.ts` `LLMProvider`.
|
||||
- Update `src/types/app.ts` `LLMProvider` if the frontend should know about it.
|
||||
- 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 `src/components/chat/hooks/useChatProviderState.ts` and
|
||||
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
|
||||
the provider should be selectable in chat.
|
||||
- Update `src/components/provider-auth/view/ProviderLoginModal.tsx` if the
|
||||
provider has a login/setup flow.
|
||||
|
||||
2. Create the wrapper class.
|
||||
|
||||
- Add `server/modules/providers/list/<provider>/<provider>.provider.ts`.
|
||||
- Extend `AbstractProvider`.
|
||||
- Expose readonly `auth`, `mcp`, `skills`, `sessions`, and `sessionSynchronizer`.
|
||||
- Call `super('<provider>')`.
|
||||
|
||||
3. Implement auth.
|
||||
|
||||
- Return a full `ProviderAuthStatus`.
|
||||
- Treat normal `not installed` / `not authenticated` states as data, not exceptions.
|
||||
- Keep provider-specific credential discovery inside the auth provider.
|
||||
- If the provider has no auth step, return a stable unauthenticated or not-installed status instead of omitting the facet.
|
||||
|
||||
4. Implement MCP.
|
||||
|
||||
- Extend `McpProvider`.
|
||||
- Pass the supported scopes and transports to `super(...)`.
|
||||
- Implement the four required methods:
|
||||
- `readScopedServers(...)`
|
||||
- `writeScopedServers(...)`
|
||||
- `buildServerConfig(...)`
|
||||
- `normalizeServerConfig(...)`
|
||||
- Use the shared validation and normalization behavior from `McpProvider`.
|
||||
- Keep the provider-specific config format local to the provider implementation.
|
||||
|
||||
Current MCP formats in this repo are:
|
||||
|
||||
| Provider | User / Project Storage | Supported Scopes | Supported Transports |
|
||||
| --- | --- | --- | --- |
|
||||
| Claude | `.mcp.json` in user / local / project locations | `user`, `local`, `project` | `stdio`, `http`, `sse` |
|
||||
| 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.
|
||||
|
||||
- Extend `SkillsProvider`.
|
||||
- Implement `getSkillSources(workspacePath)`.
|
||||
- Return the actual discovery roots for the provider.
|
||||
- Skills are discovered from `SKILL.md` files.
|
||||
- `readProviderSkillMarkdownDefinition(...)` reads front matter `name` and `description`.
|
||||
- If `name` is missing, the parent directory name is used as a fallback.
|
||||
- Use `recursive: true` only when the provider stores skills in nested trees.
|
||||
- Keep the emitted `command` string aligned with the provider's real skill syntax.
|
||||
|
||||
Current skill discovery roots are:
|
||||
|
||||
| Provider | User Roots | Project / Repo Roots | Prefix | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Claude | `~/.claude/skills` | `<workspace>/.claude/skills` | `/` | Also discovers Claude plugin skills from enabled plugin installs. Command skills live under `commands/`; markdown skills live under `skills/` and are scanned recursively. |
|
||||
| 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:
|
||||
|
||||
- Claude user/project skills: `/skill-name`
|
||||
- Claude plugin skills: `/plugin-name:skill-name`
|
||||
- Codex skills: `$skill-name`
|
||||
- Cursor skills: `/skill-name`
|
||||
- Gemini skills: `/skill-name`
|
||||
- OpenCode skills: `/skill-name`
|
||||
|
||||
6. Implement sessions.
|
||||
|
||||
- Implement `normalizeMessage(raw, sessionId)` and `fetchHistory(sessionId, options)`.
|
||||
- Use `createNormalizedMessage(...)` and `generateMessageId(...)` for emitted messages.
|
||||
- Keep normalized message ids unique. If one raw event produces multiple text
|
||||
parts, append a discriminator so ids do not collide.
|
||||
- Keep pagination consistent:
|
||||
- `limit: null` means unbounded/full history.
|
||||
- `limit: 0` means an empty page.
|
||||
- always return `total`, `hasMore`, `offset`, and `limit` when paginating.
|
||||
- Sanitize any filesystem-derived ids before using them in file or database paths.
|
||||
- Do not assume a provider's history format matches another provider's format.
|
||||
|
||||
7. Implement session synchronization.
|
||||
|
||||
- Implement `synchronize(since?: Date)` to scan provider artifacts and upsert
|
||||
sessions into `sessionsDb`.
|
||||
- Implement `synchronizeFile(filePath)` for single-file watcher updates.
|
||||
- Use the existing helpers when they fit:
|
||||
- `buildLookupMap(...)`
|
||||
- `extractFirstValidJsonlData(...)`
|
||||
- `findFilesRecursivelyCreatedAfter(...)`
|
||||
- `normalizeSessionName(...)`
|
||||
- `readFileTimestamps(...)`
|
||||
- Make the sync resilient to partial, malformed, or missing provider files.
|
||||
- The orchestration service runs all provider synchronizers and only advances
|
||||
`scan_state.last_scanned_at` when every provider succeeds.
|
||||
|
||||
Current session sync roots are:
|
||||
|
||||
| Provider | Scan Roots | Metadata Helpers / Notes |
|
||||
| --- | --- | --- |
|
||||
| Claude | `~/.claude/projects/**/*.jsonl` | Uses `~/.claude/history.jsonl` for name lookup and the trailing `ai-title`, `last-prompt`, or `custom-title` entries for title recovery. |
|
||||
| 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.
|
||||
|
||||
- Add the new provider class to `server/modules/providers/provider.registry.ts`.
|
||||
- Update `server/modules/providers/provider.routes.ts` provider parsing.
|
||||
- If the provider introduces a new service or lifecycle hook, export it from the module entrypoint that consumes providers.
|
||||
|
||||
9. Wire runtime and UI surfaces outside the providers module when needed.
|
||||
|
||||
If the provider can run live chat sessions, update the runtime entrypoints too:
|
||||
|
||||
- `server/routes/agent.js`
|
||||
- `server/index.js`
|
||||
|
||||
If the provider is visible in the UI, update:
|
||||
|
||||
- provider model fallback files under `server/modules/providers/list/<provider>/`
|
||||
- `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
|
||||
|
||||
```ts
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { <Provider>ProviderAuth } from './<provider>-auth.provider.js';
|
||||
import { <Provider>McpProvider } from './<provider>-mcp.provider.js';
|
||||
import { <Provider>SkillsProvider } from './<provider>-skills.provider.js';
|
||||
import { <Provider>SessionsProvider } from './<provider>-sessions.provider.js';
|
||||
import { <Provider>SessionSynchronizer } from './<provider>-session-synchronizer.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderMcp,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSessions,
|
||||
IProviderSkills,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class <Provider>Provider extends AbstractProvider {
|
||||
readonly auth: IProviderAuth = new <Provider>ProviderAuth();
|
||||
readonly mcp: IProviderMcp = new <Provider>McpProvider();
|
||||
readonly skills: IProviderSkills = new <Provider>SkillsProvider();
|
||||
readonly sessions: IProviderSessions = new <Provider>SessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer =
|
||||
new <Provider>SessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('<provider>');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Minimal Skills Template
|
||||
|
||||
```ts
|
||||
import path from 'node:path';
|
||||
|
||||
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||
|
||||
export class <Provider>SkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('<provider>');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
return [
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.<provider>', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Minimal Session Sync Template
|
||||
|
||||
```ts
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
|
||||
export class <Provider>SessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## AI Prompt Template
|
||||
|
||||
Use this prompt when asking an AI agent to add a provider:
|
||||
|
||||
```text
|
||||
Add a new provider "<provider>" using the current provider module architecture.
|
||||
|
||||
Requirements:
|
||||
1) Create:
|
||||
- server/modules/providers/list/<provider>/<provider>.provider.ts
|
||||
- server/modules/providers/list/<provider>/<provider>-auth.provider.ts
|
||||
- server/modules/providers/list/<provider>/<provider>-mcp.provider.ts
|
||||
- server/modules/providers/list/<provider>/<provider>-skills.provider.ts
|
||||
- server/modules/providers/list/<provider>/<provider>-sessions.provider.ts
|
||||
- server/modules/providers/list/<provider>/<provider>-session-synchronizer.provider.ts
|
||||
2) Register in:
|
||||
- server/modules/providers/provider.registry.ts
|
||||
- server/modules/providers/provider.routes.ts
|
||||
- server/shared/types.ts LLMProvider
|
||||
- src/types/app.ts LLMProvider
|
||||
3) Mirror the nearest existing provider implementation for file naming, style,
|
||||
and error handling.
|
||||
4) Implement skills support with SkillsProvider and the current skill roots.
|
||||
5) Implement session synchronization if the provider stores transcript files.
|
||||
6) Ensure sessions use unique ids, safe path handling, and correct pagination.
|
||||
7) Keep `sessions` and `sessionSynchronizer` separate.
|
||||
8) Run:
|
||||
- npx eslint <touched files>
|
||||
- npx tsc --noEmit -p server/tsconfig.json
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
After adding or changing a provider, run the relevant checks:
|
||||
|
||||
```bash
|
||||
npx eslint server/modules/providers/**/*.ts server/shared/types.ts server/shared/interfaces.ts
|
||||
npx tsc --noEmit -p server/tsconfig.json
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Adding provider files but forgetting `provider.registry.ts` or
|
||||
`provider.routes.ts`.
|
||||
- Updating backend provider ids but not `src/types/app.ts` or the frontend
|
||||
provider constants.
|
||||
- Omitting `skills` or `sessionSynchronizer` from the wrapper.
|
||||
- Returning duplicate normalized message ids for split content.
|
||||
- Treating `limit === 0` as unbounded history.
|
||||
- Building file paths from raw session ids without validation.
|
||||
- Hardcoding a skill root without checking the provider's actual discovery rules.
|
||||
- Forgetting that Claude plugin skills are discovered differently from normal
|
||||
user/project skill folders.
|
||||
- Assuming one provider's MCP config file format works for the others.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
|
||||
export { providerSkillsService } from './services/skills.service.js';
|
||||
|
||||
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
|
||||
191
server/modules/providers/list/claude/claude-models.provider.ts
Normal file
191
server/modules/providers/list/claude/claude-models.provider.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
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 CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
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',
|
||||
};
|
||||
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 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> {
|
||||
// claude creates a new jsonl file as a separate session for this request.
|
||||
// As a result, it lists the workspace where this is invoked when it shouldn't.
|
||||
//
|
||||
// Disabled for now:
|
||||
// const queryInstance = query({
|
||||
// prompt: 'Get supported models',
|
||||
// options: buildClaudeQueryOptions(),
|
||||
// });
|
||||
// const supportedModels = await queryInstance.supportedModels();
|
||||
// queryInstance.close();
|
||||
// return buildClaudeModelsDefinition(supportedModels);
|
||||
return CLAUDE_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
257
server/modules/providers/list/claude/claude-skills.provider.ts
Normal file
257
server/modules/providers/list/claude/claude-skills.provider.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { readFile, readdir, stat } 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 { parseFrontMatter } from '@/shared/frontmatter.js';
|
||||
import type {
|
||||
ProviderSkill,
|
||||
ProviderSkillListOptions,
|
||||
ProviderSkillSource,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
findProviderSkillMarkdownFiles,
|
||||
readJsonConfig,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readProviderSkillMarkdownDefinition,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const getClaudeHomePath = (): string => path.join(os.homedir(), '.claude');
|
||||
|
||||
const getClaudePluginName = (pluginId: string): string | null => {
|
||||
const normalizedPluginId = pluginId.trim();
|
||||
if (!normalizedPluginId || normalizedPluginId === '@') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [pluginName] = normalizedPluginId.split('@');
|
||||
return readOptionalString(pluginName) ?? null;
|
||||
};
|
||||
|
||||
const stripMarkdownExtension = (filename: string): string =>
|
||||
filename.replace(/\.md$/i, '');
|
||||
|
||||
const pathExistsAsDirectory = async (directoryPath: string): Promise<boolean> => {
|
||||
try {
|
||||
const directoryStats = await stat(directoryPath);
|
||||
return directoryStats.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const listChildDirectories = async (directoryPath: string): Promise<string[]> => {
|
||||
try {
|
||||
const entries = await readdir(directoryPath, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(directoryPath, entry.name))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const readClaudePluginName = async (
|
||||
installPath: string,
|
||||
pluginId: string,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const pluginConfig = await readJsonConfig(
|
||||
path.join(installPath, '.claude-plugin', 'plugin.json'),
|
||||
);
|
||||
|
||||
// Older or partial plugin installs may not have plugin.json yet. Falling
|
||||
// back keeps discovery useful without inventing a separate namespace.
|
||||
return readOptionalString(pluginConfig.name) ?? getClaudePluginName(pluginId);
|
||||
} catch {
|
||||
return getClaudePluginName(pluginId);
|
||||
}
|
||||
};
|
||||
|
||||
export class ClaudeSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('claude');
|
||||
}
|
||||
|
||||
async listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]> {
|
||||
return [
|
||||
...(await super.listSkills(options)),
|
||||
...(await this.listPluginSkills(getClaudeHomePath())),
|
||||
];
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
const claudeHomePath = getClaudeHomePath();
|
||||
|
||||
return [
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(claudeHomePath, 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.claude', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async listPluginSkills(claudeHomePath: string): Promise<ProviderSkill[]> {
|
||||
const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json'));
|
||||
const enabledPlugins = readObjectRecord(settings.enabledPlugins);
|
||||
if (!enabledPlugins) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const installedConfig = await readJsonConfig(
|
||||
path.join(claudeHomePath, 'plugins', 'installed_plugins.json'),
|
||||
);
|
||||
const installedPlugins = readObjectRecord(installedConfig.plugins);
|
||||
if (!installedPlugins) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills: ProviderSkill[] = [];
|
||||
const visitedPluginFolders = new Set<string>();
|
||||
const pluginEntries = Object.entries(enabledPlugins)
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
for (const [pluginId, enabled] of pluginEntries) {
|
||||
if (enabled !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const installs = installedPlugins[pluginId];
|
||||
if (!Array.isArray(installs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const install of installs) {
|
||||
const installRecord = readObjectRecord(install);
|
||||
const installPath = readOptionalString(installRecord?.installPath);
|
||||
if (!installPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Claude's installed path points at one version folder; the usable
|
||||
// plugin payloads live in the direct child folders beside it.
|
||||
const pluginFolders = await listChildDirectories(path.dirname(installPath));
|
||||
for (const pluginFolder of pluginFolders) {
|
||||
const pluginFolderKey = `${pluginId}:${path.resolve(pluginFolder)}`;
|
||||
if (visitedPluginFolders.has(pluginFolderKey)) {
|
||||
continue;
|
||||
}
|
||||
visitedPluginFolders.add(pluginFolderKey);
|
||||
|
||||
const pluginName = await readClaudePluginName(pluginFolder, pluginId);
|
||||
if (!pluginName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const commandsPath = path.join(pluginFolder, 'commands');
|
||||
if (await pathExistsAsDirectory(commandsPath)) {
|
||||
skills.push(
|
||||
...(await this.listPluginCommandSkills(commandsPath, pluginId, pluginName)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillsPath = path.join(pluginFolder, 'skills');
|
||||
if (!(await pathExistsAsDirectory(skillsPath))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
skills.push(
|
||||
...(await this.listPluginSkillMarkdowns(pluginFolder, pluginId, pluginName)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
private async listPluginCommandSkills(
|
||||
commandsPath: string,
|
||||
pluginId: string,
|
||||
pluginName: string,
|
||||
): Promise<ProviderSkill[]> {
|
||||
const skills: ProviderSkill[] = [];
|
||||
|
||||
try {
|
||||
const entries = await readdir(commandsPath, { withFileTypes: true });
|
||||
const commandFiles = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
for (const commandFile of commandFiles) {
|
||||
const sourcePath = path.join(commandsPath, commandFile.name);
|
||||
try {
|
||||
const definition = await this.readPluginCommandDefinition(sourcePath);
|
||||
skills.push({
|
||||
provider: this.provider,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
command: `/${pluginName}:${definition.name}`,
|
||||
scope: 'plugin',
|
||||
sourcePath,
|
||||
pluginName,
|
||||
pluginId,
|
||||
});
|
||||
} catch {
|
||||
// Malformed command markdown should not block sibling plugin commands.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Missing or unreadable command folders are treated as empty plugin command sets.
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
private async readPluginCommandDefinition(
|
||||
commandPath: string,
|
||||
): Promise<{ name: string; description: string }> {
|
||||
const content = await readFile(commandPath, 'utf8');
|
||||
const parsed = parseFrontMatter(content);
|
||||
const data = readObjectRecord(parsed.data) ?? {};
|
||||
|
||||
return {
|
||||
name: stripMarkdownExtension(path.basename(commandPath)),
|
||||
description: readOptionalString(data.description) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
private async listPluginSkillMarkdowns(
|
||||
installPath: string,
|
||||
pluginId: string,
|
||||
pluginName: string,
|
||||
): Promise<ProviderSkill[]> {
|
||||
const skillFiles = await findProviderSkillMarkdownFiles(path.join(installPath, 'skills'), {
|
||||
recursive: true,
|
||||
});
|
||||
const skills: ProviderSkill[] = [];
|
||||
|
||||
for (const skillPath of skillFiles) {
|
||||
try {
|
||||
const definition = await readProviderSkillMarkdownDefinition(skillPath);
|
||||
skills.push({
|
||||
provider: this.provider,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
command: `/${pluginName}:${definition.name}`,
|
||||
scope: 'plugin',
|
||||
sourcePath: skillPath,
|
||||
pluginName,
|
||||
pluginId,
|
||||
});
|
||||
} catch {
|
||||
// A bad plugin skill file should not block other installed plugin skills.
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
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 type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.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();
|
||||
readonly sessions: IProviderSessions = new ClaudeSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new ClaudeSessionSynchronizer();
|
||||
|
||||
|
||||
125
server/modules/providers/list/codex/codex-models.provider.ts
Normal file
125
server/modules/providers/list/codex/codex-models.provider.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
60
server/modules/providers/list/codex/codex-skills.provider.ts
Normal file
60
server/modules/providers/list/codex/codex-skills.provider.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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';
|
||||
|
||||
export class CodexSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('codex');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
const sources: ProviderSkillSource[] = [];
|
||||
const seenRootDirs = new Set<string>();
|
||||
const repoRoot = await findTopmostGitRoot(workspacePath);
|
||||
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
|
||||
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, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(path.dirname(workspacePath), '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(repoRoot, '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
}
|
||||
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'admin',
|
||||
rootDir: path.join('/etc', 'codex', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'system',
|
||||
rootDir: path.join(os.homedir(), '.codex', 'skills', '.system'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
|
||||
return sources;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
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 type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.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();
|
||||
readonly sessions: IProviderSessions = new CodexSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new CodexSessionSynchronizer();
|
||||
|
||||
|
||||
820
server/modules/providers/list/cursor/cursor-models.provider.ts
Normal file
820
server/modules/providers/list/cursor/cursor-models.provider.ts
Normal file
@@ -0,0 +1,820 @@
|
||||
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: "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",
|
||||
};
|
||||
|
||||
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,7 +4,12 @@ import path from 'node:path';
|
||||
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
import {
|
||||
createNormalizedMessage,
|
||||
generateMessageId,
|
||||
readObjectRecord,
|
||||
sanitizeLeafDirectoryName,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'cursor';
|
||||
|
||||
@@ -186,24 +191,6 @@ 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
|
||||
@@ -214,7 +201,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 = sanitizeCursorSessionId(sessionId);
|
||||
const safeSessionId = sanitizeLeafDirectoryName(sessionId, 'cursor session id');
|
||||
const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
||||
const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db');
|
||||
const resolvedBaseChatsPath = path.resolve(baseChatsPath);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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';
|
||||
|
||||
export class CursorSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('cursor');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
return [
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.cursor', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.cursor', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
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 type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.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();
|
||||
readonly sessions: IProviderSessions = new CursorSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new CursorSessionSynchronizer();
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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';
|
||||
|
||||
export class GeminiSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('gemini');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
return [
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
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 type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.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();
|
||||
readonly sessions: IProviderSessions = new GeminiSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new GeminiSessionSynchronizer();
|
||||
|
||||
|
||||
111
server/modules/providers/list/opencode/opencode-auth.provider.ts
Normal file
111
server/modules/providers/list/opencode/opencode-auth.provider.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
228
server/modules/providers/list/opencode/opencode-mcp.provider.ts
Normal file
228
server/modules/providers/list/opencode/opencode-mcp.provider.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
27
server/modules/providers/list/opencode/opencode.provider.ts
Normal file
27
server/modules/providers/list/opencode/opencode.provider.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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,6 +2,7 @@ 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';
|
||||
@@ -11,6 +12,7 @@ const providers: Record<LLMProvider, IProvider> = {
|
||||
codex: new CodexProvider(),
|
||||
cursor: new CursorProvider(),
|
||||
gemini: new GeminiProvider(),
|
||||
opencode: new OpenCodeProvider(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,9 +2,17 @@ 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, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
McpScope,
|
||||
McpTransport,
|
||||
ProviderChangeActiveModelInput,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -172,7 +180,13 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
|
||||
|
||||
const parseProvider = (value: unknown): LLMProvider => {
|
||||
const normalized = normalizeProviderParam(value);
|
||||
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
|
||||
if (
|
||||
normalized === 'claude'
|
||||
|| normalized === 'codex'
|
||||
|| normalized === 'cursor'
|
||||
|| normalized === 'gemini'
|
||||
|| normalized === 'opencode'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -238,6 +252,29 @@ 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) => {
|
||||
@@ -247,6 +284,41 @@ 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',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const workspacePath = readOptionalQueryString(req.query.workspacePath);
|
||||
const skills = await providerSkillsService.listProviderSkills(provider, { workspacePath });
|
||||
res.json(createApiSuccessResponse({ provider, skills }));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- MCP routes -----------------
|
||||
router.get(
|
||||
'/:provider/mcp/servers',
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import os from 'node:os';
|
||||
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
/** Cursor MCP is not supported on Windows hosts (no Cursor CLI integration). */
|
||||
function includeProviderInGlobalMcp(providerId: LLMProvider): boolean {
|
||||
if (providerId === 'cursor' && os.platform() === 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export const providerMcpService = {
|
||||
/**
|
||||
@@ -75,7 +64,7 @@ export const providerMcpService = {
|
||||
|
||||
const scope = input.scope ?? 'project';
|
||||
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
|
||||
const providers = providerRegistry.listProviders().filter((p) => includeProviderInGlobalMcp(p.id));
|
||||
const providers = providerRegistry.listProviders();
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
await provider.mcp.upsertServer({ ...input, scope });
|
||||
|
||||
325
server/modules/providers/services/provider-models.service.ts
Normal file
325
server/modules/providers/services/provider-models.service.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
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,6 +22,7 @@ export const sessionSynchronizerService = {
|
||||
codex: 0,
|
||||
cursor: 0,
|
||||
gemini: 0,
|
||||
opencode: 0,
|
||||
};
|
||||
const failures: string[] = [];
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ 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 = [
|
||||
@@ -67,6 +71,10 @@ 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');
|
||||
}
|
||||
|
||||
15
server/modules/providers/services/skills.service.ts
Normal file
15
server/modules/providers/services/skills.service.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { ProviderSkill, ProviderSkillListOptions } from '@/shared/types.js';
|
||||
|
||||
export const providerSkillsService = {
|
||||
/**
|
||||
* Lists normalized skills visible to one provider.
|
||||
*/
|
||||
async listProviderSkills(
|
||||
providerName: string,
|
||||
options?: ProviderSkillListOptions,
|
||||
): Promise<ProviderSkill[]> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.skills.listSkills(options);
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,9 @@ import type {
|
||||
IProvider,
|
||||
IProviderAuth,
|
||||
IProviderMcp,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
@@ -16,8 +18,10 @@ 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;
|
||||
abstract readonly sessions: IProviderSessions;
|
||||
abstract readonly sessionSynchronizer: IProviderSessionSynchronizer;
|
||||
|
||||
|
||||
64
server/modules/providers/shared/skills/skills.provider.ts
Normal file
64
server/modules/providers/shared/skills/skills.provider.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { IProviderSkills } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
ProviderSkill,
|
||||
ProviderSkillListOptions,
|
||||
ProviderSkillSource,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
findProviderSkillMarkdownFiles,
|
||||
readProviderSkillMarkdownDefinition,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const resolveWorkspacePath = (workspacePath?: string): string =>
|
||||
path.resolve(workspacePath ?? process.cwd());
|
||||
|
||||
/**
|
||||
* Shared skills provider for provider-specific skill source discovery.
|
||||
*/
|
||||
export abstract class SkillsProvider implements IProviderSkills {
|
||||
protected readonly provider: LLMProvider;
|
||||
|
||||
protected constructor(provider: LLMProvider) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
async listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]> {
|
||||
const workspacePath = resolveWorkspacePath(options?.workspacePath);
|
||||
const sources = await this.getSkillSources(workspacePath);
|
||||
const skills: ProviderSkill[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
const skillFiles = await findProviderSkillMarkdownFiles(source.rootDir, {
|
||||
recursive: source.recursive,
|
||||
});
|
||||
for (const skillPath of skillFiles) {
|
||||
try {
|
||||
const definition = await readProviderSkillMarkdownDefinition(skillPath);
|
||||
const command = source.commandForSkill
|
||||
? source.commandForSkill(definition.name)
|
||||
: `${source.commandPrefix ?? '/'}${definition.name}`;
|
||||
|
||||
skills.push({
|
||||
provider: this.provider,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
command,
|
||||
scope: source.scope,
|
||||
sourcePath: skillPath,
|
||||
pluginName: source.pluginName,
|
||||
pluginId: source.pluginId,
|
||||
});
|
||||
} catch {
|
||||
// A malformed or unreadable skill markdown file should not hide other valid skills.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
protected abstract getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]>;
|
||||
}
|
||||
@@ -169,6 +169,93 @@ 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.
|
||||
*/
|
||||
@@ -254,8 +341,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const expectCursorGlobal = process.platform !== 'win32';
|
||||
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
|
||||
assert.equal(globalResult.length, 5);
|
||||
assert.ok(globalResult.every((entry) => entry.created === true));
|
||||
|
||||
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||
@@ -267,10 +353,11 @@ 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']);
|
||||
|
||||
if (expectCursorGlobal) {
|
||||
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||
assert.ok((cursorProject.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']);
|
||||
|
||||
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.addMcpServerToAllProviders({
|
||||
|
||||
73
server/modules/providers/tests/opencode-models.test.ts
Normal file
73
server/modules/providers/tests/opencode-models.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
321
server/modules/providers/tests/opencode-sessions.test.ts
Normal file
321
server/modules/providers/tests/opencode-sessions.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
318
server/modules/providers/tests/provider-models.service.test.ts
Normal file
318
server/modules/providers/tests/provider-models.service.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
512
server/modules/providers/tests/skills.test.ts
Normal file
512
server/modules/providers/tests/skills.test.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
||||
|
||||
const patchHomeDir = (nextHomeDir: string) => {
|
||||
const original = os.homedir;
|
||||
(os as any).homedir = () => nextHomeDir;
|
||||
return () => {
|
||||
(os as any).homedir = original;
|
||||
};
|
||||
};
|
||||
|
||||
const writeSkill = async (
|
||||
skillsRoot: string,
|
||||
directoryName: string,
|
||||
name: string,
|
||||
description: string,
|
||||
): Promise<string> => {
|
||||
const skillDir = path.join(skillsRoot, directoryName);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
const skillPath = path.join(skillDir, 'SKILL.md');
|
||||
await fs.writeFile(
|
||||
skillPath,
|
||||
`---\nname: ${name}\ndescription: ${description}\n---\n\n`,
|
||||
'utf8',
|
||||
);
|
||||
return skillPath;
|
||||
};
|
||||
|
||||
const writeClaudePluginManifest = async (
|
||||
installPath: string,
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
const pluginConfigDir = path.join(installPath, '.claude-plugin');
|
||||
await fs.mkdir(pluginConfigDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginConfigDir, 'plugin.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name,
|
||||
version: '0.1.0',
|
||||
description: `${name} test plugin`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
};
|
||||
|
||||
const writeClaudePluginCommand = async (
|
||||
commandsRoot: string,
|
||||
commandName: string,
|
||||
description: string,
|
||||
): Promise<string> => {
|
||||
await fs.mkdir(commandsRoot, { recursive: true });
|
||||
const commandPath = path.join(commandsRoot, `${commandName}.md`);
|
||||
await fs.writeFile(
|
||||
commandPath,
|
||||
`---\ndescription: ${description}\nargument-hint: 'test args'\n---\n\nCommand body.\n`,
|
||||
'utf8',
|
||||
);
|
||||
return commandPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* This test covers Claude user/project skill folders plus plugin discovery from
|
||||
* installed plugin command files and fallback plugin skill files.
|
||||
*/
|
||||
test('providerSkillsService lists claude user, project, and enabled plugin skills', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-claude-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
const commandPluginInstallPath = path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'cache',
|
||||
'notion-plugin',
|
||||
'notion',
|
||||
'abc123',
|
||||
);
|
||||
const skillPluginInstallPath = path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'cache',
|
||||
'anthropic-agent-skills',
|
||||
'example-skills',
|
||||
'def456',
|
||||
);
|
||||
const disabledPluginInstallPath = path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'cache',
|
||||
'disabled-marketplace',
|
||||
'disabled-skills',
|
||||
'ghi789',
|
||||
);
|
||||
const emptyIdPluginInstallPath = path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'cache',
|
||||
'invalid-empty-plugin',
|
||||
'empty',
|
||||
'000',
|
||||
);
|
||||
const atIdPluginInstallPath = path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'cache',
|
||||
'invalid-at-plugin',
|
||||
'at',
|
||||
'000',
|
||||
);
|
||||
const siblingSkillPluginPath = path.join(path.dirname(skillPluginInstallPath), 'legacy777');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.claude', 'skills'),
|
||||
'claude-user-dir',
|
||||
'claude-user',
|
||||
'Claude user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.claude', 'skills'),
|
||||
'claude-project-dir',
|
||||
'claude-project',
|
||||
'Claude project skill',
|
||||
);
|
||||
await writeClaudePluginManifest(commandPluginInstallPath, 'Notion');
|
||||
await writeClaudePluginCommand(
|
||||
path.join(commandPluginInstallPath, 'commands'),
|
||||
'insert-row',
|
||||
'Insert a Notion database row',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(commandPluginInstallPath, 'skills'),
|
||||
'ignored-command-plugin-skill-dir',
|
||||
'ignored-command-plugin-skill',
|
||||
'Command plugin fallback skill should be ignored',
|
||||
);
|
||||
await writeClaudePluginManifest(skillPluginInstallPath, 'ExampleSkills');
|
||||
await writeSkill(
|
||||
path.join(skillPluginInstallPath, 'skills'),
|
||||
'claude-plugin-dir',
|
||||
'claude-plugin',
|
||||
'Claude plugin skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(skillPluginInstallPath, 'skills'),
|
||||
'claude-plugin-second-dir',
|
||||
'claude-plugin-second',
|
||||
'Second Claude plugin skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(skillPluginInstallPath, 'skills', 'nested', 'collection'),
|
||||
'claude-plugin-nested-dir',
|
||||
'claude-plugin-nested',
|
||||
'Nested Claude plugin skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(siblingSkillPluginPath, 'skills'),
|
||||
'claude-plugin-sibling-dir',
|
||||
'claude-plugin-sibling',
|
||||
'Sibling Claude plugin skill',
|
||||
);
|
||||
await writeClaudePluginManifest(disabledPluginInstallPath, 'DisabledSkills');
|
||||
await writeClaudePluginCommand(
|
||||
path.join(disabledPluginInstallPath, 'commands'),
|
||||
'disabled-command',
|
||||
'Disabled plugin command',
|
||||
);
|
||||
await writeClaudePluginCommand(
|
||||
path.join(emptyIdPluginInstallPath, 'commands'),
|
||||
'invalid-empty-command',
|
||||
'Invalid empty id command',
|
||||
);
|
||||
await writeClaudePluginCommand(
|
||||
path.join(atIdPluginInstallPath, 'commands'),
|
||||
'invalid-at-command',
|
||||
'Invalid at id command',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(
|
||||
disabledPluginInstallPath,
|
||||
'skills',
|
||||
),
|
||||
'disabled-plugin-dir',
|
||||
'disabled-plugin',
|
||||
'Disabled plugin skill',
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(tempRoot, '.claude', 'settings.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
enabledPlugins: {
|
||||
'': true,
|
||||
'@': true,
|
||||
'notion@notion-marketplace': true,
|
||||
'example-skills@anthropic-agent-skills': true,
|
||||
'disabled-skills@disabled-marketplace': false,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(tempRoot, '.claude', 'plugins', 'installed_plugins.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 2,
|
||||
plugins: {
|
||||
'': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: emptyIdPluginInstallPath,
|
||||
version: '000',
|
||||
},
|
||||
],
|
||||
'@': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: atIdPluginInstallPath,
|
||||
version: '000',
|
||||
},
|
||||
],
|
||||
'notion@notion-marketplace': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: commandPluginInstallPath,
|
||||
version: 'abc123',
|
||||
},
|
||||
],
|
||||
'example-skills@anthropic-agent-skills': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: skillPluginInstallPath,
|
||||
version: 'def456',
|
||||
},
|
||||
],
|
||||
'disabled-skills@disabled-marketplace': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: disabledPluginInstallPath,
|
||||
version: 'ghi789',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const skills = await providerSkillsService.listProviderSkills('claude', { workspacePath });
|
||||
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
|
||||
assert.equal(byName.get('claude-user')?.scope, 'user');
|
||||
assert.equal(byName.get('claude-user')?.command, '/claude-user');
|
||||
assert.equal(byName.get('claude-project')?.scope, 'project');
|
||||
assert.equal(byName.get('claude-project')?.command, '/claude-project');
|
||||
|
||||
const pluginCommand = byName.get('insert-row');
|
||||
assert.equal(pluginCommand?.scope, 'plugin');
|
||||
assert.equal(pluginCommand?.pluginName, 'Notion');
|
||||
assert.equal(pluginCommand?.pluginId, 'notion@notion-marketplace');
|
||||
assert.equal(pluginCommand?.command, '/Notion:insert-row');
|
||||
assert.equal(pluginCommand?.description, 'Insert a Notion database row');
|
||||
assert.match(pluginCommand?.sourcePath ?? '', /commands[\\/]insert-row\.md$/);
|
||||
assert.equal(byName.has('ignored-command-plugin-skill'), false);
|
||||
|
||||
const pluginSkill = byName.get('claude-plugin');
|
||||
assert.equal(pluginSkill?.scope, 'plugin');
|
||||
assert.equal(pluginSkill?.pluginName, 'ExampleSkills');
|
||||
assert.equal(pluginSkill?.pluginId, 'example-skills@anthropic-agent-skills');
|
||||
assert.equal(pluginSkill?.command, '/ExampleSkills:claude-plugin');
|
||||
assert.equal(pluginSkill?.description, 'Claude plugin skill');
|
||||
assert.match(
|
||||
pluginSkill?.sourcePath ?? '',
|
||||
/cache[\\/]anthropic-agent-skills[\\/]example-skills[\\/]def456[\\/]skills[\\/]/,
|
||||
);
|
||||
|
||||
const secondPluginSkill = byName.get('claude-plugin-second');
|
||||
assert.equal(secondPluginSkill?.scope, 'plugin');
|
||||
assert.equal(secondPluginSkill?.command, '/ExampleSkills:claude-plugin-second');
|
||||
|
||||
const nestedPluginSkill = byName.get('claude-plugin-nested');
|
||||
assert.equal(nestedPluginSkill?.scope, 'plugin');
|
||||
assert.equal(nestedPluginSkill?.command, '/ExampleSkills:claude-plugin-nested');
|
||||
assert.equal(nestedPluginSkill?.description, 'Nested Claude plugin skill');
|
||||
|
||||
const siblingPluginSkill = byName.get('claude-plugin-sibling');
|
||||
assert.equal(siblingPluginSkill?.scope, 'plugin');
|
||||
assert.equal(siblingPluginSkill?.pluginName, 'example-skills');
|
||||
assert.equal(siblingPluginSkill?.command, '/example-skills:claude-plugin-sibling');
|
||||
assert.equal(siblingPluginSkill?.description, 'Sibling Claude plugin skill');
|
||||
assert.equal(byName.has('disabled-command'), false);
|
||||
assert.equal(byName.has('disabled-plugin'), false);
|
||||
assert.equal(byName.has('invalid-empty-command'), false);
|
||||
assert.equal(byName.has('invalid-at-command'), false);
|
||||
assert.equal(skills.some((skill) => skill.command.startsWith('/:')), false);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Codex repository/user/system skill folders and verifies that
|
||||
* repository lookup includes cwd, parent, and git root skill locations.
|
||||
*/
|
||||
test('providerSkillsService lists codex repository, user, and system skills', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-codex-'));
|
||||
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, '.agents', 'skills'),
|
||||
'codex-cwd-dir',
|
||||
'codex-cwd',
|
||||
'Codex cwd skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(repoRoot, 'packages', '.agents', 'skills'),
|
||||
'codex-parent-dir',
|
||||
'codex-parent',
|
||||
'Codex parent skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(repoRoot, '.agents', 'skills'),
|
||||
'codex-root-dir',
|
||||
'codex-root',
|
||||
'Codex root skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.agents', 'skills'),
|
||||
'codex-user-dir',
|
||||
'codex-user',
|
||||
'Codex user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.codex', 'skills', '.system'),
|
||||
'codex-system-dir',
|
||||
'codex-system',
|
||||
'Codex system skill',
|
||||
);
|
||||
|
||||
const skills = await providerSkillsService.listProviderSkills('codex', { workspacePath });
|
||||
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
|
||||
assert.equal(byName.get('codex-cwd')?.scope, 'repo');
|
||||
assert.equal(byName.get('codex-parent')?.scope, 'repo');
|
||||
assert.equal(byName.get('codex-root')?.scope, 'repo');
|
||||
assert.equal(byName.get('codex-user')?.scope, 'user');
|
||||
assert.equal(byName.get('codex-system')?.scope, 'system');
|
||||
assert.equal(byName.get('codex-root')?.command, '$codex-root');
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
test('providerSkillsService lists gemini and cursor skills from their configured directories', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gc-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.gemini', 'skills'),
|
||||
'gemini-user-dir',
|
||||
'gemini-user',
|
||||
'Gemini user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.agents', 'skills'),
|
||||
'agents-user-dir',
|
||||
'agents-user',
|
||||
'Agents user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.gemini', 'skills'),
|
||||
'gemini-project-dir',
|
||||
'gemini-project',
|
||||
'Gemini project skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.agents', 'skills'),
|
||||
'agents-project-dir',
|
||||
'agents-project',
|
||||
'Agents project skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.cursor', 'skills'),
|
||||
'cursor-project-dir',
|
||||
'cursor-project',
|
||||
'Cursor project skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.cursor', 'skills'),
|
||||
'cursor-user-dir',
|
||||
'cursor-user',
|
||||
'Cursor user skill',
|
||||
);
|
||||
|
||||
const geminiSkills = await providerSkillsService.listProviderSkills('gemini', { workspacePath });
|
||||
const geminiByName = new Map(geminiSkills.map((skill) => [skill.name, skill]));
|
||||
assert.equal(geminiByName.get('gemini-user')?.scope, 'user');
|
||||
assert.equal(geminiByName.get('agents-user')?.scope, 'user');
|
||||
assert.equal(geminiByName.get('gemini-project')?.scope, 'project');
|
||||
assert.equal(geminiByName.get('agents-project')?.scope, 'project');
|
||||
assert.equal(geminiByName.get('gemini-project')?.command, '/gemini-project');
|
||||
|
||||
const cursorSkills = await providerSkillsService.listProviderSkills('cursor', { workspacePath });
|
||||
const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill]));
|
||||
assert.equal(cursorByName.get('agents-project')?.scope, 'project');
|
||||
assert.equal(cursorByName.get('cursor-project')?.scope, 'project');
|
||||
assert.equal(cursorByName.get('cursor-user')?.scope, 'user');
|
||||
assert.equal(cursorByName.get('cursor-user')?.command, '/cursor-user');
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -29,10 +29,12 @@ 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: {
|
||||
@@ -46,19 +48,21 @@ 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') {
|
||||
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -134,6 +138,11 @@ export function handleChatConnection(
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'opencode-command') {
|
||||
await dependencies.spawnOpenCode(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'cursor-resume') {
|
||||
await dependencies.spawnCursor(
|
||||
'',
|
||||
@@ -158,6 +167,8 @@ 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);
|
||||
}
|
||||
@@ -214,6 +225,8 @@ 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) {
|
||||
@@ -251,6 +264,7 @@ export function handleChatConnection(
|
||||
cursor: dependencies.getActiveCursorSessions(),
|
||||
codex: dependencies.getActiveCodexSessions(),
|
||||
gemini: dependencies.getActiveGeminiSessions(),
|
||||
opencode: dependencies.getActiveOpenCodeSessions(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,6 +136,13 @@ 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') {
|
||||
@@ -389,6 +396,8 @@ 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,6 +17,7 @@ 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
|
||||
@@ -202,6 +203,12 @@ 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);
|
||||
|
||||
@@ -222,7 +229,7 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
skipGitRepoCheck: true,
|
||||
sandboxMode,
|
||||
approvalPolicy,
|
||||
model
|
||||
model: resolvedModel
|
||||
};
|
||||
|
||||
// Start or resume thread
|
||||
|
||||
263
server/opencode-cli.js
Normal file
263
server/opencode-cli.js
Normal file
@@ -0,0 +1,263 @@
|
||||
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,
|
||||
};
|
||||
95
server/opencode-cli.test.js
Normal file
95
server/opencode-cli.test.js
Normal file
@@ -0,0 +1,95 @@
|
||||
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,8 +9,9 @@ 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 { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
import { providerModelsService } from '../modules/providers/services/provider-models.service.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
import { normalizeProjectPath } from '../shared/utils.js';
|
||||
|
||||
@@ -608,7 +609,7 @@ class ResponseCollector {
|
||||
/**
|
||||
* POST /api/agent
|
||||
*
|
||||
* Trigger an AI agent (Claude or Cursor) to work on a project.
|
||||
* Trigger an AI agent to work on a project.
|
||||
* Supports automatic GitHub branch and pull request creation after successful completion.
|
||||
*
|
||||
* ================================================================================================
|
||||
@@ -633,7 +634,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'
|
||||
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'
|
||||
* Default: 'claude'
|
||||
*
|
||||
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
|
||||
@@ -751,7 +752,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', or 'gemini'
|
||||
* - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode'
|
||||
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
|
||||
* - branchName must pass Git naming rules (if provided)
|
||||
*
|
||||
@@ -859,8 +860,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
return res.status(400).json({ error: 'message is required' });
|
||||
}
|
||||
|
||||
if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' });
|
||||
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' });
|
||||
}
|
||||
|
||||
// Validate GitHub branch/PR creation requirements
|
||||
@@ -938,6 +939,10 @@ 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');
|
||||
@@ -967,7 +972,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: sessionId || null,
|
||||
model: model || CODEX_MODELS.DEFAULT,
|
||||
model: model || codexModels.DEFAULT,
|
||||
permissionMode: 'bypassPermissions'
|
||||
}, writer);
|
||||
} else if (provider === 'gemini') {
|
||||
@@ -977,9 +982,18 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: sessionId || null,
|
||||
model: model,
|
||||
model: model || geminiModels.DEFAULT,
|
||||
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,10 +1,12 @@
|
||||
import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
import { parseFrontmatter } from '../utils/frontmatter.js';
|
||||
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
|
||||
import { promises as fs } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
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";
|
||||
|
||||
const __dirname = getModuleDir(import.meta.url);
|
||||
// This route reads the top-level package.json for the status command, so it needs the real
|
||||
@@ -13,6 +15,77 @@ 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
|
||||
@@ -34,24 +107,30 @@ 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({
|
||||
@@ -60,7 +139,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);
|
||||
@@ -69,7 +148,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);
|
||||
}
|
||||
}
|
||||
@@ -82,53 +161,41 @@ 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: '/clear',
|
||||
description: 'Clear the conversation history',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
name: "/models",
|
||||
description: "View available models for the current provider",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
},
|
||||
{
|
||||
name: '/model',
|
||||
description: 'Switch or view the current AI model',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
name: "/cost",
|
||||
description: "Display token usage and cost information",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
},
|
||||
{
|
||||
name: '/cost',
|
||||
description: 'Display token usage and cost information',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
name: "/memory",
|
||||
description: "Open CLAUDE.md memory file for editing",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
},
|
||||
{
|
||||
name: '/memory',
|
||||
description: 'Open CLAUDE.md memory file for editing',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
name: "/config",
|
||||
description: "Open settings and configuration",
|
||||
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: '/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' }
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -136,14 +203,18 @@ 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
|
||||
|
||||
@@ -165,71 +236,40 @@ Custom commands can be created in:
|
||||
`;
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'help',
|
||||
type: "builtin",
|
||||
action: "help",
|
||||
data: {
|
||||
content: helpText,
|
||||
format: 'markdown'
|
||||
}
|
||||
format: "markdown",
|
||||
commands: builtInCommands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
namespace: command.namespace,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
'/clear': async (args, context) => {
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'clear',
|
||||
data: {
|
||||
message: 'Conversation history cleared'
|
||||
}
|
||||
};
|
||||
},
|
||||
"/models": executeModelsCommand,
|
||||
|
||||
'/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) => {
|
||||
"/cost": async (args, context) => {
|
||||
const tokenUsage = context?.tokenUsage || {};
|
||||
const provider = context?.provider || 'claude';
|
||||
const model =
|
||||
context?.model ||
|
||||
(provider === 'cursor'
|
||||
? CURSOR_MODELS.DEFAULT
|
||||
: provider === 'codex'
|
||||
? CODEX_MODELS.DEFAULT
|
||||
: CLAUDE_MODELS.DEFAULT);
|
||||
const provider = readModelProvider(context?.provider);
|
||||
const catalog = (await providerModelsService.getProviderModels(provider)).models;
|
||||
const model = await resolveCommandModel(provider, catalog, context?.sessionId);
|
||||
|
||||
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(
|
||||
@@ -258,7 +298,9 @@ 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 = {
|
||||
@@ -273,76 +315,96 @@ 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 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();
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'status',
|
||||
type: "builtin",
|
||||
action: "status",
|
||||
data: {
|
||||
version,
|
||||
packageName,
|
||||
uptime: uptimeFormatted,
|
||||
uptimeSeconds: Math.floor(uptime),
|
||||
model: context?.model || CLAUDE_MODELS.DEFAULT,
|
||||
provider: context?.provider || 'claude',
|
||||
model,
|
||||
provider: statusProvider,
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform
|
||||
}
|
||||
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),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
'/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;
|
||||
@@ -354,85 +416,63 @@ 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));
|
||||
@@ -440,13 +480,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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -457,13 +497,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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -474,14 +514,17 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -489,7 +532,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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -497,56 +540,62 @@ 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_MODELS } from '../../shared/modelConstants.js';
|
||||
import { CURSOR_FALLBACK_MODELS } from '../modules/providers/list/cursor/cursor-models.provider.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -29,7 +29,7 @@ router.get('/config', async (req, res) => {
|
||||
config: {
|
||||
version: 1,
|
||||
model: {
|
||||
modelId: CURSOR_MODELS.DEFAULT,
|
||||
modelId: CURSOR_FALLBACK_MODELS.DEFAULT,
|
||||
displayName: 'GPT-5',
|
||||
},
|
||||
permissions: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
|
||||
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js';
|
||||
import { getPublicKey } from '../services/vapid-keys.js';
|
||||
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
|
||||
@@ -273,14 +274,4 @@ router.post('/push/unsubscribe', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Host OS for UI (e.g. hide Cursor agent when the backend runs on Windows).
|
||||
router.get('/server-env', async (req, res) => {
|
||||
try {
|
||||
res.json({ platform: process.platform });
|
||||
} catch (error) {
|
||||
console.error('Error reading server environment:', error);
|
||||
res.status(500).json({ error: 'Failed to read server environment' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
82
server/routes/tests/commands.test.js
Normal file
82
server/routes/tests/commands.test.js
Normal file
@@ -0,0 +1,82 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -9,10 +9,10 @@ const frontmatterOptions = {
|
||||
engines: {
|
||||
js: disabledFrontmatterEngine,
|
||||
javascript: disabledFrontmatterEngine,
|
||||
json: disabledFrontmatterEngine
|
||||
}
|
||||
json: disabledFrontmatterEngine,
|
||||
},
|
||||
};
|
||||
|
||||
export function parseFrontmatter(content) {
|
||||
export function parseFrontMatter(content: string) {
|
||||
return matter(content, frontmatterOptions);
|
||||
}
|
||||
@@ -4,8 +4,14 @@ import type {
|
||||
LLMProvider,
|
||||
McpScope,
|
||||
NormalizedMessage,
|
||||
ProviderSkill,
|
||||
ProviderSkillListOptions,
|
||||
ProviderAuthStatus,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderMcpServer,
|
||||
ProviderSessionActiveModelChange,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
@@ -18,12 +24,54 @@ import type {
|
||||
*/
|
||||
export interface IProvider {
|
||||
readonly id: LLMProvider;
|
||||
readonly models: IProviderModels;
|
||||
readonly mcp: IProviderMcp;
|
||||
readonly auth: IProviderAuth;
|
||||
readonly skills: IProviderSkills;
|
||||
readonly sessions: IProviderSessions;
|
||||
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 ------------
|
||||
/**
|
||||
@@ -39,6 +87,22 @@ export interface IProviderAuth {
|
||||
getStatus(): Promise<ProviderAuthStatus>;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SKILLS INTERFACE ------------
|
||||
/**
|
||||
* Skills contract for one provider.
|
||||
*
|
||||
* Implementations discover provider-native skill markdown locations and return
|
||||
* normalized skill records with the exact command syntax expected by that
|
||||
* provider. Each skill is read from a `SKILL.md` file under its skill directory.
|
||||
*/
|
||||
export interface IProviderSkills {
|
||||
/**
|
||||
* Lists all skills visible to this provider for the optional workspace.
|
||||
*/
|
||||
listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]>;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER MCP INTERFACE ------------
|
||||
/**
|
||||
|
||||
@@ -65,7 +65,94 @@ 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';
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Message/event variants emitted by provider adapters and normalized transports.
|
||||
@@ -171,6 +258,69 @@ export type FetchHistoryResult = {
|
||||
tokenUsage?: unknown;
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SKILL TYPES ------------
|
||||
/**
|
||||
* Scope where a provider skill definition was discovered.
|
||||
*
|
||||
* Provider skill adapters should use this to describe the origin of each
|
||||
* skill markdown file without leaking provider-specific folder names into route
|
||||
* contracts. `repo` is used for Codex repository lookup locations, while
|
||||
* `project` is used for providers that treat workspace-local skills as project
|
||||
* scoped.
|
||||
*/
|
||||
export type ProviderSkillScope = 'user' | 'project' | 'plugin' | 'repo' | 'admin' | 'system';
|
||||
|
||||
/**
|
||||
* Shared input accepted by provider skill listing operations.
|
||||
*
|
||||
* Routes pass `workspacePath` when a caller wants project/repository skills for
|
||||
* a specific folder. Providers should fall back to the backend process cwd when
|
||||
* this option is omitted.
|
||||
*/
|
||||
export type ProviderSkillListOptions = {
|
||||
workspacePath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalized skill record returned by provider skill adapters.
|
||||
*
|
||||
* The `command` value is the exact invocation text the selected provider expects
|
||||
* for this skill. Claude plugin skills use a namespaced command such as
|
||||
* `/plugin-name:skill-name`, while Codex skills use the `$skill-name` form.
|
||||
* `sourcePath` points to the skill markdown file that produced the record so
|
||||
* callers can distinguish duplicate skill names across scopes.
|
||||
*/
|
||||
export type ProviderSkill = {
|
||||
provider: LLMProvider;
|
||||
name: string;
|
||||
description: string;
|
||||
command: string;
|
||||
scope: ProviderSkillScope;
|
||||
sourcePath: string;
|
||||
pluginName?: string;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal source descriptor consumed by shared provider skill discovery logic.
|
||||
*
|
||||
* Concrete provider adapters build these records from their native lookup rules.
|
||||
* The shared skills provider then scans `rootDir` for child skill markdown files
|
||||
* and uses `commandForSkill` or `commandPrefix` to produce the provider-specific
|
||||
* invocation command. Set `recursive` only when a provider stores skills under
|
||||
* arbitrary nested folders below the source root.
|
||||
*/
|
||||
export type ProviderSkillSource = {
|
||||
scope: ProviderSkillScope;
|
||||
rootDir: string;
|
||||
recursive?: boolean;
|
||||
commandPrefix?: '/' | '$';
|
||||
commandForSkill?: (skillName: string) => string;
|
||||
pluginName?: string;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
//----------------- SHARED ERROR TYPES ------------
|
||||
/**
|
||||
|
||||
@@ -17,11 +17,18 @@ import readline from 'node:readline';
|
||||
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
import { parseFrontMatter } from '@/shared/frontmatter.js';
|
||||
import type {
|
||||
AnyRecord,
|
||||
ApiSuccessShape,
|
||||
AppErrorOptions,
|
||||
LLMProvider,
|
||||
NormalizedMessage,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
ProviderSkillSource,
|
||||
WorkspacePathValidationResult,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
@@ -412,6 +419,231 @@ 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 ------------
|
||||
/**
|
||||
@@ -503,6 +735,160 @@ export const writeJsonConfig = async (filePath: string, data: Record<string, unk
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
//----------------- 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.
|
||||
*
|
||||
* Skill systems usually store one skill per child directory, so direct mode
|
||||
* scans only `<root>/<skill-name>/SKILL.md`. Recursive mode is reserved for
|
||||
* provider sources that can nest skills arbitrarily, and it returns every
|
||||
* descendant `SKILL.md`. Missing or unreadable roots return an empty list
|
||||
* because users may not have every provider installed or configured.
|
||||
*/
|
||||
export async function findProviderSkillMarkdownFiles(
|
||||
rootDir: string,
|
||||
options: { recursive?: boolean } = {},
|
||||
): Promise<string[]> {
|
||||
const skillFiles: string[] = [];
|
||||
|
||||
const collectRecursive = async (dirPath: string): Promise<void> => {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const skillPath = path.join(dirPath, 'SKILL.md');
|
||||
const skillStats = await stat(skillPath);
|
||||
if (skillStats.isFile()) {
|
||||
skillFiles.push(skillPath);
|
||||
}
|
||||
} catch {
|
||||
// Directories without SKILL.md are expected while walking plugin trees.
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
await collectRecursive(path.join(dirPath, entry.name));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (options.recursive) {
|
||||
await collectRecursive(rootDir);
|
||||
return skillFiles.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(rootDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillPath = path.join(rootDir, entry.name, 'SKILL.md');
|
||||
try {
|
||||
const skillStats = await stat(skillPath);
|
||||
if (skillStats.isFile()) {
|
||||
skillFiles.push(skillPath);
|
||||
}
|
||||
} catch {
|
||||
// A partial skill directory should not block discovery of sibling skills.
|
||||
}
|
||||
}
|
||||
|
||||
return skillFiles.sort((left, right) => left.localeCompare(right));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the `name` and `description` fields from a provider skill markdown file.
|
||||
*
|
||||
* The metadata is expected in markdown front matter. If a skill omits `name`, the
|
||||
* parent directory name is used as a stable fallback so providers can still
|
||||
* expose the skill. Missing descriptions are normalized to an empty string.
|
||||
*/
|
||||
export async function readProviderSkillMarkdownDefinition(
|
||||
skillPath: string,
|
||||
): Promise<{ name: string; description: string }> {
|
||||
const content = await readFile(skillPath, 'utf8');
|
||||
const parsed = parseFrontMatter(content);
|
||||
const data = readObjectRecord(parsed.data) ?? {};
|
||||
const fallbackName = path.basename(path.dirname(skillPath));
|
||||
|
||||
return {
|
||||
name: readOptionalString(data.name) ?? fallbackName,
|
||||
description: readOptionalString(data.description) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- SESSION SYNCHRONIZER TITLE HELPERS ------------
|
||||
/**
|
||||
@@ -522,6 +908,98 @@ 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 ------------
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { parse as parseShellCommand } from 'shell-quote';
|
||||
import { parseFrontmatter } from './frontmatter.js';
|
||||
|
||||
import { parseFrontMatter } from '../shared/frontmatter.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -32,7 +34,7 @@ const BASH_COMMAND_ALLOWLIST = [
|
||||
*/
|
||||
export function parseCommand(content) {
|
||||
try {
|
||||
const parsed = parseFrontmatter(content);
|
||||
const parsed = parseFrontMatter(content);
|
||||
return {
|
||||
data: parsed.data || {},
|
||||
content: parsed.content || '',
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* 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,14 +20,13 @@ import type {
|
||||
PendingPermissionRequest,
|
||||
PermissionMode,
|
||||
} from '../types/types';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } 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;
|
||||
};
|
||||
|
||||
@@ -42,6 +41,7 @@ interface UseChatComposerStateArgs {
|
||||
claudeModel: string;
|
||||
codexModel: string;
|
||||
geminiModel: string;
|
||||
opencodeModel: string;
|
||||
isLoading: boolean;
|
||||
canAbortSession: boolean;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
@@ -55,8 +55,6 @@ 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;
|
||||
@@ -78,6 +76,76 @@ 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>;
|
||||
};
|
||||
@@ -111,6 +179,7 @@ export function useChatComposerState({
|
||||
claudeModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
@@ -124,8 +193,6 @@ export function useChatComposerState({
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
@@ -145,6 +212,7 @@ 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);
|
||||
@@ -152,40 +220,39 @@ export function useChatComposerState({
|
||||
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
|
||||
>(null);
|
||||
const inputValueRef = useRef(input);
|
||||
const selectedProjectId = selectedProject?.projectId;
|
||||
|
||||
const handleBuiltInCommand = useCallback(
|
||||
(result: CommandExecutionResult) => {
|
||||
const { action, data } = result;
|
||||
switch (action) {
|
||||
case 'clear':
|
||||
clearMessages();
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: data.content,
|
||||
timestamp: Date.now(),
|
||||
setCommandModalPayload({
|
||||
kind: 'help',
|
||||
data: (data || {}) as HelpCommandData,
|
||||
});
|
||||
break;
|
||||
|
||||
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(),
|
||||
case 'models':
|
||||
setCommandModalPayload({
|
||||
kind: 'models',
|
||||
data: (data || {}) as ModelCommandData,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'cost': {
|
||||
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() });
|
||||
setCommandModalPayload({
|
||||
kind: 'cost',
|
||||
data: (data || {}) as CostCommandData,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
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() });
|
||||
setCommandModalPayload({
|
||||
kind: 'status',
|
||||
data: (data || {}) as StatusCommandData,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -212,30 +279,17 @@ 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, clearMessages, rewindMessages],
|
||||
[onFileOpen, onShowSettings, addMessage],
|
||||
);
|
||||
|
||||
const closeCommandModal = useCallback(() => {
|
||||
setCommandModalPayload(null);
|
||||
}, []);
|
||||
|
||||
const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
|
||||
const { content, hasBashCommands } = result;
|
||||
|
||||
@@ -284,7 +338,15 @@ export function useChatComposerState({
|
||||
projectId: selectedProject.projectId,
|
||||
sessionId: currentSessionId,
|
||||
provider,
|
||||
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
|
||||
model: provider === 'cursor'
|
||||
? cursorModel
|
||||
: provider === 'codex'
|
||||
? codexModel
|
||||
: provider === 'gemini'
|
||||
? geminiModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: claudeModel,
|
||||
tokenUsage: tokenBudget,
|
||||
};
|
||||
|
||||
@@ -336,6 +398,7 @@ export function useChatComposerState({
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
handleBuiltInCommand,
|
||||
handleCustomCommand,
|
||||
input,
|
||||
@@ -361,6 +424,7 @@ export function useChatComposerState({
|
||||
handleCommandMenuKeyDown,
|
||||
} = useSlashCommands({
|
||||
selectedProject,
|
||||
provider,
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
@@ -470,14 +534,27 @@ export function useChatComposerState({
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept slash commands: if input starts with /commandName, execute as command with args
|
||||
const trimmedInput = currentInput.trim();
|
||||
if (trimmedInput.startsWith('/')) {
|
||||
const firstSpace = trimmedInput.indexOf(' ');
|
||||
const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput;
|
||||
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
|
||||
if (matchedCommand) {
|
||||
executeCommand(matchedCommand, trimmedInput);
|
||||
// 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) {
|
||||
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);
|
||||
if (matchedCommand && matchedCommand.type !== 'skill') {
|
||||
executeCommand(matchedCommand, isHelpAlias ? '/help' : commandInput);
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
setAttachedImages([]);
|
||||
@@ -553,13 +630,9 @@ export function useChatComposerState({
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
|
||||
if (!effectiveSessionId && !selectedSession?.id) {
|
||||
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() };
|
||||
// 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 (effectiveSessionId) {
|
||||
onSessionActive?.(effectiveSessionId);
|
||||
@@ -575,6 +648,8 @@ export function useChatComposerState({
|
||||
? 'codex-settings'
|
||||
: provider === 'gemini'
|
||||
? 'gemini-settings'
|
||||
: provider === 'opencode'
|
||||
? 'opencode-settings'
|
||||
: 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
@@ -642,6 +717,20 @@ 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',
|
||||
@@ -684,6 +773,7 @@ export function useChatComposerState({
|
||||
cursorModel,
|
||||
executeCommand,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
@@ -713,27 +803,27 @@ export function useChatComposerState({
|
||||
}, [input]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
if (!selectedProjectId) {
|
||||
return;
|
||||
}
|
||||
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || '';
|
||||
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProjectId}`) || '';
|
||||
setInput((previous) => {
|
||||
const next = previous === savedInput ? previous : savedInput;
|
||||
inputValueRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, [selectedProject?.projectId]);
|
||||
}, [selectedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
if (!selectedProjectId) {
|
||||
return;
|
||||
}
|
||||
if (input !== '') {
|
||||
safeLocalStorage.setItem(`draft_input_${selectedProject.projectId}`, input);
|
||||
safeLocalStorage.setItem(`draft_input_${selectedProjectId}`, input);
|
||||
} else {
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProjectId}`);
|
||||
}
|
||||
}, [input, selectedProject]);
|
||||
}, [input, selectedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) {
|
||||
@@ -854,15 +944,11 @@ 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,
|
||||
];
|
||||
@@ -880,7 +966,7 @@ export function useChatComposerState({
|
||||
sessionId: targetSessionId,
|
||||
provider,
|
||||
});
|
||||
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
|
||||
}, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]);
|
||||
|
||||
const handleGrantToolPermission = useCallback(
|
||||
(suggestion: { entry: string; toolName: string }) => {
|
||||
@@ -978,5 +1064,7 @@ export function useChatComposerState({
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
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 } from '../../../types/app';
|
||||
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',
|
||||
};
|
||||
|
||||
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
|
||||
if (provider === 'codex') {
|
||||
@@ -11,33 +24,242 @@ 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;
|
||||
}
|
||||
|
||||
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
|
||||
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) {
|
||||
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') || CURSOR_MODELS.DEFAULT;
|
||||
return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor;
|
||||
});
|
||||
const [claudeModel, setClaudeModel] = useState<string>(() => {
|
||||
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
|
||||
return localStorage.getItem('claude-model') || FALLBACK_DEFAULT_MODEL.claude;
|
||||
});
|
||||
const [codexModel, setCodexModel] = useState<string>(() => {
|
||||
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
||||
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
|
||||
});
|
||||
const [geminiModel, setGeminiModel] = useState<string>(() => {
|
||||
return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT;
|
||||
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
|
||||
});
|
||||
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) {
|
||||
@@ -107,6 +329,41 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||
}
|
||||
}, [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,
|
||||
@@ -118,10 +375,18 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
permissionMode,
|
||||
setPermissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
cyclePermissionMode,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsLoading,
|
||||
providerModelsRefreshing,
|
||||
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
|
||||
selectProviderModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -63,6 +62,7 @@ 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,6 +89,7 @@ export function useChatRealtimeHandlers({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
@@ -104,7 +105,7 @@ export function useChatRealtimeHandlers({
|
||||
lastProcessedMessageRef.current = latestMessage;
|
||||
|
||||
const activeViewSessionId =
|
||||
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
|
||||
selectedSession?.id || currentSessionId || null;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Legacy messages (no `kind` field) — handle and return */
|
||||
@@ -151,10 +152,12 @@ 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) {
|
||||
@@ -235,15 +238,21 @@ 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;
|
||||
}
|
||||
@@ -266,6 +275,7 @@ export function useChatRealtimeHandlers({
|
||||
setPendingPermissionRequests([]);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
|
||||
// Handle aborted case
|
||||
if (msg.aborted) {
|
||||
@@ -279,16 +289,10 @@ 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 === pendingSessionId
|
||||
|| pendingViewSessionRef.current?.sessionId === sid
|
||||
),
|
||||
&& sid === activeViewSessionId,
|
||||
);
|
||||
|
||||
if (actualSessionId && sid && actualSessionId !== sid) {
|
||||
@@ -296,17 +300,6 @@ 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) {
|
||||
@@ -316,16 +309,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -335,6 +318,7 @@ export function useChatRealtimeHandlers({
|
||||
setClaudeStatus(null);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -399,6 +383,7 @@ export function useChatRealtimeHandlers({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
|
||||
@@ -13,7 +13,6 @@ const MESSAGES_PER_PAGE = 20;
|
||||
const INITIAL_VISIBLE_MESSAGES = 100;
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -160,7 +159,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 pending session IDs in sessionStorage.
|
||||
* pagination/scroll bookkeeping, and provider-specific sessionStorage keys.
|
||||
* - 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".
|
||||
@@ -177,7 +176,6 @@ export function useChatSessionState({
|
||||
setIsLoading(false);
|
||||
setCurrentSessionId(null);
|
||||
setPendingUserMessage(null);
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
@@ -396,6 +394,12 @@ 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);
|
||||
@@ -537,10 +541,6 @@ 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;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { LLMProvider, Project } from '../../../types/app';
|
||||
|
||||
const COMMAND_QUERY_DEBOUNCE_MS = 150;
|
||||
|
||||
@@ -12,19 +12,37 @@ export interface SlashCommand {
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
path?: string;
|
||||
type?: string;
|
||||
type?: 'built-in' | 'custom' | 'skill' | string;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseSlashCommandsOptions {
|
||||
selectedProject: Project | null;
|
||||
provider: LLMProvider;
|
||||
input: string;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
type ProviderSkill = {
|
||||
name: string;
|
||||
description?: string;
|
||||
command: string;
|
||||
scope: string;
|
||||
sourcePath?: string;
|
||||
pluginName?: string;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
type ProviderSkillsResponse = {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
skills?: ProviderSkill[];
|
||||
};
|
||||
};
|
||||
|
||||
const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;
|
||||
|
||||
const readCommandHistory = (projectName: string): Record<string, number> => {
|
||||
@@ -48,8 +66,78 @@ const saveCommandHistory = (projectName: string, history: Record<string, number>
|
||||
const isPromiseLike = (value: unknown): value is Promise<unknown> =>
|
||||
Boolean(value) && typeof (value as Promise<unknown>).then === 'function';
|
||||
|
||||
const isSkillCommand = (command: SlashCommand) =>
|
||||
command.type === 'skill' || command.metadata?.type === 'skill';
|
||||
|
||||
const dedupeProviderSkills = (skills: ProviderSkill[]): ProviderSkill[] => {
|
||||
const seenCommands = new Set<string>();
|
||||
|
||||
return skills.filter((skill) => {
|
||||
// Multiple physical Claude plugin folders can expose the same invocation.
|
||||
// The slash menu should show each executable command only once.
|
||||
const key = skill.command;
|
||||
if (seenCommands.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenCommands.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const mapSkillToSlashCommand = (skill: ProviderSkill): SlashCommand => ({
|
||||
name: skill.command,
|
||||
description: skill.description,
|
||||
namespace: 'skill',
|
||||
path: skill.sourcePath,
|
||||
type: 'skill',
|
||||
metadata: {
|
||||
type: skill.scope,
|
||||
scope: skill.scope,
|
||||
sourcePath: skill.sourcePath,
|
||||
pluginName: skill.pluginName,
|
||||
pluginId: skill.pluginId,
|
||||
skillName: skill.name,
|
||||
},
|
||||
});
|
||||
|
||||
const filterSlashCommands = (
|
||||
commands: SlashCommand[],
|
||||
query: string,
|
||||
): SlashCommand[] => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return commands;
|
||||
}
|
||||
|
||||
const commandPrefix = normalizedQuery.startsWith('/')
|
||||
? normalizedQuery
|
||||
: `/${normalizedQuery}`;
|
||||
const namePrefixMatches = commands.filter((command) =>
|
||||
command.name.toLowerCase().startsWith(commandPrefix),
|
||||
);
|
||||
|
||||
// Namespaced commands should behave like path completion. Once a provider
|
||||
// namespace is typed, only exact command-prefix matches should stay visible.
|
||||
if (normalizedQuery.includes(':') || namePrefixMatches.length > 0) {
|
||||
return namePrefixMatches;
|
||||
}
|
||||
|
||||
const nameSubstringMatches = commands.filter((command) =>
|
||||
command.name.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
if (nameSubstringMatches.length > 0) {
|
||||
return nameSubstringMatches;
|
||||
}
|
||||
|
||||
return commands.filter((command) =>
|
||||
command.description?.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
};
|
||||
|
||||
export function useSlashCommands({
|
||||
selectedProject,
|
||||
provider,
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
@@ -80,6 +168,8 @@ export function useSlashCommands({
|
||||
}, [clearCommandQueryTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchCommands = async () => {
|
||||
if (!selectedProject) {
|
||||
setSlashCommands([]);
|
||||
@@ -88,13 +178,14 @@ export function useSlashCommands({
|
||||
}
|
||||
|
||||
try {
|
||||
const workspacePath = selectedProject.fullPath || selectedProject.path || '';
|
||||
const response = await authenticatedFetch('/api/commands/list', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectPath: selectedProject.path,
|
||||
projectPath: workspacePath || selectedProject.path,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -103,11 +194,25 @@ export function useSlashCommands({
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const skillsParams = new URLSearchParams();
|
||||
if (workspacePath) {
|
||||
skillsParams.set('workspacePath', workspacePath);
|
||||
}
|
||||
|
||||
const skillsResponse = await authenticatedFetch(
|
||||
`/api/providers/${encodeURIComponent(provider)}/skills${skillsParams.toString() ? `?${skillsParams.toString()}` : ''}`,
|
||||
);
|
||||
const skillsData = skillsResponse.ok
|
||||
? ((await skillsResponse.json()) as ProviderSkillsResponse)
|
||||
: null;
|
||||
const skillCommands = dedupeProviderSkills(skillsData?.data?.skills || [])
|
||||
.map(mapSkillToSlashCommand);
|
||||
const allCommands: SlashCommand[] = [
|
||||
...((data.builtIn || []) as SlashCommand[]).map((command) => ({
|
||||
...command,
|
||||
type: 'built-in',
|
||||
})),
|
||||
...skillCommands,
|
||||
...((data.custom || []) as SlashCommand[]).map((command) => ({
|
||||
...command,
|
||||
type: 'custom',
|
||||
@@ -121,15 +226,22 @@ export function useSlashCommands({
|
||||
return commandBUsage - commandAUsage;
|
||||
});
|
||||
|
||||
setSlashCommands(sortedCommands);
|
||||
if (!cancelled) {
|
||||
setSlashCommands(sortedCommands);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching slash commands:', error);
|
||||
setSlashCommands([]);
|
||||
if (!cancelled) {
|
||||
setSlashCommands([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchCommands();
|
||||
}, [selectedProject]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProject, provider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCommandMenu) {
|
||||
@@ -137,36 +249,9 @@ export function useSlashCommands({
|
||||
}
|
||||
}, [showCommandMenu]);
|
||||
|
||||
const fuse = useMemo(() => {
|
||||
if (!slashCommands.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Fuse(slashCommands, {
|
||||
keys: [
|
||||
{ name: 'name', weight: 2 },
|
||||
{ name: 'description', weight: 1 },
|
||||
],
|
||||
threshold: 0.4,
|
||||
includeScore: true,
|
||||
minMatchCharLength: 1,
|
||||
});
|
||||
}, [slashCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandQuery) {
|
||||
setFilteredCommands(slashCommands);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fuse) {
|
||||
setFilteredCommands([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = fuse.search(commandQuery);
|
||||
setFilteredCommands(results.map((result) => result.item));
|
||||
}, [commandQuery, slashCommands, fuse]);
|
||||
setFilteredCommands(filterSlashCommands(slashCommands, commandQuery));
|
||||
}, [commandQuery, slashCommands]);
|
||||
|
||||
const frequentCommands = useMemo(() => {
|
||||
if (!selectedProject || slashCommands.length === 0) {
|
||||
@@ -198,25 +283,63 @@ export function useSlashCommands({
|
||||
[selectedProject],
|
||||
);
|
||||
|
||||
const selectCommandFromKeyboard = useCallback(
|
||||
const insertCommandIntoInput = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
const textBeforeSlash = input.slice(0, slashPosition);
|
||||
const textAfterSlash = input.slice(slashPosition);
|
||||
const spaceIndex = textAfterSlash.indexOf(' ');
|
||||
const textAfterQuery = spaceIndex !== -1 ? textAfterSlash.slice(spaceIndex) : '';
|
||||
const newInput = `${textBeforeSlash}${command.name} ${textAfterQuery}`;
|
||||
const currentTextarea = textareaRef.current;
|
||||
const insertionStart = slashPosition >= 0
|
||||
? slashPosition
|
||||
: currentTextarea?.selectionStart ?? input.length;
|
||||
const textBeforeCommand = input.slice(0, insertionStart);
|
||||
const textAfterCommandStart = input.slice(insertionStart);
|
||||
const spaceIndex = textAfterCommandStart.indexOf(' ');
|
||||
const textAfterCommand = slashPosition >= 0 && spaceIndex !== -1
|
||||
? textAfterCommandStart.slice(spaceIndex).trimStart()
|
||||
: input.slice(currentTextarea?.selectionEnd ?? insertionStart);
|
||||
const separator = textBeforeCommand && !/\s$/.test(textBeforeCommand) ? ' ' : '';
|
||||
const newInput = `${textBeforeCommand}${separator}${command.name}${textAfterCommand ? ` ${textAfterCommand}` : ' '}`;
|
||||
|
||||
setInput(newInput);
|
||||
resetCommandMenuState();
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
currentTextarea?.focus();
|
||||
const nextCursorPosition = `${textBeforeCommand}${separator}${command.name} `.length;
|
||||
currentTextarea?.setSelectionRange(nextCursorPosition, nextCursorPosition);
|
||||
});
|
||||
},
|
||||
[input, resetCommandMenuState, setInput, slashPosition, textareaRef],
|
||||
);
|
||||
|
||||
const executeNonSkillCommand = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
const executionResult = onExecuteCommand(command);
|
||||
if (isPromiseLike(executionResult)) {
|
||||
executionResult.catch(() => {
|
||||
// Keep behavior silent; execution errors are handled by caller.
|
||||
});
|
||||
executionResult.then(
|
||||
() => {
|
||||
resetCommandMenuState();
|
||||
},
|
||||
() => {
|
||||
resetCommandMenuState();
|
||||
// Keep behavior silent; execution errors are handled by caller.
|
||||
},
|
||||
);
|
||||
} else {
|
||||
resetCommandMenuState();
|
||||
}
|
||||
},
|
||||
[input, slashPosition, setInput, resetCommandMenuState, onExecuteCommand],
|
||||
[onExecuteCommand, resetCommandMenuState],
|
||||
);
|
||||
|
||||
const selectCommandFromKeyboard = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
if (isSkillCommand(command)) {
|
||||
insertCommandIntoInput(command);
|
||||
return;
|
||||
}
|
||||
|
||||
executeNonSkillCommand(command);
|
||||
},
|
||||
[executeNonSkillCommand, insertCommandIntoInput],
|
||||
);
|
||||
|
||||
const handleCommandSelect = useCallback(
|
||||
@@ -231,20 +354,14 @@ export function useSlashCommands({
|
||||
}
|
||||
|
||||
trackCommandUsage(command);
|
||||
const executionResult = onExecuteCommand(command);
|
||||
|
||||
if (isPromiseLike(executionResult)) {
|
||||
executionResult.then(() => {
|
||||
resetCommandMenuState();
|
||||
});
|
||||
executionResult.catch(() => {
|
||||
// Keep behavior silent; execution errors are handled by caller.
|
||||
});
|
||||
} else {
|
||||
resetCommandMenuState();
|
||||
if (isSkillCommand(command)) {
|
||||
insertCommandIntoInput(command);
|
||||
return;
|
||||
}
|
||||
|
||||
executeNonSkillCommand(command);
|
||||
},
|
||||
[selectedProject, trackCommandUsage, onExecuteCommand, resetCommandMenuState],
|
||||
[selectedProject, trackCommandUsage, insertCommandIntoInput, executeNonSkillCommand],
|
||||
);
|
||||
|
||||
const handleToggleCommandMenu = useCallback(() => {
|
||||
@@ -276,7 +393,7 @@ export function useSlashCommands({
|
||||
return;
|
||||
}
|
||||
|
||||
const slashPattern = /(^|\s)\/(\S*)$/;
|
||||
const slashPattern = /^\/(\S*)$/;
|
||||
const match = textBeforeCursor.match(slashPattern);
|
||||
|
||||
if (!match) {
|
||||
@@ -284,8 +401,8 @@ export function useSlashCommands({
|
||||
return;
|
||||
}
|
||||
|
||||
const slashPos = (match.index || 0) + match[1].length;
|
||||
const query = match[2];
|
||||
const slashPos = 0;
|
||||
const query = match[1];
|
||||
|
||||
setSlashPosition(slashPos);
|
||||
setShowCommandMenu(true);
|
||||
|
||||
@@ -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,19 +72,26 @@ 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,
|
||||
@@ -170,7 +177,9 @@ function ChatInterface({
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
isInputFocused: _isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
} = useChatComposerState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
@@ -182,6 +191,7 @@ function ChatInterface({
|
||||
claudeModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
@@ -195,8 +205,6 @@ function ChatInterface({
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
@@ -234,6 +242,7 @@ function ChatInterface({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
@@ -280,6 +289,8 @@ function ChatInterface({
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude');
|
||||
|
||||
return (
|
||||
@@ -318,6 +329,10 @@ function ChatInterface({
|
||||
setCodexModel={setCodexModel}
|
||||
geminiModel={geminiModel}
|
||||
setGeminiModel={setGeminiModel}
|
||||
opencodeModel={opencodeModel}
|
||||
setOpenCodeModel={setOpenCodeModel}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelsLoading={providerModelsLoading}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
@@ -406,6 +421,8 @@ function ChatInterface({
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude'),
|
||||
})}
|
||||
isTextareaExpanded={isTextareaExpanded}
|
||||
@@ -414,6 +431,17 @@ 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,9 +1,16 @@
|
||||
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 } from '../../../../types/app';
|
||||
import type {
|
||||
Project,
|
||||
ProjectSession,
|
||||
LLMProvider,
|
||||
ProviderModelsDefinition,
|
||||
} from '../../../../types/app';
|
||||
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||
|
||||
import MessageComponent from './MessageComponent';
|
||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||
|
||||
@@ -26,6 +33,10 @@ 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;
|
||||
@@ -71,6 +82,10 @@ export default function ChatMessagesPane({
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
providerModelCatalog,
|
||||
providerModelsLoading,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
@@ -154,6 +169,10 @@ 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,6 +29,7 @@ const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
||||
codex: 'messageTypes.codex',
|
||||
cursor: 'messageTypes.cursor',
|
||||
gemini: 'messageTypes.gemini',
|
||||
opencode: 'messageTypes.opencode',
|
||||
};
|
||||
|
||||
function formatElapsedTime(totalSeconds: number) {
|
||||
@@ -126,4 +127,4 @@ export default function ClaudeStatus({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import {
|
||||
CornerDownLeft,
|
||||
Folder,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
Star,
|
||||
Terminal,
|
||||
User,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
type CommandMenuCommand = {
|
||||
name: string;
|
||||
@@ -21,59 +31,92 @@ type CommandMenuProps = {
|
||||
frequentCommands?: CommandMenuCommand[];
|
||||
};
|
||||
|
||||
type CommandMenuRow = {
|
||||
command: CommandMenuCommand;
|
||||
commandIndex: number;
|
||||
renderKey: string;
|
||||
};
|
||||
|
||||
const menuBaseStyle: CSSProperties = {
|
||||
maxHeight: '300px',
|
||||
maxHeight: '360px',
|
||||
overflowY: 'auto',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
boxShadow: '0 24px 60px rgba(2, 6, 23, 0.38), 0 0 0 1px rgba(148, 163, 184, 0.12)',
|
||||
zIndex: 1000,
|
||||
padding: '8px',
|
||||
padding: '6px',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
backdropFilter: 'blur(12px)',
|
||||
};
|
||||
|
||||
const namespaceLabels: Record<string, string> = {
|
||||
frequent: 'Frequently Used',
|
||||
builtin: 'Built-in Commands',
|
||||
skill: 'Skills',
|
||||
project: 'Project Commands',
|
||||
user: 'User Commands',
|
||||
other: 'Other Commands',
|
||||
};
|
||||
|
||||
const namespaceIcons: Record<string, string> = {
|
||||
frequent: '[*]',
|
||||
builtin: '[B]',
|
||||
project: '[P]',
|
||||
user: '[U]',
|
||||
other: '[O]',
|
||||
const namespaceIcons: Record<string, LucideIcon> = {
|
||||
frequent: Star,
|
||||
builtin: Terminal,
|
||||
skill: Sparkles,
|
||||
project: Folder,
|
||||
user: User,
|
||||
other: MessageSquare,
|
||||
};
|
||||
|
||||
const namespaceAccentClasses: Record<string, string> = {
|
||||
frequent: 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200',
|
||||
builtin: 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-400/20 dark:bg-sky-400/10 dark:text-sky-200',
|
||||
skill: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/20 dark:bg-emerald-400/10 dark:text-emerald-200',
|
||||
project: 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-400/20 dark:bg-indigo-400/10 dark:text-indigo-200',
|
||||
user: 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200',
|
||||
other: 'border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-500/20 dark:bg-gray-500/10 dark:text-gray-200',
|
||||
};
|
||||
|
||||
const MENU_EDGE_GAP = 16;
|
||||
const MENU_MAX_HEIGHT = 360;
|
||||
|
||||
const getCommandKey = (command: CommandMenuCommand) =>
|
||||
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
||||
|
||||
const getNamespace = (command: CommandMenuCommand) => command.namespace || command.type || 'other';
|
||||
|
||||
const getNamespaceIcon = (namespace: string) => namespaceIcons[namespace] || namespaceIcons.other;
|
||||
|
||||
const getNamespaceAccentClass = (namespace: string) =>
|
||||
namespaceAccentClasses[namespace] || namespaceAccentClasses.other;
|
||||
|
||||
const getMenuPosition = (position: { top: number; left: number; bottom?: number }): CSSProperties => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { position: 'fixed', top: '16px', left: '16px' };
|
||||
}
|
||||
if (window.innerWidth < 640) {
|
||||
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: `${position.bottom ?? 90}px`,
|
||||
bottom: `${anchorBottom}px`,
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
width: 'auto',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: 'min(50vh, 300px)',
|
||||
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
|
||||
};
|
||||
}
|
||||
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
||||
const clampedLeft = Math.max(
|
||||
MENU_EDGE_GAP,
|
||||
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
|
||||
);
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${Math.max(16, Math.min(position.top, window.innerHeight - 316))}px`,
|
||||
left: `${position.left}px`,
|
||||
width: 'min(400px, calc(100vw - 32px))',
|
||||
bottom: `${anchorBottom}px`,
|
||||
left: `${clampedLeft}px`,
|
||||
width: 'min(440px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: '300px',
|
||||
maxHeight: `min(${MENU_MAX_HEIGHT}px, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -123,7 +166,24 @@ export default function CommandMenu({
|
||||
|
||||
const hasFrequentCommands = frequentCommands.length > 0;
|
||||
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
|
||||
const groupedCommands = commands.reduce<Record<string, CommandMenuCommand[]>>((groups, command) => {
|
||||
const commandIndexesByKey = new Map<string, number[]>();
|
||||
commands.forEach((command, index) => {
|
||||
const key = getCommandKey(command);
|
||||
const commandIndexes = commandIndexesByKey.get(key) ?? [];
|
||||
commandIndexes.push(index);
|
||||
commandIndexesByKey.set(key, commandIndexes);
|
||||
});
|
||||
const frequentCommandOccurrences = new Map<string, number>();
|
||||
const getFrequentCommandIndex = (command: CommandMenuCommand): number => {
|
||||
const key = getCommandKey(command);
|
||||
const occurrence = frequentCommandOccurrences.get(key) ?? 0;
|
||||
frequentCommandOccurrences.set(key, occurrence + 1);
|
||||
|
||||
const commandIndexes = commandIndexesByKey.get(key) ?? [];
|
||||
return commandIndexes[occurrence] ?? commandIndexes[0] ?? -1;
|
||||
};
|
||||
|
||||
const groupedCommands = commands.reduce<Record<string, CommandMenuRow[]>>((groups, command, index) => {
|
||||
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
|
||||
return groups;
|
||||
}
|
||||
@@ -131,33 +191,46 @@ export default function CommandMenu({
|
||||
if (!groups[namespace]) {
|
||||
groups[namespace] = [];
|
||||
}
|
||||
groups[namespace].push(command);
|
||||
groups[namespace].push({
|
||||
command,
|
||||
commandIndex: index,
|
||||
renderKey: `${namespace}-${index}-${getCommandKey(command)}`,
|
||||
});
|
||||
return groups;
|
||||
}, {});
|
||||
if (hasFrequentCommands) {
|
||||
groupedCommands.frequent = frequentCommands;
|
||||
groupedCommands.frequent = frequentCommands
|
||||
.map((command, index) => {
|
||||
const commandIndex = getFrequentCommandIndex(command);
|
||||
return {
|
||||
command,
|
||||
commandIndex,
|
||||
renderKey: `frequent-${index}-${commandIndex}-${getCommandKey(command)}`,
|
||||
};
|
||||
})
|
||||
.filter((row) => row.commandIndex >= 0);
|
||||
}
|
||||
|
||||
const preferredOrder = hasFrequentCommands
|
||||
? ['frequent', 'builtin', 'project', 'user', 'other']
|
||||
: ['builtin', 'project', 'user', 'other'];
|
||||
? ['frequent', 'builtin', 'skill', 'project', 'user', 'other']
|
||||
: ['builtin', 'skill', 'project', 'user', 'other'];
|
||||
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
|
||||
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
|
||||
|
||||
const commandIndexByKey = new Map<string, number>();
|
||||
commands.forEach((command, index) => {
|
||||
const key = getCommandKey(command);
|
||||
if (!commandIndexByKey.has(key)) {
|
||||
commandIndexByKey.set(key, index);
|
||||
}
|
||||
});
|
||||
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="command-menu command-menu-empty border border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"
|
||||
style={{ ...menuPosition, ...menuBaseStyle, overflowY: 'hidden', padding: '20px', opacity: 1, transform: 'translateY(0)', textAlign: 'center' }}
|
||||
className="command-menu command-menu-empty border border-gray-200 bg-white/95 text-sm text-gray-500 dark:border-gray-700/80 dark:bg-gray-900/95 dark:text-gray-400"
|
||||
style={{
|
||||
...menuBaseStyle,
|
||||
...menuPosition,
|
||||
overflowY: 'hidden',
|
||||
padding: '20px',
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
No commands available
|
||||
</div>
|
||||
@@ -169,51 +242,73 @@ export default function CommandMenu({
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
aria-label="Available commands"
|
||||
className="command-menu border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
||||
style={{ ...menuPosition, ...menuBaseStyle, opacity: 1, transform: 'translateY(0)' }}
|
||||
className="command-menu border border-gray-200/90 bg-white/95 text-gray-900 dark:border-slate-700/80 dark:bg-slate-950/95 dark:text-slate-100"
|
||||
style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }}
|
||||
>
|
||||
{orderedNamespaces.map((namespace) => (
|
||||
<div key={namespace} className="command-group">
|
||||
{orderedNamespaces.length > 1 && (
|
||||
<div className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{namespaceLabels[namespace] || namespace}
|
||||
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400">
|
||||
<span>{namespaceLabels[namespace] || namespace}</span>
|
||||
<span className="rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] text-gray-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
||||
{(groupedCommands[namespace] || []).length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(groupedCommands[namespace] || []).map((command) => {
|
||||
const commandKey = getCommandKey(command);
|
||||
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
|
||||
{(groupedCommands[namespace] || []).map(({ command, commandIndex, renderKey }) => {
|
||||
const isSelected = commandIndex === selectedIndex;
|
||||
const NamespaceIcon = getNamespaceIcon(namespace);
|
||||
const accentClass = getNamespaceAccentClass(namespace);
|
||||
return (
|
||||
<div
|
||||
key={`${namespace}-${command.name}-${command.path || ''}`}
|
||||
key={renderKey}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={`command-item mb-0.5 flex cursor-pointer items-start rounded-md px-3 py-2.5 transition-colors ${
|
||||
isSelected ? 'bg-blue-50 dark:bg-blue-900' : 'bg-transparent'
|
||||
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-sky-200 bg-sky-50 shadow-sm dark:border-cyan-400/30 dark:bg-cyan-400/10'
|
||||
: 'border-transparent bg-transparent hover:border-gray-200 hover:bg-gray-50/90 dark:hover:border-slate-700 dark:hover:bg-slate-900/80'
|
||||
}`}
|
||||
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
|
||||
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`flex items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
|
||||
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-300">{namespaceIcons[namespace] || namespaceIcons.other}</span>
|
||||
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-gray-100">{command.name}</span>
|
||||
{isSelected && (
|
||||
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-sky-500 dark:bg-cyan-300" />
|
||||
)}
|
||||
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}>
|
||||
<NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pr-1">
|
||||
<div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
|
||||
<span
|
||||
className="min-w-0 truncate font-mono text-[13px] font-semibold text-gray-950 dark:text-slate-50"
|
||||
title={command.name}
|
||||
>
|
||||
{command.name}
|
||||
</span>
|
||||
{command.metadata?.type && (
|
||||
<span className="command-metadata-badge rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300">
|
||||
<span className="command-metadata-badge shrink-0 rounded border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
|
||||
{command.metadata.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{command.description && (
|
||||
<div className="ml-6 truncate whitespace-nowrap text-[13px] text-gray-500 dark:text-gray-300">
|
||||
<div
|
||||
className="truncate whitespace-nowrap text-[12px] leading-4 text-gray-500 dark:text-slate-400"
|
||||
title={command.description}
|
||||
>
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && <span className="ml-2 text-xs font-semibold text-blue-500 dark:text-blue-300">{'<-'}</span>}
|
||||
{isSelected && (
|
||||
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-sky-200 bg-white text-sky-600 shadow-sm dark:border-cyan-400/30 dark:bg-slate-950 dark:text-cyan-200">
|
||||
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
731
src/components/chat/view/subcomponents/CommandResultModal.tsx
Normal file
731
src/components/chat/view/subcomponents/CommandResultModal.tsx
Normal file
@@ -0,0 +1,731 @@
|
||||
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,7 +176,19 @@ 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') : 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')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude'))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
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,
|
||||
@@ -27,6 +23,14 @@ 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";
|
||||
|
||||
@@ -44,6 +48,10 @@ 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;
|
||||
@@ -53,20 +61,15 @@ type ProviderSelectionEmptyStateProps = {
|
||||
type ProviderGroup = {
|
||||
id: LLMProvider;
|
||||
name: string;
|
||||
models: { value: string; label: string }[];
|
||||
models: { value: string; label: string; description?: string }[];
|
||||
};
|
||||
|
||||
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 getModelConfig(
|
||||
p: LLMProvider,
|
||||
catalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>,
|
||||
): ProviderModelsDefinition {
|
||||
const entry = catalog[p];
|
||||
return entry ?? { OPTIONS: [], DEFAULT: "" };
|
||||
}
|
||||
|
||||
function getCurrentModel(
|
||||
@@ -75,10 +78,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -86,6 +91,7 @@ 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";
|
||||
}
|
||||
|
||||
@@ -103,26 +109,25 @@ export default function ProviderSelectionEmptyState({
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
providerModelCatalog,
|
||||
providerModelsLoading,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
setInput,
|
||||
}: ProviderSelectionEmptyStateProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const { isWindowsServer } = useServerPlatform();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const visibleProviderGroups = useMemo(
|
||||
() => (isWindowsServer ? PROVIDER_GROUPS.filter((p) => p.id !== "cursor") : PROVIDER_GROUPS),
|
||||
[isWindowsServer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWindowsServer && provider === "cursor") {
|
||||
setProvider("claude");
|
||||
localStorage.setItem("selected-provider", "claude");
|
||||
}
|
||||
}, [isWindowsServer, provider, setProvider]);
|
||||
const visibleProviderGroups = useMemo<ProviderGroup[]>(() => {
|
||||
return PROVIDER_META.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
models: providerModelCatalog[p.id]?.OPTIONS ?? [],
|
||||
}));
|
||||
}, [providerModelCatalog]);
|
||||
|
||||
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
|
||||
defaultValue: "Start the next task",
|
||||
@@ -134,15 +139,16 @@ export default function ProviderSelectionEmptyState({
|
||||
cursorModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
);
|
||||
|
||||
const currentModelLabel = useMemo(() => {
|
||||
const config = getModelConfig(provider);
|
||||
const config = getModelConfig(provider, providerModelCatalog);
|
||||
const found = config.OPTIONS.find(
|
||||
(o: { value: string; label: string }) => o.value === currentModel,
|
||||
);
|
||||
return found?.label || currentModel;
|
||||
}, [provider, currentModel]);
|
||||
}, [provider, currentModel, providerModelCatalog]);
|
||||
|
||||
const setModelForProvider = useCallback(
|
||||
(providerId: LLMProvider, modelValue: string) => {
|
||||
@@ -155,12 +161,15 @@ 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],
|
||||
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
@@ -222,6 +231,9 @@ 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", {
|
||||
@@ -249,16 +261,28 @@ 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}`}
|
||||
value={`${group.name} ${model.label} ${model.description || ''}`}
|
||||
onSelect={() => handleModelSelect(group.id, model.value)}
|
||||
className="ml-4 border-l border-border/40 pl-4"
|
||||
>
|
||||
<span className="flex-1 truncate">{model.label}</span>
|
||||
<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>
|
||||
{isSelected && (
|
||||
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
@@ -287,6 +311,10 @@ 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,6 +14,7 @@ interface SessionsResponse {
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
geminiSessions?: ProjectSession[];
|
||||
opencodeSessions?: ProjectSession[];
|
||||
}
|
||||
|
||||
export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
|
||||
@@ -33,6 +34,7 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea
|
||||
...(data.cursorSessions ?? []),
|
||||
...(data.codexSessions ?? []),
|
||||
...(data.geminiSessions ?? []),
|
||||
...(data.opencodeSessions ?? []),
|
||||
];
|
||||
return all.map<SessionResult>((s) => ({
|
||||
id: s.id,
|
||||
|
||||
25
src/components/llm-logo-provider/OpenCodeLogo.tsx
Normal file
25
src/components/llm-logo-provider/OpenCodeLogo.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
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,6 +3,7 @@ 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;
|
||||
@@ -25,5 +26,9 @@ export default function SessionProviderLogo({
|
||||
return <GeminiLogo className={className} />;
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
return <OpenCodeLogo className={className} />;
|
||||
}
|
||||
|
||||
return <ClaudeLogo className={className} />;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
|
||||
@@ -12,6 +13,7 @@ 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[]> = {
|
||||
@@ -19,6 +21,7 @@ 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'];
|
||||
@@ -30,6 +33,7 @@ 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> = {
|
||||
@@ -37,6 +41,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||
cursor: false,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_MCP_FORM: McpFormState = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LLMProvider } from '../../../../types/app';
|
||||
import type { ProviderAuthStatusMap } from '../../../provider-auth/types';
|
||||
|
||||
import AgentConnectionCard from './AgentConnectionCard';
|
||||
|
||||
type AgentConnectionsStepProps = {
|
||||
@@ -36,6 +37,13 @@ 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({
|
||||
|
||||
@@ -1,12 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Clock,
|
||||
Download,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ServerCrash,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { usePlugins } from '../../../contexts/PluginsContext';
|
||||
import type { Plugin } from '../../../contexts/PluginsContext';
|
||||
|
||||
import PluginIcon from './PluginIcon';
|
||||
|
||||
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
|
||||
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
|
||||
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
|
||||
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
|
||||
|
||||
type PluginRecommendation = {
|
||||
id: string;
|
||||
translationKey: string;
|
||||
repoUrl: string;
|
||||
installedNames: string[];
|
||||
icon: LucideIcon;
|
||||
source: 'official' | 'unofficial';
|
||||
};
|
||||
|
||||
const OFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
|
||||
{
|
||||
id: 'project-stats',
|
||||
translationKey: 'starterPlugin',
|
||||
repoUrl: STARTER_PLUGIN_URL,
|
||||
installedNames: ['project-stats'],
|
||||
icon: BarChart3,
|
||||
source: 'official',
|
||||
},
|
||||
{
|
||||
id: 'web-terminal',
|
||||
translationKey: 'terminalPlugin',
|
||||
repoUrl: TERMINAL_PLUGIN_URL,
|
||||
installedNames: ['web-terminal'],
|
||||
icon: Terminal,
|
||||
source: 'official',
|
||||
},
|
||||
];
|
||||
|
||||
const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
|
||||
{
|
||||
id: 'cloudcli-claude-watch',
|
||||
translationKey: 'claudeWatchPlugin',
|
||||
repoUrl: CLAUDE_WATCH_PLUGIN_URL,
|
||||
installedNames: ['cloudcli-claude-watch'],
|
||||
icon: Activity,
|
||||
source: 'unofficial',
|
||||
},
|
||||
{
|
||||
id: 'workspace-scheduled-prompts',
|
||||
translationKey: 'scheduledPromptPlugin',
|
||||
repoUrl: SCHEDULED_PROMPT_PLUGIN_URL,
|
||||
installedNames: ['workspace-scheduled-prompts'],
|
||||
icon: Clock,
|
||||
source: 'unofficial',
|
||||
},
|
||||
];
|
||||
|
||||
function repoSlug(repoUrl: string) {
|
||||
return repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, '');
|
||||
}
|
||||
|
||||
function normalizeRepoUrl(repoUrl: string | null) {
|
||||
return repoUrl?.replace(/\.git$/, '').replace(/\/$/, '').toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function pluginMatchesRecommendation(plugin: Plugin, recommendation: PluginRecommendation) {
|
||||
return (
|
||||
recommendation.installedNames.includes(plugin.name)
|
||||
|| normalizeRepoUrl(plugin.repoUrl) === normalizeRepoUrl(recommendation.repoUrl)
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */
|
||||
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
|
||||
@@ -208,117 +289,95 @@ function PluginCard({
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */
|
||||
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
/* ─── Recommendation Section ────────────────────────────────────────────── */
|
||||
function RecommendationSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
||||
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
|
||||
<div className="min-w-0 flex-1 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold leading-none text-foreground">
|
||||
{t('pluginSettings.starterPlugin.name')}
|
||||
</span>
|
||||
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
|
||||
{t('pluginSettings.starterPlugin.badge')}
|
||||
</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{t('pluginSettings.tab')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
||||
{t('pluginSettings.starterPlugin.description')}
|
||||
</p>
|
||||
<a
|
||||
href={STARTER_PLUGIN_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
cloudcli-ai/cloudcli-plugin-starter
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onInstall}
|
||||
disabled={installing}
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{installing ? t('pluginSettings.installing') : t('pluginSettings.starterPlugin.install')}
|
||||
</button>
|
||||
</div>
|
||||
<section className="space-y-2">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground/70">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Terminal Plugin Card ──────────────────────────────────────────────── */
|
||||
function TerminalPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
|
||||
/* ─── Plugin Recommendation Card ────────────────────────────────────────── */
|
||||
function PluginRecommendationCard({
|
||||
recommendation,
|
||||
onInstall,
|
||||
disabled,
|
||||
installing,
|
||||
}: {
|
||||
recommendation: PluginRecommendation;
|
||||
onInstall: () => void;
|
||||
disabled: boolean;
|
||||
installing: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('settings');
|
||||
const Icon = recommendation.icon;
|
||||
const isOfficial = recommendation.source === 'official';
|
||||
const accentClass = isOfficial ? 'bg-blue-500/30' : 'bg-amber-500/40';
|
||||
const hoverClass = isOfficial ? 'hover:border-blue-400 dark:hover:border-blue-500' : 'hover:border-amber-400 dark:hover:border-amber-500';
|
||||
const iconClass = isOfficial ? 'text-blue-500' : 'text-amber-500';
|
||||
|
||||
return (
|
||||
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
||||
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
|
||||
<div className={`relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 ${hoverClass}`}>
|
||||
<div className={`w-[3px] flex-shrink-0 ${accentClass}`} />
|
||||
<div className="min-w-0 flex-1 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M7 8l4 4-4 4"/>
|
||||
<line x1="13" y1="16" x2="17" y2="16"/>
|
||||
</svg>
|
||||
<div className={`h-5 w-5 flex-shrink-0 ${iconClass}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold leading-none text-foreground">
|
||||
{t('pluginSettings.terminalPlugin.name')}
|
||||
</span>
|
||||
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
|
||||
{t('pluginSettings.terminalPlugin.badge')}
|
||||
{t(`pluginSettings.${recommendation.translationKey}.name`)}
|
||||
</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{t('pluginSettings.tab')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
||||
{t('pluginSettings.terminalPlugin.description')}
|
||||
{t(`pluginSettings.${recommendation.translationKey}.description`)}
|
||||
</p>
|
||||
<a
|
||||
href={TERMINAL_PLUGIN_URL}
|
||||
href={recommendation.repoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
cloudcli-ai/cloudcli-plugin-terminal
|
||||
{repoSlug(recommendation.repoUrl)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onInstall}
|
||||
disabled={installing}
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{installing ? t('pluginSettings.installing') : t('pluginSettings.terminalPlugin.install')}
|
||||
{installing ? t('pluginSettings.installing') : t(`pluginSettings.${recommendation.translationKey}.install`)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,8 +393,7 @@ export default function PluginSettingsTab() {
|
||||
|
||||
const [gitUrl, setGitUrl] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installingStarter, setInstallingStarter] = useState(false);
|
||||
const [installingTerminal, setInstallingTerminal] = useState(false);
|
||||
const [installingRecommendation, setInstallingRecommendation] = useState<string | null>(null);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
|
||||
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
|
||||
@@ -364,24 +422,18 @@ export default function PluginSettingsTab() {
|
||||
setInstalling(false);
|
||||
};
|
||||
|
||||
const handleInstallStarter = async () => {
|
||||
setInstallingStarter(true);
|
||||
const handleInstallRecommendation = async (recommendation: PluginRecommendation) => {
|
||||
if (installingRecommendation) return;
|
||||
setInstallingRecommendation(recommendation.id);
|
||||
setInstallError(null);
|
||||
const result = await installPlugin(STARTER_PLUGIN_URL);
|
||||
if (!result.success) {
|
||||
setInstallError(result.error || t('pluginSettings.installFailed'));
|
||||
try {
|
||||
const result = await installPlugin(recommendation.repoUrl);
|
||||
if (!result.success) {
|
||||
setInstallError(result.error || t('pluginSettings.installFailed'));
|
||||
}
|
||||
} finally {
|
||||
setInstallingRecommendation(null);
|
||||
}
|
||||
setInstallingStarter(false);
|
||||
};
|
||||
|
||||
const handleInstallTerminal = async () => {
|
||||
setInstallingTerminal(true);
|
||||
setInstallError(null);
|
||||
const result = await installPlugin(TERMINAL_PLUGIN_URL);
|
||||
if (!result.success) {
|
||||
setInstallError(result.error || t('pluginSettings.installFailed'));
|
||||
}
|
||||
setInstallingTerminal(false);
|
||||
};
|
||||
|
||||
const handleUninstall = async (name: string) => {
|
||||
@@ -398,8 +450,50 @@ export default function PluginSettingsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
|
||||
const hasTerminalInstalled = plugins.some((p) => p.name === 'web-terminal');
|
||||
const isRecommendationInstalled = (recommendation: PluginRecommendation) => {
|
||||
return plugins.some((plugin) => pluginMatchesRecommendation(plugin, recommendation));
|
||||
};
|
||||
|
||||
const isOfficialPlugin = (plugin: Plugin) => {
|
||||
return OFFICIAL_PLUGIN_RECOMMENDATIONS.some((recommendation) => (
|
||||
pluginMatchesRecommendation(plugin, recommendation)
|
||||
));
|
||||
};
|
||||
|
||||
const officialPlugins = plugins.filter(isOfficialPlugin);
|
||||
const otherPlugins = plugins.filter((plugin) => !isOfficialPlugin(plugin));
|
||||
const officialRecommendations = OFFICIAL_PLUGIN_RECOMMENDATIONS.filter(
|
||||
(recommendation) => !isRecommendationInstalled(recommendation),
|
||||
);
|
||||
const unofficialRecommendations = UNOFFICIAL_PLUGIN_RECOMMENDATIONS.filter(
|
||||
(recommendation) => !isRecommendationInstalled(recommendation),
|
||||
);
|
||||
const hasOfficialSection = officialPlugins.length > 0 || officialRecommendations.length > 0;
|
||||
const hasOtherSection = otherPlugins.length > 0 || unofficialRecommendations.length > 0;
|
||||
|
||||
const renderPluginCard = (plugin: Plugin, index: number) => {
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
const r = await togglePlugin(plugin.name, enabled);
|
||||
if (!r.success) {
|
||||
setInstallError(r.error || t('pluginSettings.toggleFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PluginCard
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
index={index}
|
||||
onToggle={(enabled) => void handleToggle(enabled)}
|
||||
onUpdate={() => void handleUpdate(plugin.name)}
|
||||
onUninstall={() => void handleUninstall(plugin.name)}
|
||||
updating={updatingPlugins.has(plugin.name)}
|
||||
confirmingUninstall={confirmUninstall === plugin.name}
|
||||
onCancelUninstall={() => setConfirmUninstall(null)}
|
||||
updateError={updateErrors[plugin.name] ?? null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -456,51 +550,49 @@ export default function PluginSettingsTab() {
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Official plugin suggestions — above the list */}
|
||||
{!loading && (!hasStarterInstalled || !hasTerminalInstalled) && (
|
||||
<div className="space-y-2">
|
||||
{!hasStarterInstalled && (
|
||||
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
|
||||
)}
|
||||
{!hasTerminalInstalled && (
|
||||
<TerminalPluginCard onInstall={handleInstallTerminal} installing={installingTerminal} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin List */}
|
||||
{/* Plugin sections */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('pluginSettings.scanningPlugins')}
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('pluginSettings.noPluginsInstalled')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{plugins.map((plugin, index) => {
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
const r = await togglePlugin(plugin.name, enabled);
|
||||
if (!r.success) {
|
||||
setInstallError(r.error || t('pluginSettings.toggleFailed'));
|
||||
}
|
||||
};
|
||||
<div className="space-y-4">
|
||||
{hasOfficialSection && (
|
||||
<RecommendationSection
|
||||
title={t('pluginSettings.sections.officialTitle')}
|
||||
description={t('pluginSettings.sections.officialDescription')}
|
||||
>
|
||||
{officialPlugins.map((plugin, index) => renderPluginCard(plugin, index))}
|
||||
{officialRecommendations.map((recommendation) => (
|
||||
<PluginRecommendationCard
|
||||
key={recommendation.id}
|
||||
recommendation={recommendation}
|
||||
onInstall={() => void handleInstallRecommendation(recommendation)}
|
||||
disabled={!!installingRecommendation}
|
||||
installing={installingRecommendation === recommendation.id}
|
||||
/>
|
||||
))}
|
||||
</RecommendationSection>
|
||||
)}
|
||||
|
||||
return (
|
||||
<PluginCard
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
index={index}
|
||||
onToggle={(enabled) => void handleToggle(enabled)}
|
||||
onUpdate={() => void handleUpdate(plugin.name)}
|
||||
onUninstall={() => void handleUninstall(plugin.name)}
|
||||
updating={updatingPlugins.has(plugin.name)}
|
||||
confirmingUninstall={confirmUninstall === plugin.name}
|
||||
onCancelUninstall={() => setConfirmUninstall(null)}
|
||||
updateError={updateErrors[plugin.name] ?? null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{hasOtherSection && (
|
||||
<RecommendationSection
|
||||
title={t('pluginSettings.sections.unofficialTitle')}
|
||||
description={t('pluginSettings.sections.unofficialDescription')}
|
||||
>
|
||||
{otherPlugins.map((plugin, index) => renderPluginCard(plugin, officialPlugins.length + index))}
|
||||
{unofficialRecommendations.map((recommendation) => (
|
||||
<PluginRecommendationCard
|
||||
key={recommendation.id}
|
||||
recommendation={recommendation}
|
||||
onInstall={() => void handleInstallRecommendation(recommendation)}
|
||||
disabled={!!installingRecommendation}
|
||||
installing={installingRecommendation === recommendation.id}
|
||||
/>
|
||||
))}
|
||||
</RecommendationSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ export type ProviderAuthStatus = {
|
||||
|
||||
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
|
||||
|
||||
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
|
||||
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 => ({
|
||||
@@ -24,4 +25,5 @@ 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,6 +37,10 @@ const getProviderCommand = ({
|
||||
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
return 'opencode auth login';
|
||||
}
|
||||
|
||||
return 'gemini status';
|
||||
};
|
||||
|
||||
@@ -44,6 +48,7 @@ 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'];
|
||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
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';
|
||||
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc';
|
||||
};
|
||||
|
||||
const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||
@@ -31,7 +31,11 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||
gemini: {
|
||||
name: 'Gemini',
|
||||
color: 'indigo',
|
||||
}
|
||||
},
|
||||
opencode: {
|
||||
name: 'OpenCode',
|
||||
color: 'zinc',
|
||||
},
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
@@ -47,6 +51,9 @@ const colorClasses = {
|
||||
indigo: {
|
||||
dot: 'bg-indigo-500',
|
||||
},
|
||||
zinc: {
|
||||
dot: 'bg-zinc-500',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default function AgentListItem({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useServerPlatform } from '../../../../../hooks/useServerPlatform';
|
||||
import type { AgentCategory, AgentProvider } from '../../../types/types';
|
||||
|
||||
import type { AgentContext, AgentsSettingsTabProps } from './types';
|
||||
@@ -23,22 +22,10 @@ export default function AgentsSettingsTab({
|
||||
}: AgentsSettingsTabProps) {
|
||||
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
|
||||
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
|
||||
const { isWindowsServer } = useServerPlatform();
|
||||
|
||||
const visibleAgents = useMemo<AgentProvider[]>(() => {
|
||||
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
if (isWindowsServer) {
|
||||
return all.filter((id) => id !== 'cursor');
|
||||
}
|
||||
|
||||
return all;
|
||||
}, [isWindowsServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWindowsServer && selectedAgent === 'cursor') {
|
||||
setSelectedAgent('claude');
|
||||
}
|
||||
}, [isWindowsServer, selectedAgent]);
|
||||
return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||
}, []);
|
||||
|
||||
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
|
||||
claude: {
|
||||
@@ -57,12 +44,17 @@ 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,6 +8,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
|
||||
export default function AgentSelectorSection({
|
||||
@@ -23,7 +24,8 @@ export default function AgentSelectorSection({
|
||||
const dotColor =
|
||||
agent === 'claude' ? 'bg-blue-500' :
|
||||
agent === 'cursor' ? 'bg-purple-500' :
|
||||
agent === 'gemini' ? 'bg-indigo-500' : 'bg-foreground/60';
|
||||
agent === 'gemini' ? 'bg-indigo-500' :
|
||||
agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60';
|
||||
|
||||
return (
|
||||
<Pill
|
||||
|
||||
@@ -54,6 +54,15 @@ 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) {
|
||||
@@ -66,7 +75,11 @@ 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`)}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(`agents.account.${agent}.description`, {
|
||||
defaultValue: config.description || `${config.name} CLI assistant`,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export type SessionViewModel = {
|
||||
isCursorSession: boolean;
|
||||
isCodexSession: boolean;
|
||||
isGeminiSession: boolean;
|
||||
isOpenCodeSession: boolean;
|
||||
isActive: boolean;
|
||||
sessionName: string;
|
||||
sessionTime: string;
|
||||
|
||||
@@ -85,6 +85,7 @@ 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),
|
||||
@@ -113,7 +114,12 @@ export const getAllSessions = (project: Project): SessionWithProvider[] => {
|
||||
__provider: 'gemini' as const,
|
||||
}));
|
||||
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions].sort(
|
||||
const opencodeSessions = (project.opencodeSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'opencode' as const,
|
||||
}));
|
||||
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...opencodeSessions].sort(
|
||||
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,7 +61,8 @@ const projectsHaveChanges = (
|
||||
return (
|
||||
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
|
||||
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||
|
||||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions)
|
||||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) ||
|
||||
serialize(nextProject.opencodeSessions) !== serialize(prevProject.opencodeSessions)
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -98,6 +99,7 @@ const getProjectSessions = (project: Project): ProjectSession[] => {
|
||||
...(project.codexSessions ?? []),
|
||||
...(project.cursorSessions ?? []),
|
||||
...(project.geminiSessions ?? []),
|
||||
...(project.opencodeSessions ?? []),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -145,6 +147,7 @@ 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);
|
||||
@@ -160,7 +163,7 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
|
||||
|
||||
const mergeProjectSessionPage = (
|
||||
existingProject: Project,
|
||||
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>,
|
||||
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>,
|
||||
): Project => {
|
||||
const mergedProject: Project = {
|
||||
...existingProject,
|
||||
@@ -168,6 +171,7 @@ 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);
|
||||
@@ -555,6 +559,21 @@ 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
|
||||
@@ -583,6 +602,8 @@ export function useProjectsState({
|
||||
? 'codex'
|
||||
: providerFromStorage === 'gemini'
|
||||
? 'gemini'
|
||||
: providerFromStorage === 'opencode'
|
||||
? 'opencode'
|
||||
: 'claude';
|
||||
|
||||
setSelectedSession({
|
||||
@@ -665,12 +686,14 @@ 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) {
|
||||
@@ -683,6 +706,7 @@ export function useProjectsState({
|
||||
cursorSessions,
|
||||
codexSessions,
|
||||
geminiSessions,
|
||||
opencodeSessions,
|
||||
};
|
||||
|
||||
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
|
||||
@@ -776,7 +800,7 @@ export function useProjectsState({
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>;
|
||||
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>;
|
||||
|
||||
let mergedProjectForSelection: Project | null = null;
|
||||
setProjects((previousProjects) =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user