Compare commits

..

5 Commits

Author SHA1 Message Date
Haileyesus
9d291d3efb fix: install claude watch adapter plugin 2026-05-29 20:56:15 +03:00
Haileyesus
6bf82a39bb fix: group plugin settings by source 2026-05-29 19:13:08 +03:00
Haile
3b79aab958 Fix/use fallback models for claude (#806)
* fix: remove the hide cursor on windows logic

* feat(cursor): update fallback models

* fix(claude): force fallback models and disable supportedModels lookup
2026-05-29 13:33:13 +02:00
Haile
997cf9fd1a Feature/update cursor model (#804)
* fix: remove the hide cursor on windows logic

* feat(cursor): update fallback models
2026-05-28 20:23:01 +02:00
Haile
374e9de719 feat: add opencode support (#762)
* feat: add opencode support

* fix: stabilize opencode session startup

* fix: /models

* fix: improveUI for commands

* fix: format commands.js

* feat: load models through provider adapters

Provider model selection had outgrown a single hardcoded service.

The old service mixed shared caching with provider catalogs and CLI lookup details.

That made stale model lists more likely as providers changed on separate schedules.

Move model discovery behind each provider so lookup lives next to the integration.

The shared service now focuses on provider resolution, caching, persistence, and dedupe.

Return cache metadata and add bypassCache because model availability changes outside the app.

The UI and /models command can show freshness and let users force a provider refresh.

Surface model descriptions while keeping fallback catalogs for unavailable CLIs or SDKs.

* feat(models): resolve active session models through provider adapters

The model inventory command was showing a mix of catalog defaults and
composer-local state instead of the model that is actually active for a
real provider session. That made /models, /cost, and /status
misleading once a session had already started, especially for providers
whose effective runtime model can differ from the optimistic model value
held in the UI.

Introduce an explicit getCurrentActiveModel() contract on
IProviderModels so model resolution lives next to each provider's
catalog logic and uses the provider-native source of truth:

- Claude reads the init event from a resumed stream-json run
- Codex reads model from ~/.codex/config.toml
- Cursor reads lastUsedModel from the chat store.db
- OpenCode reads the persisted session model from opencode.db
- Gemini intentionally returns its default because the CLI does not
  provide a reliable active-session lookup

Keep the returned shape intentionally minimal ({ model }). The goal is
to expose only what downstream command consumers need and avoid leaking
provider-specific metadata into a shared transport shape that would
create extra UI coupling and future cleanup cost.

Also make command behavior session-aware: when there is no concrete
session id, do not spawn provider processes or inspect provider session
storage just to answer /models, /cost, or /status. In a new-session
view the correct answer is simply the provider default, and doing more
work there adds latency and unnecessary side effects for no user value.

As part of this, centralize two supporting concerns:

- add a shared helper for building the default current-model result from
  a provider catalog so fallbacks stay aligned with DEFAULT
- move leaf-directory validation into shared utils so Cursor session
  readers and model lookup code enforce the same path-safety rule

Tests were expanded to cover both the new service delegation path and
the sessionless command behavior, while keeping cache-sensitive tests
isolated from persisted host cache state.

Why this change:
- command output should reflect the model actually driving a session
- new-session views should stay fast and side-effect free
- provider-specific active-model lookup should not be scattered across
  routes or UI code
- fallback behavior should be explicit, consistent, and limited to the
  provider default when no true active model can be resolved

* feat: support session-scoped model overrides

Model selection was acting like a provider-level preference.

That made resumed sessions drift back to a default or request-time model.

Users expect /models changes made inside a conversation to affect that session.

Store explicit session choices in app-owned ~/.cloudcli state.

This avoids editing provider transcripts or native provider config.

Resolve the effective model before launching each provider runtime.

Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices.

Expose a backend active-model change endpoint for existing sessions.

The models modal can now distinguish default changes from session overrides.

It also shows when a selected model will apply on the next response.

For Claude, stop probing active model state by resuming with a dummy prompt.

Read the indexed JSONL transcript from the end instead.

This preserves provider history while honoring /model stdout or model fields.

Add service tests for adapter delegation and resume-model precedence.

The tests keep cache state, override state, and requested fallback separate.

* feat: make command modal more compact

* fix: preserve opencode session creation events

OpenCode emits the real session id asynchronously on its first JSON output. The runner
registered that id from a helper that could not see the spawned process because
the process reference was scoped inside the model-resolution callback. That
ReferenceError was swallowed by the generic JSON parse fallback, so the client
never received session_created. Without that event, a new OpenCode chat stayed
on / and the assistant stream was not attached to the new session view.

Keep the process reference in the outer spawn scope so registration can update
the active-process map and websocket writer as soon as OpenCode announces the
session id. Split JSON parsing from event processing so malformed non-JSON
output can still stream as raw text, while registration or adapter failures are
surfaced as real errors instead of being hidden as assistant content.

Add a fake opencode executable regression test to lock in the expected lifecycle
ordering: session_created must be sent before live assistant messages, and the
same session id must carry through stream_end and complete.

* fix: clarify model refresh and onboarding providers

OpenCode is now a supported chat provider, but first-run onboarding still only offered
Claude, Cursor, Codex, and Gemini. That made OpenCode harder to discover and
forced users to finish setup before finding the provider in settings or chat.
Adding it to onboarding keeps first-run setup aligned with the providers the
application already supports elsewhere.

The model refresh control was also doing too much visual work. In the new chat
model picker, the previous Hard Refresh label looked like the dialog heading,
which made the primary task unclear. Users open that dialog to choose a model;
refreshing catalogs is only a secondary maintenance action for stale cached
provider model lists.

Rename and reposition the refresh affordance so the model picker reads as a
model picker first. The copy now explains why catalogs are cached, when a refresh
is useful, and that the refresh checks every provider. The /models modal gets the
same clarification so both model-selection surfaces describe the cache behavior
consistently.

* fix: format opencode model catalog labels

OpenCode returns provider-prefixed ids directly from the CLI. Passing those ids through as
labels made the model picker hard to scan: users saw values like
anthropic/claude-3-5-sonnet-20241022 or lowercased, hyphen-split text instead
of readable model names.

Keep the exact OpenCode id as the option value because that is what the CLI
expects, but derive a presentation label for the frontend. The formatter is
intentionally generic rather than a catalog of known providers. It handles common
identifier structure such as provider/model, hyphen-delimited words, v-prefixed
versions, adjacent numeric version tokens, and 8-digit date suffixes.

This keeps OpenCode usable as its model list expands across many upstream
providers without requiring code changes for every new provider or model family.
The description keeps the raw provider-prefixed id visible so users can still
confirm the precise model being selected.

* feat: add more fallback models for cursor

* docs: move model catalog out of shared

The model catalog is no longer a frontend/backend runtime contract.

Keeping it under shared made ownership misleading. It implied the catalog was
application code shared by runtime consumers, even though it now only supports
README links and public API documentation.

Move the catalog into public so it lives beside the docs surfaces that need it.
This gives the API docs a stable, served module and gives README readers a
linkable source without suggesting frontend or backend runtime dependency.

Render the API docs model list from the exported provider registry instead of a
hardcoded Claude/Cursor/Codex subset. That keeps Gemini and OpenCode visible and
makes future provider documentation changes flow through one docs-specific file.

Update README links, provider maintenance notes, and package files so published
artifacts include the standalone docs page and model catalog without relying on
the old shared path.

* fix: simplify empty-state model selector

Keep the provider empty state focused on the setup action users need there:

choosing a model.

The refresh control, cache timestamp, and refresh explanation made the dialog feel

like a cache-management surface.

That extra action is out of place in the empty state, where the goal is to start

a chat with the selected provider and model.

Remove the refresh-specific UI from ProviderSelectionEmptyState and drop the

now-unused refresh/cache props from the ChatMessagesPane pass-through.

Refresh behavior remains available in the dedicated command result flow.
2026-05-28 10:50:41 +02:00
10 changed files with 843 additions and 321 deletions

View File

@@ -1,14 +1,10 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { query, type ModelInfo, type Options } from '@anthropic-ai/claude-agent-sdk';
import { sessionsDb } from '@/modules/database/index.js'; import { sessionsDb } from '@/modules/database/index.js';
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
import type { IProviderModels } from '@/shared/interfaces.js'; import type { IProviderModels } from '@/shared/interfaces.js';
import type { import type {
ProviderChangeActiveModelInput, ProviderChangeActiveModelInput,
ProviderCurrentActiveModel, ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition, ProviderModelsDefinition,
ProviderSessionActiveModelChange, ProviderSessionActiveModelChange,
} from '@/shared/types.js'; } from '@/shared/types.js';
@@ -19,17 +15,29 @@ import {
export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [ OPTIONS: [
{ value: 'default', label: 'Default (recommended)' }, {
{ value: 'sonnet[1m]', label: 'Sonnet (1M context)' }, value: 'default',
{ value: 'opus', label: 'Opus' }, label: 'Default (recommended)',
{ value: 'opus[1m]', label: 'Opus (1M context)' }, description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok',
{ value: 'haiku', label: 'Haiku' }, },
{ value: 'sonnet', label: 'sonnet' }, {
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', DEFAULT: 'default',
}; };
type ClaudeModelQueryOptions = Pick<Options, 'env' | 'pathToClaudeCodeExecutable' | 'permissionMode'>;
type ClaudeInitEvent = { type ClaudeInitEvent = {
sessionId?: string; sessionId?: string;
session_id?: string; session_id?: string;
@@ -49,46 +57,6 @@ const ANSI_PATTERN = new RegExp(
'g', 'g',
); );
const buildClaudeQueryOptions = (): ClaudeModelQueryOptions => ({
env: { ...process.env },
pathToClaudeCodeExecutable: resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH),
permissionMode: 'default',
});
const mapClaudeModel = (model: ModelInfo): ProviderModelOption => ({
value: model.value,
label: model.displayName || model.value,
description: model.description || undefined,
});
const buildClaudeModelsDefinition = (models: ModelInfo[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = [];
const seenValues = new Set<string>();
for (const model of models) {
const mappedModel = mapClaudeModel(model);
if (seenValues.has(mappedModel.value)) {
continue;
}
seenValues.add(mappedModel.value);
options.push(mappedModel);
}
if (options.length === 0) {
return CLAUDE_FALLBACK_MODELS;
}
const defaultValue = options.find((option) => option.value === 'default')?.value
?? options[0]?.value
?? CLAUDE_FALLBACK_MODELS.DEFAULT;
return {
OPTIONS: options,
DEFAULT: defaultValue,
};
};
const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => { const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => {
const eventSessionId = event.sessionId ?? event.session_id; const eventSessionId = event.sessionId ?? event.session_id;
if (eventSessionId && eventSessionId !== sessionId) { if (eventSessionId && eventSessionId !== sessionId) {
@@ -181,25 +149,18 @@ const readClaudeSessionModelFromJsonl = async (
export class ClaudeProviderModels implements IProviderModels { export class ClaudeProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> { async getSupportedModels(): Promise<ProviderModelsDefinition> {
let queryInstance: ReturnType<typeof query> | null = null; // 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.
try { //
// The SDK exposes its runtime model catalog on the initialized query // Disabled for now:
// instance, so we create a lightweight query and immediately close it // const queryInstance = query({
// after reading the control-plane metadata. // prompt: 'Get supported models',
queryInstance = query({ // options: buildClaudeQueryOptions(),
prompt: 'Get supported models', // });
options: buildClaudeQueryOptions(), // const supportedModels = await queryInstance.supportedModels();
}); // queryInstance.close();
// return buildClaudeModelsDefinition(supportedModels);
const supportedModels = await queryInstance.supportedModels(); return CLAUDE_FALLBACK_MODELS;
return buildClaudeModelsDefinition(supportedModels);
} catch {
return CLAUDE_FALLBACK_MODELS;
} finally {
queryInstance?.close();
}
} }
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> { async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {

View File

@@ -21,27 +21,563 @@ import {
export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = { export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [ OPTIONS: [
{ value: "opus-4.6-thinking", label: "Claude 4.6 Opus (Thinking)" }, {
{ value: "gpt-5.3-codex", label: "GPT-5.3" }, value: "auto",
{ value: "gpt-5.2-high", label: "GPT-5.2 High" }, label: "auto",
{ value: "gemini-3-pro", label: "Gemini 3 Pro" }, description: "Auto",
{ 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: "composer-2-fast",
{ value: "gpt-5.1-high", label: "GPT-5.1 High" }, label: "composer-2-fast",
{ value: "composer-1", label: "Composer 1" }, description: "Composer 2 Fast",
{ 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: "composer-2",
{ value: "opus-4.5", label: "Claude 4.5 Opus" }, label: "composer-2",
{ value: "gpt-5.1-codex", label: "GPT-5.1 Codex" }, description: "Composer 2",
{ 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: "gpt-5.3-codex-low",
{ value: "opus-4.1", label: "Claude 4.1 Opus" }, label: "gpt-5.3-codex-low",
{ value: "grok", label: "Grok" }, 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-fast', DEFAULT: "composer-2.5-fast",
}; };
type CursorModelRow = { type CursorModelRow = {
@@ -281,3 +817,4 @@ export class CursorProviderModels implements IProviderModels {
return writeProviderSessionActiveModelChange('cursor', input); return writeProviderSessionActiveModelChange('cursor', input);
} }
} }

View File

@@ -1,18 +1,7 @@
import os from 'node:os';
import { providerRegistry } from '@/modules/providers/provider.registry.js'; import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError } from '@/shared/utils.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 = { export const providerMcpService = {
/** /**
@@ -75,7 +64,7 @@ export const providerMcpService = {
const scope = input.scope ?? 'project'; const scope = input.scope ?? 'project';
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = []; 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) { for (const provider of providers) {
try { try {
await provider.mcp.upsertServer({ ...input, scope }); await provider.mcp.upsertServer({ ...input, scope });

View File

@@ -341,8 +341,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
workspacePath, workspacePath,
}); });
const expectCursorGlobal = process.platform !== 'win32'; assert.equal(globalResult.length, 5);
assert.equal(globalResult.length, expectCursorGlobal ? 5 : 4);
assert.ok(globalResult.every((entry) => entry.created === true)); assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json')); const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
@@ -357,10 +356,8 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json')); const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']); assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
if (expectCursorGlobal) { const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json')); assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
}
await assert.rejects( await assert.rejects(
providerMcpService.addMcpServerToAllProviders({ providerMcpService.addMcpServerToAllProviders({

View File

@@ -1,4 +1,5 @@
import express from 'express'; import express from 'express';
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js'; import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js';
import { getPublicKey } from '../services/vapid-keys.js'; import { getPublicKey } from '../services/vapid-keys.js';
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.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; export default router;

View File

@@ -1,8 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { Check, ChevronDown } from "lucide-react"; import { Check, ChevronDown } from "lucide-react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
import type { import type {
ProjectSession, ProjectSession,
LLMProvider, LLMProvider,
@@ -120,24 +119,15 @@ export default function ProviderSelectionEmptyState({
setInput, setInput,
}: ProviderSelectionEmptyStateProps) { }: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const { isWindowsServer } = useServerPlatform();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const visibleProviderGroups = useMemo(() => { const visibleProviderGroups = useMemo<ProviderGroup[]>(() => {
const groups: ProviderGroup[] = PROVIDER_META.map((p) => ({ return PROVIDER_META.map((p) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
models: providerModelCatalog[p.id]?.OPTIONS ?? [], models: providerModelCatalog[p.id]?.OPTIONS ?? [],
})); }));
return isWindowsServer ? groups.filter((p) => p.id !== "cursor") : groups; }, [providerModelCatalog]);
}, [isWindowsServer, providerModelCatalog]);
useEffect(() => {
if (isWindowsServer && provider === "cursor") {
setProvider("claude");
localStorage.setItem("selected-provider", "claude");
}
}, [isWindowsServer, provider, setProvider]);
const nextTaskPrompt = t("tasks.nextTaskPrompt", { const nextTaskPrompt = t("tasks.nextTaskPrompt", {
defaultValue: "Start the next task", defaultValue: "Start the next task",

View File

@@ -1,12 +1,93 @@
import { useState } from 'react'; import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; 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 { usePlugins } from '../../../contexts/PluginsContext';
import type { Plugin } from '../../../contexts/PluginsContext'; import type { Plugin } from '../../../contexts/PluginsContext';
import PluginIcon from './PluginIcon'; import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter'; 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 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 ─────────────────────────────────────────────────────── */ /* ─── Toggle Switch ─────────────────────────────────────────────────────── */
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) { function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
@@ -208,117 +289,95 @@ function PluginCard({
); );
} }
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */ /* ─── Recommendation Section ────────────────────────────────────────────── */
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) { function RecommendationSection({
const { t } = useTranslation('settings'); title,
description,
children,
}: {
title: string;
description: string;
children: ReactNode;
}) {
return ( 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"> <section className="space-y-2">
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" /> <div>
<div className="min-w-0 flex-1 p-4"> <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="flex items-start justify-between gap-3"> {title}
<div className="flex min-w-0 items-center gap-2.5"> </h4>
<div className="h-5 w-5 flex-shrink-0 text-blue-500"> <p className="mt-0.5 text-xs text-muted-foreground/70">
<BarChart3 className="h-5 w-5" /> {description}
</div> </p>
<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>
</div> </div>
</div> <div className="space-y-2">
{children}
</div>
</section>
); );
} }
/* ─── Terminal Plugin Card ──────────────────────────────────────────────── */ /* ─── Plugin Recommendation Card ────────────────────────────────────────── */
function TerminalPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) { function PluginRecommendationCard({
recommendation,
onInstall,
disabled,
installing,
}: {
recommendation: PluginRecommendation;
onInstall: () => void;
disabled: boolean;
installing: boolean;
}) {
const { t } = useTranslation('settings'); 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 ( 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={`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 bg-blue-500/30" /> <div className={`w-[3px] flex-shrink-0 ${accentClass}`} />
<div className="min-w-0 flex-1 p-4"> <div className="min-w-0 flex-1 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-2.5">
<div className="h-5 w-5 flex-shrink-0 text-blue-500"> <div className={`h-5 w-5 flex-shrink-0 ${iconClass}`}>
<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"> <Icon 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> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground"> <span className="text-sm font-semibold leading-none text-foreground">
{t('pluginSettings.terminalPlugin.name')} {t(`pluginSettings.${recommendation.translationKey}.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')}
</span> </span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"> <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t('pluginSettings.tab')} {t('pluginSettings.tab')}
</span> </span>
</div> </div>
<p className="mt-1 text-sm leading-snug text-muted-foreground"> <p className="mt-1 text-sm leading-snug text-muted-foreground">
{t('pluginSettings.terminalPlugin.description')} {t(`pluginSettings.${recommendation.translationKey}.description`)}
</p> </p>
<a <a
href={TERMINAL_PLUGIN_URL} href={recommendation.repoUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground" 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" /> <GitBranch className="h-3 w-3" />
cloudcli-ai/cloudcli-plugin-terminal {repoSlug(recommendation.repoUrl)}
</a> </a>
</div> </div>
</div> </div>
<button <button
onClick={onInstall} onClick={onInstall}
disabled={installing} disabled={disabled}
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" 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 ? ( {installing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Download className="h-3.5 w-3.5" /> <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> </button>
</div> </div>
</div> </div>
@@ -334,8 +393,7 @@ export default function PluginSettingsTab() {
const [gitUrl, setGitUrl] = useState(''); const [gitUrl, setGitUrl] = useState('');
const [installing, setInstalling] = useState(false); const [installing, setInstalling] = useState(false);
const [installingStarter, setInstallingStarter] = useState(false); const [installingRecommendation, setInstallingRecommendation] = useState<string | null>(null);
const [installingTerminal, setInstallingTerminal] = useState(false);
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null); const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set()); const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
@@ -364,24 +422,18 @@ export default function PluginSettingsTab() {
setInstalling(false); setInstalling(false);
}; };
const handleInstallStarter = async () => { const handleInstallRecommendation = async (recommendation: PluginRecommendation) => {
setInstallingStarter(true); if (installingRecommendation) return;
setInstallingRecommendation(recommendation.id);
setInstallError(null); setInstallError(null);
const result = await installPlugin(STARTER_PLUGIN_URL); try {
if (!result.success) { const result = await installPlugin(recommendation.repoUrl);
setInstallError(result.error || t('pluginSettings.installFailed')); 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) => { const handleUninstall = async (name: string) => {
@@ -398,8 +450,50 @@ export default function PluginSettingsTab() {
} }
}; };
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats'); const isRecommendationInstalled = (recommendation: PluginRecommendation) => {
const hasTerminalInstalled = plugins.some((p) => p.name === 'web-terminal'); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -456,51 +550,49 @@ export default function PluginSettingsTab() {
</span> </span>
</p> </p>
{/* Official plugin suggestions — above the list */} {/* Plugin sections */}
{!loading && (!hasStarterInstalled || !hasTerminalInstalled) && (
<div className="space-y-2">
{!hasStarterInstalled && (
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
)}
{!hasTerminalInstalled && (
<TerminalPluginCard onInstall={handleInstallTerminal} installing={installingTerminal} />
)}
</div>
)}
{/* Plugin List */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{t('pluginSettings.scanningPlugins')} {t('pluginSettings.scanningPlugins')}
</div> </div>
) : plugins.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('pluginSettings.noPluginsInstalled')}</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-4">
{plugins.map((plugin, index) => { {hasOfficialSection && (
const handleToggle = async (enabled: boolean) => { <RecommendationSection
const r = await togglePlugin(plugin.name, enabled); title={t('pluginSettings.sections.officialTitle')}
if (!r.success) { description={t('pluginSettings.sections.officialDescription')}
setInstallError(r.error || t('pluginSettings.toggleFailed')); >
} {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 ( {hasOtherSection && (
<PluginCard <RecommendationSection
key={plugin.name} title={t('pluginSettings.sections.unofficialTitle')}
plugin={plugin} description={t('pluginSettings.sections.unofficialDescription')}
index={index} >
onToggle={(enabled) => void handleToggle(enabled)} {otherPlugins.map((plugin, index) => renderPluginCard(plugin, officialPlugins.length + index))}
onUpdate={() => void handleUpdate(plugin.name)} {unofficialRecommendations.map((recommendation) => (
onUninstall={() => void handleUninstall(plugin.name)} <PluginRecommendationCard
updating={updatingPlugins.has(plugin.name)} key={recommendation.id}
confirmingUninstall={confirmUninstall === plugin.name} recommendation={recommendation}
onCancelUninstall={() => setConfirmUninstall(null)} onInstall={() => void handleInstallRecommendation(recommendation)}
updateError={updateErrors[plugin.name] ?? null} disabled={!!installingRecommendation}
/> installing={installingRecommendation === recommendation.id}
); />
})} ))}
</RecommendationSection>
)}
</div> </div>
)} )}

View File

@@ -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 { AgentCategory, AgentProvider } from '../../../types/types';
import type { AgentContext, AgentsSettingsTabProps } from './types'; import type { AgentContext, AgentsSettingsTabProps } from './types';
@@ -23,22 +22,10 @@ export default function AgentsSettingsTab({
}: AgentsSettingsTabProps) { }: AgentsSettingsTabProps) {
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude'); const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account'); const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
const { isWindowsServer } = useServerPlatform();
const visibleAgents = useMemo<AgentProvider[]>(() => { const visibleAgents = useMemo<AgentProvider[]>(() => {
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
if (isWindowsServer) { }, []);
return all.filter((id) => id !== 'cursor');
}
return all;
}, [isWindowsServer]);
useEffect(() => {
if (isWindowsServer && selectedAgent === 'cursor') {
setSelectedAgent('claude');
}
}, [isWindowsServer, selectedAgent]);
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({ const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
claude: { claude: {

View File

@@ -1,40 +0,0 @@
import { useEffect, useState } from 'react';
import { authenticatedFetch } from '../utils/api';
/**
* Node `process.platform` from the API host (e.g. win32, darwin, linux).
* Null until loaded or if the request fails.
*/
export function useServerPlatform(): {
serverPlatform: string | null;
isWindowsServer: boolean;
} {
const [serverPlatform, setServerPlatform] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const response = await authenticatedFetch('/api/settings/server-env');
if (!response.ok) {
return;
}
const body = (await response.json()) as { platform?: string };
if (!cancelled && typeof body.platform === 'string') {
setServerPlatform(body.platform);
}
} catch {
// Keep null: treat as unknown host.
}
})();
return () => {
cancelled = true;
};
}, []);
return {
serverPlatform,
isWindowsServer: serverPlatform === 'win32',
};
}

View File

@@ -472,6 +472,12 @@
"starterPluginLabel": "Starter Plugin", "starterPluginLabel": "Starter Plugin",
"starter": "Starter", "starter": "Starter",
"docs": "Docs", "docs": "Docs",
"sections": {
"officialTitle": "Official Plugins",
"officialDescription": "Maintained by the CloudCLI team and ready for direct install.",
"unofficialTitle": "Other Plugins",
"unofficialDescription": "Unofficial plugins and integrations from other users. Review the source before installing."
},
"starterPlugin": { "starterPlugin": {
"name": "Project Stats", "name": "Project Stats",
"badge": "starter", "badge": "starter",
@@ -484,6 +490,18 @@
"description": "Integrated terminal with full shell access directly within the interface.", "description": "Integrated terminal with full shell access directly within the interface.",
"install": "Install" "install": "Install"
}, },
"scheduledPromptPlugin": {
"name": "Scheduled Prompts",
"badge": "unofficial",
"description": "Schedule workspace prompts, review run history, and manage recurring local tasks.",
"install": "Install"
},
"claudeWatchPlugin": {
"name": "Claude Watch",
"badge": "unofficial",
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
"install": "Install"
},
"morePlugins": "More", "morePlugins": "More",
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",