Compare commits

..

10 Commits

Author SHA1 Message Date
Simos Mikelatos
9bcd82834c Merge branch 'main' into feat/claude-codex-effort 2026-07-03 17:30:19 +02:00
Simos Mikelatos
e93c83addb fix desktop release asset upload 2026-07-03 07:22:50 +00:00
Haileyesus
10a0e3ff8d fix: use portal for showing effort dropdown 2026-07-01 23:41:02 +03:00
Haileyesus
78968fe33c chore: update gpt fallback models 2026-07-01 23:40:22 +03:00
Haileyesus
7785763c14 feat: add effort support for opencode 2026-07-01 23:32:13 +03:00
Simos Mikelatos
85e8facfbb Merge branch 'main' into feat/claude-codex-effort 2026-07-01 16:09:10 +02:00
Simos Mikelatos
ff4f8d6ac7 fix: pass effort through external agent api 2026-07-01 04:44:27 +00:00
Simos Mikelatos
eb08aadaa0 fix: reconcile provider effort after model changes 2026-07-01 00:08:32 +00:00
Simos Mikelatos
0206a1f6aa refactor: generic provider effort handling 2026-06-30 23:56:01 +00:00
Simos Mikelatos
d618abb075 feat: add Claude and Codex effort controls 2026-06-30 23:29:57 +00:00
20 changed files with 832 additions and 109 deletions

View File

@@ -4,18 +4,9 @@ on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to create or update (defaults to v<package version>)"
description: "Existing release tag to upload desktop assets to (defaults to v<package version>)"
required: false
type: string
release_name:
description: 'Release name (defaults to "CloudCLI Desktop <tag>")'
required: false
type: string
prerelease:
description: "Mark the GitHub release as a prerelease"
required: true
default: false
type: boolean
jobs:
resolve-release:
@@ -25,7 +16,6 @@ jobs:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
release_name: ${{ steps.release.outputs.release_name }}
server_bundle_tag: ${{ steps.release.outputs.server_bundle_tag }}
steps:
- name: Checkout
@@ -37,7 +27,6 @@ jobs:
id: release
env:
TAG_INPUT: ${{ inputs.tag }}
RELEASE_NAME_INPUT: ${{ inputs.release_name }}
run: |
VERSION="$(node -p "require('./package.json').version")"
TAG="$TAG_INPUT"
@@ -50,17 +39,8 @@ jobs:
exit 1
fi
RELEASE_NAME="$RELEASE_NAME_INPUT"
if [ -z "$RELEASE_NAME" ]; then
RELEASE_NAME="CloudCLI Desktop ${TAG}"
fi
RELEASE_NAME_DELIMITER="release_name_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}"
{
echo "tag=$TAG"
echo "release_name<<$RELEASE_NAME_DELIMITER"
printf '%s\n' "$RELEASE_NAME"
echo "$RELEASE_NAME_DELIMITER"
echo "server_bundle_tag=cloudcli-local-server-${TAG}"
} >> "$GITHUB_OUTPUT"
@@ -270,6 +250,17 @@ jobs:
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
find release -maxdepth 2 -type f -print | sort
- name: Verify target GitHub release exists
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
run: |
if ! gh release view "$RELEASE_TAG" >/dev/null; then
echo "GitHub release $RELEASE_TAG does not exist. Run the normal Release workflow first, then rerun Desktop Release." >&2
exit 1
fi
- name: Publish local server runtime assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
@@ -288,18 +279,11 @@ jobs:
files: |
release/local-server/*
- name: Publish GitHub release assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ needs.resolve-release.outputs.tag }}
target_commitish: ${{ github.sha }}
name: ${{ needs.resolve-release.outputs.release_name }}
body: |
Download the CloudCLI Desktop installer for your platform.
The local server runtime used by local mode is installed automatically by the desktop app. You do not need to download any server bundle manually.
prerelease: ${{ inputs.prerelease }}
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/desktop/*
- name: Upload desktop assets to existing GitHub release
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
run: |
mapfile -d '' DESKTOP_ASSETS < <(find release/desktop -maxdepth 1 -type f -print0 | sort -z)
gh release upload "$RELEASE_TAG" "${DESKTOP_ASSETS[@]}" --clobber

View File

@@ -489,7 +489,7 @@
<span class="endpoint-path"><span class="api-url">http://localhost:3001</span>/api/agent</span>
</div>
<p>Trigger an AI agent (Claude, Cursor, or Codex) to work on a project.</p>
<p>Trigger an AI agent (Claude, Cursor, Codex, Gemini, or OpenCode) to work on a project.</p>
<h4>Request Body Parameters</h4>
<table>
@@ -524,7 +524,7 @@
<td><code>provider</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td><code>claude</code>, <code>cursor</code>, or <code>codex</code> (default: <code>claude</code>)</td>
<td><code>claude</code>, <code>cursor</code>, <code>codex</code>, <code>gemini</code>, or <code>opencode</code> (default: <code>claude</code>)</td>
</tr>
<tr>
<td><code>stream</code></td>
@@ -540,6 +540,12 @@
Model identifier for the AI provider (loading from constants...)
</td>
</tr>
<tr>
<td><code>effort</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>Reasoning effort for Claude, Codex, and OpenCode models that expose effort metadata. Use <code>default</code> or omit it to let the provider decide.</td>
</tr>
<tr>
<td><code>cleanup</code></td>
<td>boolean</td>

View File

@@ -12,11 +12,13 @@
* - WebSocket message streaming
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import crypto from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import path from 'path';
import { query } from '@anthropic-ai/claude-agent-sdk';
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';
@@ -41,6 +43,15 @@ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEO
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_FALLBACK_MODELS) {
const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model) || null;
const allowedEfforts = selectedModel?.effort?.values
?.map((value) => value.value) || [];
return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
? effort
: undefined;
}
function createRequestId() {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
@@ -145,13 +156,8 @@ function matchesToolPermission(entry, toolName, input) {
return false;
}
/**
* Maps CLI options to SDK-compatible options format
* @param {Object} options - CLI options
* @returns {Object} SDK-compatible options
*/
function mapCliOptionsToSDK(options = {}) {
const { sessionId, cwd, toolsSettings, permissionMode } = options;
const { sessionId, cwd, toolsSettings, permissionMode, effort } = options;
const sdkOptions = {};
@@ -163,32 +169,26 @@ function mapCliOptionsToSDK(options = {}) {
// which does not reliably follow npm's shell wrappers like cross-spawn does.
sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
// Map working directory
if (cwd) {
sdkOptions.cwd = cwd;
}
// Map permission mode
if (permissionMode && permissionMode !== 'default') {
sdkOptions.permissionMode = permissionMode;
}
// Map tool settings
const settings = toolsSettings || {
allowedTools: [],
disallowedTools: [],
skipPermissions: false
};
// Handle tool permissions
if (settings.skipPermissions && permissionMode !== 'plan') {
// When skipping permissions, use bypassPermissions mode
sdkOptions.permissionMode = 'bypassPermissions';
}
let allowedTools = [...(settings.allowedTools || [])];
// Add plan mode default tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) {
@@ -207,22 +207,24 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
// Model logged at query start below
// Map system prompt configuration
const resolvedEffort = resolveClaudeEffort(
sdkOptions.model,
effort,
options.effortModels || CLAUDE_FALLBACK_MODELS,
);
if (resolvedEffort) {
sdkOptions.effort = resolvedEffort;
}
sdkOptions.systemPrompt = {
type: 'preset',
preset: 'claude_code' // Required to use CLAUDE.md
preset: 'claude_code'
};
// Map setting sources for CLAUDE.md loading
// This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories
sdkOptions.settingSources = ['project', 'user', 'local'];
// Map resume session
if (sessionId) {
sdkOptions.resume = sessionId;
}
@@ -533,20 +535,24 @@ async function queryClaudeSDK(command, options = {}, ws) {
sessionId,
options.model,
);
let effortModels = CLAUDE_FALLBACK_MODELS;
try {
effortModels = (await providerModelsService.getProviderModels('claude')).models;
} catch (error) {
console.warn('[Claude SDK] Unable to load provider models for effort validation:', error);
}
// Map CLI options to SDK format
const sdkOptions = mapCliOptionsToSDK({
...options,
model: resolvedModel || options.model,
effortModels,
});
// Load MCP configuration
const mcpServers = await loadMcpConfig(options.cwd);
if (mcpServers) {
sdkOptions.mcpServers = mcpServers;
}
// Handle images - save to temp files and modify prompt
const imageResult = await handleImages(command, options.images, options.cwd);
const finalCommand = imageResult.modifiedCommand;
tempImagePaths = imageResult.tempImagePaths;
@@ -650,7 +656,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
};
// Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
// Query constructor reads this synchronously.
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';

View File

@@ -5,6 +5,7 @@ import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
@@ -18,27 +19,89 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
{
value: 'default',
label: 'Default (recommended)',
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
description: 'Use the Claude Code default model (currently Sonnet 4.6)',
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'max' },
],
},
},
{
value: 'fable',
label: 'Fable',
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'xhigh' },
{ value: 'max' },
],
},
},
{
value: "sonnet",
label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'max' },
],
},
},
{
value: 'sonnet[1m]',
label: 'Sonnet (1M context)',
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'max' },
],
},
},
{
value: 'opus',
label: 'Opus',
description: 'Opus 4.8 · Best for everyday, complex tasks · ~2× usage vs Sonnet',
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'xhigh' },
{ value: 'max' },
],
},
},
{
value: 'opus[1m]',
label: 'Opus 4.8 (1M context)',
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'xhigh' },
{ value: 'max' },
],
},
},
{
value: 'haiku',
@@ -48,6 +111,15 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
],
DEFAULT: 'default',
};
export const findClaudeModelOption = (model: string | undefined | null): ProviderModelOption | null => {
const normalizedModel = typeof model === 'string' ? model.trim() : '';
if (!normalizedModel) {
return null;
}
return CLAUDE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === normalizedModel) ?? null;
};
type ClaudeInitEvent = {
sessionId?: string;
session_id?: string;

View File

@@ -21,11 +21,30 @@ import {
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' },
{
value: 'gpt-5.5',
label: 'gpt-5.5',
effort: {
default: 'medium',
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
},
},
{
value: 'gpt-5.4',
label: 'gpt-5.4',
effort: {
default: 'medium',
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
},
},
{
value: 'gpt-5.4-mini',
label: 'gpt-5.4-mini',
effort: {
default: 'medium',
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
},
},
],
DEFAULT: 'gpt-5.4',
};
@@ -37,6 +56,11 @@ type CodexCachedModel = {
priority?: number;
visibility?: string;
supported_in_api?: boolean;
default_reasoning_level?: string;
supported_reasoning_levels?: Array<{
effort?: string;
description?: string;
}>;
};
const CODEX_MODELS_CACHE_PATH = path.join(os.homedir(), '.codex', 'models_cache.json');
@@ -51,15 +75,39 @@ 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 mapCodexModel = (model: CodexCachedModel): ProviderModelOption => {
const effortValues = Array.isArray(model.supported_reasoning_levels)
? model.supported_reasoning_levels
.map((level) => {
const value = readOptionalString(level?.effort);
if (!value) {
return null;
}
return {
value,
description: readOptionalString(level?.description),
};
})
.filter((level): level is NonNullable<typeof level> => Boolean(level))
: [];
return {
value: model.slug as string,
label: readOptionalString(model.display_name) ?? (model.slug as string),
description: readOptionalString(model.description),
effort: effortValues.length > 0
? {
default: readOptionalString(model.default_reasoning_level) ?? undefined,
values: effortValues,
}
: undefined,
};
};
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
const sortedModels = [...models]
.filter((model) => model.visibility !== 'hidden' && model.supported_in_api !== false)
.filter((model) => model.visibility === 'list' && model.supported_in_api !== false)
.sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority));
const options: ProviderModelOption[] = [];

View File

@@ -74,6 +74,13 @@ const VERSION_TOKEN = /^[a-z]\d+$/i;
const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/;
const SHORT_ACRONYM_TOKEN = /^[a-z]{2,3}$/;
type OpenCodeVerboseModel = {
id?: string;
name?: string;
providerID?: string;
variants?: Record<string, unknown>;
};
export const parseOpenCodeModelsStdout = (stdout: string): string[] => {
const ids: string[] = [];
@@ -91,6 +98,83 @@ export const parseOpenCodeModelsStdout = (stdout: string): string[] => {
return [...new Set(ids)];
};
const countJsonBraceDelta = (value: string): number => {
let delta = 0;
let inString = false;
let escaped = false;
for (const character of value) {
if (escaped) {
escaped = false;
continue;
}
if (character === '\\') {
escaped = inString;
continue;
}
if (character === '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (character === '{') {
delta += 1;
} else if (character === '}') {
delta -= 1;
}
}
return delta;
};
const isOpenCodeVerboseModel = (value: unknown): value is OpenCodeVerboseModel => {
const record = readObjectRecord(value);
return Boolean(record && readOptionalString(record.id));
};
export const parseOpenCodeVerboseModelsStdout = (stdout: string): OpenCodeVerboseModel[] => {
const models: OpenCodeVerboseModel[] = [];
let buffer: string[] = [];
let depth = 0;
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (buffer.length === 0) {
if (line === '{') {
buffer = [rawLine];
depth = 1;
}
continue;
}
buffer.push(rawLine);
depth += countJsonBraceDelta(rawLine);
if (depth !== 0) {
continue;
}
try {
const parsed = JSON.parse(buffer.join('\n'));
if (isOpenCodeVerboseModel(parsed)) {
models.push(parsed);
}
} catch {
// Ignore malformed verbose blocks and fall back to the plain id parser.
}
buffer = [];
}
return models;
};
const formatDateToken = (token: string): string => (
`${token.slice(0, 4)}-${token.slice(4, 6)}-${token.slice(6, 8)}`
);
@@ -155,6 +239,20 @@ const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: s
};
};
const readOpenCodeVerboseModelId = (model: OpenCodeVerboseModel): string | null => {
const id = readOptionalString(model.id);
if (!id) {
return null;
}
if (id.includes('/')) {
return id;
}
const upstreamProvider = readOptionalString(model.providerID);
return upstreamProvider ? `${upstreamProvider}/${id}` : id;
};
const labelForOpenCodeModelId = (id: string): string => {
const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label;
if (fallbackLabel) {
@@ -170,6 +268,52 @@ const descriptionForOpenCodeModelId = (id: string): string => {
return upstreamProvider ? `${upstreamProvider} - ${id}` : id;
};
const readOpenCodeVariantEffort = (key: string, value: unknown): string | null => {
const variant = readObjectRecord(value);
return readOptionalString(variant?.reasoningEffort)
?? readOptionalString(variant?.effort)
?? key;
};
const readOpenCodeEffortValues = (
variants: OpenCodeVerboseModel['variants'],
): NonNullable<ProviderModelOption['effort']>['values'] => {
const effortValues: NonNullable<ProviderModelOption['effort']>['values'] = [];
const seenValues = new Set<string>();
for (const [key, value] of Object.entries(variants ?? {})) {
const effort = readOpenCodeVariantEffort(key, value);
if (!effort || seenValues.has(effort)) {
continue;
}
seenValues.add(effort);
effortValues.push({ value: effort });
}
return effortValues;
};
const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOption | null => {
const value = readOpenCodeVerboseModelId(model);
if (!value) {
return null;
}
const effortValues = readOpenCodeEffortValues(model.variants);
return {
value,
label: readOptionalString(model.name) ?? labelForOpenCodeModelId(value),
description: descriptionForOpenCodeModelId(value),
effort: effortValues.length > 0
? {
values: effortValues,
}
: undefined,
};
};
export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = ids.map((value) => ({
value,
@@ -187,6 +331,36 @@ export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDef
};
};
export const buildOpenCodeDefinitionFromVerboseModels = (
models: OpenCodeVerboseModel[],
): ProviderModelsDefinition => {
const options: ProviderModelOption[] = [];
const seenValues = new Set<string>();
for (const model of models) {
const mappedModel = mapOpenCodeVerboseModel(model);
if (!mappedModel || seenValues.has(mappedModel.value)) {
continue;
}
seenValues.add(mappedModel.value);
options.push(mappedModel);
}
if (options.length === 0) {
return OPENCODE_FALLBACK_MODELS;
}
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();
@@ -214,7 +388,7 @@ const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => {
};
const runOpenCodeModelsCommand = (): Promise<string> => new Promise((resolve, reject) => {
const openCodeProcess = spawnFunction('opencode', ['models'], {
const openCodeProcess = spawnFunction('opencode', ['models', '--verbose'], {
cwd: process.cwd(),
env: { ...process.env },
});
@@ -273,6 +447,11 @@ export class OpenCodeProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
try {
const stdout = await runOpenCodeModelsCommand();
const verboseModels = parseOpenCodeVerboseModelsStdout(stdout);
if (verboseModels.length > 0) {
return buildOpenCodeDefinitionFromVerboseModels(verboseModels);
}
const ids = parseOpenCodeModelsStdout(stdout);
if (ids.length === 0) {
return OPENCODE_FALLBACK_MODELS;

View File

@@ -21,6 +21,8 @@ type ProviderCapabilities = {
supportsPermissionRequests: boolean;
/** Whether the token-usage endpoint has data for this provider. */
supportsTokenUsage: boolean;
/** Whether the provider runtime can accept model-level reasoning effort. */
supportsEffort: boolean;
};
/**
@@ -38,6 +40,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true,
supportsPermissionRequests: true,
supportsTokenUsage: true,
supportsEffort: true,
},
cursor: {
provider: 'cursor',
@@ -47,6 +50,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: false,
supportsEffort: false,
},
codex: {
provider: 'codex',
@@ -56,6 +60,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
supportsEffort: true,
},
gemini: {
provider: 'gemini',
@@ -65,6 +70,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
supportsEffort: false,
},
opencode: {
provider: 'opencode',
@@ -74,6 +80,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
supportsEffort: true,
},
};

View File

@@ -16,7 +16,7 @@ import type {
import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const PROVIDER_MODELS_CACHE_VERSION = 1;
const PROVIDER_MODELS_CACHE_VERSION = 2;
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
type ProviderModelsServiceDependencies = {

View File

@@ -2,8 +2,10 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildOpenCodeDefinitionFromVerboseModels,
buildOpenCodeDefinitionFromIds,
parseOpenCodeModelsStdout,
parseOpenCodeVerboseModelsStdout,
} from '@/modules/providers/list/opencode/opencode-models.provider.js';
test('OpenCode models provider parses plain CLI output and removes duplicates', () => {
@@ -71,3 +73,63 @@ test('OpenCode models provider formats frontend labels from provider-prefixed id
},
]);
});
test('OpenCode models provider maps verbose model variants to effort options', () => {
const models = parseOpenCodeVerboseModelsStdout(`
opencode/deepseek-v4-flash-free
{
"id": "deepseek-v4-flash-free",
"providerID": "opencode",
"name": "DeepSeek V4 Flash Free",
"variants": {
"low": {
"reasoningEffort": "low"
},
"high": {
"reasoningEffort": "high"
}
}
}
anthropic/claude-sonnet-5
{
"id": "claude-sonnet-5",
"providerID": "anthropic",
"name": "Claude Sonnet 5",
"variants": {
"low": {
"effort": "low"
},
"max": {
"effort": "max"
}
}
}
`);
const definition = buildOpenCodeDefinitionFromVerboseModels(models);
assert.deepEqual(definition.OPTIONS, [
{
value: 'opencode/deepseek-v4-flash-free',
label: 'DeepSeek V4 Flash Free',
description: 'opencode - opencode/deepseek-v4-flash-free',
effort: {
values: [
{ value: 'low' },
{ value: 'high' },
],
},
},
{
value: 'anthropic/claude-sonnet-5',
label: 'Claude Sonnet 5',
description: 'anthropic - anthropic/claude-sonnet-5',
effort: {
values: [
{ value: 'low' },
{ value: 'max' },
],
},
},
]);
});

View File

@@ -20,7 +20,6 @@ import { providerAuthService } from './modules/providers/services/provider-auth.
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
// Track active sessions
const activeCodexSessions = new Map();
function readUsageNumber(value) {
@@ -228,6 +227,7 @@ export async function queryCodex(command, options = {}, ws) {
cwd,
projectPath,
model,
effort,
permissionMode = 'default'
} = options;
@@ -239,6 +239,12 @@ export async function queryCodex(command, options = {}, ws) {
const workingDirectory = cwd || projectPath || process.cwd();
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
const catalog = (await providerModelsService.getProviderModels('codex')).models;
const selectedModel = catalog.OPTIONS.find((option) => option.value === resolvedModel) || null;
const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
const resolvedEffort = typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
? effort
: undefined;
let codex;
let thread;
@@ -248,19 +254,17 @@ export async function queryCodex(command, options = {}, ws) {
const abortController = new AbortController();
try {
// Initialize Codex SDK
codex = new Codex();
// Thread options with sandbox and approval settings
const threadOptions = {
workingDirectory,
skipGitRepoCheck: true,
sandboxMode,
approvalPolicy,
model: resolvedModel
model: resolvedModel,
modelReasoningEffort: resolvedEffort,
};
// Start or resume thread
if (sessionId) {
thread = codex.resumeThread(sessionId, threadOptions);
} else {
@@ -280,12 +284,10 @@ export async function queryCodex(command, options = {}, ws) {
});
};
// Existing sessions can be tracked immediately; new sessions are tracked after thread.started.
if (capturedSessionId) {
registerSession(capturedSessionId);
}
// Execute with streaming
const streamedTurn = await thread.runStreamed(command, {
signal: abortController.signal
});

View File

@@ -14,6 +14,14 @@ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
const activeOpenCodeProcesses = new Map();
function resolveOpenCodeEffort(model, effort, modelsDefinition) {
const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model);
const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
? effort
: undefined;
}
function readOpenCodeSessionId(event) {
if (!event || typeof event !== 'object') {
return null;
@@ -84,7 +92,7 @@ function readOpenCodeTokenUsage(sessionId) {
async function spawnOpenCode(command, options = {}, ws) {
return new Promise((resolve, reject) => {
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
const { sessionId, projectPath, cwd, model, effort, sessionSummary } = options;
const workingDir = cwd || projectPath || process.cwd();
const processKey = sessionId || Date.now().toString();
let capturedSessionId = sessionId || null;
@@ -192,7 +200,15 @@ async function spawnOpenCode(command, options = {}, ws) {
}
};
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then(async (resolvedModel) => {
let effortModels = null;
try {
effortModels = (await providerModelsService.getProviderModels('opencode')).models;
} catch (error) {
console.warn('[OpenCode] Unable to load provider models for effort validation:', error);
}
const resolvedEffort = resolveOpenCodeEffort(resolvedModel, effort, effortModels);
const args = ['run', '--format', 'json'];
// OpenCode's `run` command owns workspace selection through `--dir`.
// Relying on the child-process cwd alone is not enough on Linux, where
@@ -204,6 +220,9 @@ async function spawnOpenCode(command, options = {}, ws) {
if (resolvedModel) {
args.push('--model', resolvedModel);
}
if (resolvedEffort) {
args.push('--variant', resolvedEffort);
}
if (command && command.trim()) {
args.push(command.trim());
}

View File

@@ -646,12 +646,17 @@ class ResponseCollector {
*
* @param {string} model - (Optional) Model identifier for providers.
*
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
* Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'fable'
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
* 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
* Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
* Codex models: 'gpt-5.4' (default), 'gpt-5.5', 'gpt-5.4-mini'
*
* @param {string} effort - (Optional) Reasoning effort for providers/models that support it.
* Claude supports: 'low', 'medium', 'high', 'xhigh', 'max' depending on model.
* Codex supports: 'low', 'medium', 'high', 'xhigh'.
* 'default' or omission lets the provider decide.
*
* @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
* Default: true
@@ -844,6 +849,9 @@ class ResponseCollector {
*/
router.post('/', validateExternalApiKey, async (req, res) => {
const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName, sessionId } = req.body;
const effort = typeof req.body.effort === 'string' && req.body.effort.trim()
? req.body.effort.trim()
: undefined;
// Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
@@ -954,6 +962,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model,
effort,
permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
}, writer);
@@ -975,6 +984,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model || codexModels.DEFAULT,
effort,
permissionMode: 'bypassPermissions'
}, writer);
} else if (provider === 'gemini') {
@@ -994,7 +1004,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model || opencodeModels.DEFAULT
model: model || opencodeModels.DEFAULT,
effort
}, writer);
}

View File

@@ -74,6 +74,13 @@ export type ProviderModelOption = {
value: string;
label: string;
description?: string;
effort?: {
default?: string;
values: {
value: string;
description?: string;
}[];
};
};
/**

View File

@@ -0,0 +1,13 @@
import type { LLMProvider, ProviderModelOption } from '../../../types/app';
export const DEFAULT_EFFORT_VALUE = 'default';
export const FALLBACK_PROVIDER_EFFORT_VALUES: Partial<Record<LLMProvider, readonly string[]>> = {
claude: ['low', 'medium', 'high', 'xhigh', 'max'],
codex: ['low', 'medium', 'high', 'xhigh'],
opencode: ['none', 'low', 'medium', 'high', 'xhigh', 'max'],
};
export const toProviderEffortOptions = (
values: readonly string[],
): NonNullable<ProviderModelOption['effort']>['values'] => values.map((value) => ({ value }));

View File

@@ -34,9 +34,11 @@ interface UseChatComposerStateArgs {
provider: LLMProvider;
permissionMode: PermissionMode | string;
cyclePermissionMode: () => void;
resolvePermissionModeForProvider: (provider: LLMProvider, requestedMode: PermissionMode | string) => PermissionMode;
cursorModel: string;
claudeModel: string;
codexModel: string;
currentProviderEffort: string;
geminiModel: string;
opencodeModel: string;
isLoading: boolean;
@@ -168,9 +170,11 @@ export function useChatComposerState({
provider,
permissionMode,
cyclePermissionMode,
resolvePermissionModeForProvider,
cursorModel,
claudeModel,
codexModel,
currentProviderEffort,
geminiModel,
opencodeModel,
isLoading,
@@ -728,8 +732,9 @@ export function useChatComposerState({
: provider === 'gemini'
? geminiModel
: provider === 'opencode'
? opencodeModel
: claudeModel;
? opencodeModel
: claudeModel;
const effort = currentProviderEffort;
// One message shape for every provider. The backend resolves the
// provider, project path, and provider-native resume id from the
@@ -740,9 +745,8 @@ export function useChatComposerState({
content: messageContent,
options: {
model,
// Codex has no plan mode; downgrade rather than sending an
// unsupported value to its runtime.
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
effort,
permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
toolsSettings,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
@@ -769,6 +773,7 @@ export function useChatComposerState({
attachedImages,
claudeModel,
codexModel,
currentProviderEffort,
currentSessionId,
cursorModel,
executeCommand,
@@ -779,6 +784,7 @@ export function useChatComposerState({
onSessionEstablished,
permissionMode,
provider,
resolvePermissionModeForProvider,
resetCommandMenuState,
scrollToBottom,
selectedProject,

View File

@@ -1,22 +1,31 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type {
ProjectSession,
LLMProvider,
Project,
ProviderModelOption,
ProviderModelsCacheInfo,
ProviderModelsDefinition,
} from '../../../types/app';
import {
DEFAULT_EFFORT_VALUE,
FALLBACK_PROVIDER_EFFORT_VALUES,
toProviderEffortOptions,
} from '../constants/providerEffort';
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
claude: 'opus',
claude: 'default',
cursor: 'gpt-5.3-codex',
codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5',
};
const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
/**
* Fallback permission-mode matrix used only until the backend capability
* matrix (`GET /api/providers/capabilities`) has loaded. The backend is the
@@ -39,6 +48,7 @@ type ProviderCapabilities = {
supportsAbort: boolean;
supportsPermissionRequests: boolean;
supportsTokenUsage: boolean;
supportsEffort?: boolean;
};
type ProviderCapabilitiesApiResponse = {
@@ -72,7 +82,7 @@ type ChangeActiveModelApiResponse = {
};
};
export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) {
export function useChatProviderState({ selectedSession, selectedProject: _selectedProject }: UseChatProviderStateArgs) {
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
const [provider, setProvider] = useState<LLMProvider>(() => {
@@ -87,6 +97,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
});
const [providerEfforts, setProviderEfforts] = useState<Partial<Record<LLMProvider, string>>>(() => {
return PROVIDERS.reduce<Partial<Record<LLMProvider, string>>>((acc, targetProvider) => {
acc[targetProvider] = localStorage.getItem(`${targetProvider}-effort`) || DEFAULT_EFFORT_VALUE;
return acc;
}, {});
});
const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
});
@@ -145,8 +161,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
localStorage.setItem('opencode-model', model);
}, []);
const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => {
setProviderEfforts((previous) => (
previous[targetProvider] === effort
? previous
: { ...previous, [targetProvider]: effort }
));
localStorage.setItem(`${targetProvider}-effort`, effort);
}, []);
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;
@@ -159,7 +183,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
try {
const results = await Promise.all(
providers.map(async (p) => {
PROVIDERS.map(async (p) => {
const params = new URLSearchParams();
if (options.bypassCache) {
params.set('bypassCache', 'true');
@@ -183,7 +207,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const nextCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {};
providers.forEach((p, i) => {
PROVIDERS.forEach((p, i) => {
const entry = results[i];
if (!entry) {
return;
@@ -244,6 +268,23 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
}, [providerCapabilities]);
const getDefaultPermissionModeForProvider = useCallback((targetProvider: LLMProvider): PermissionMode => {
const modes = getPermissionModesForProvider(targetProvider);
const capabilityDefault = providerCapabilities?.[targetProvider]?.defaultPermissionMode as PermissionMode | undefined;
if (capabilityDefault && modes.includes(capabilityDefault)) {
return capabilityDefault;
}
return modes[0] ?? 'default';
}, [getPermissionModesForProvider, providerCapabilities]);
const getSupportsEffortForProvider = useCallback((targetProvider: LLMProvider): boolean => {
const capabilitySupport = providerCapabilities?.[targetProvider]?.supportsEffort;
if (typeof capabilitySupport === 'boolean') {
return capabilitySupport;
}
return Boolean(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider]?.length);
}, [providerCapabilities]);
const pickStoredOrCurrent = (
storageKey: string,
current: string,
@@ -259,6 +300,70 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return def.DEFAULT;
};
const getModelOption = useCallback((
targetProvider: LLMProvider,
model: string,
): ProviderModelOption | null => {
const definition = providerModelCatalog[targetProvider];
if (!definition) {
return null;
}
return definition.OPTIONS.find((option) => option.value === model) ?? null;
}, [providerModelCatalog]);
const getEffortOptionsForModel = useCallback((
targetProvider: LLMProvider,
model: string,
): NonNullable<ProviderModelOption['effort']>['values'] => {
if (!getSupportsEffortForProvider(targetProvider)) {
return [];
}
const option = getModelOption(targetProvider, model);
if (option) {
return option.effort?.values ?? [];
}
return toProviderEffortOptions(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider] ?? []);
}, [getModelOption, getSupportsEffortForProvider]);
const getAllowedEffortValues = useCallback((
targetProvider: LLMProvider,
model: string,
): string[] => (
getEffortOptionsForModel(targetProvider, model).map((value) => value.value)
), [getEffortOptionsForModel]);
const reconcileStoredEffort = useCallback((
targetProvider: LLMProvider,
model: string,
currentEffort: string,
): string => {
const allowedValues = getAllowedEffortValues(targetProvider, model);
if (allowedValues.length === 0) {
return DEFAULT_EFFORT_VALUE;
}
if (currentEffort === DEFAULT_EFFORT_VALUE || !currentEffort) {
return DEFAULT_EFFORT_VALUE;
}
if (allowedValues.includes(currentEffort)) {
return currentEffort;
}
return DEFAULT_EFFORT_VALUE;
}, [getAllowedEffortValues]);
const providerModels = useMemo<Record<LLMProvider, string>>(() => ({
claude: claudeModel,
cursor: cursorModel,
codex: codexModel,
gemini: geminiModel,
opencode: opencodeModel,
}), [claudeModel, cursorModel, codexModel, geminiModel, opencodeModel]);
useEffect(() => {
const claude = providerModelCatalog.claude;
if (claude) {
@@ -324,6 +429,27 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [providerModelCatalog.opencode, opencodeModel]);
useEffect(() => {
const nextEfforts: Partial<Record<LLMProvider, string>> = {};
let hasUpdates = false;
for (const targetProvider of PROVIDERS) {
const currentEffort = providerEfforts[targetProvider] ?? DEFAULT_EFFORT_VALUE;
const nextEffort = reconcileStoredEffort(targetProvider, providerModels[targetProvider], currentEffort);
if (nextEffort === currentEffort) {
continue;
}
nextEfforts[targetProvider] = nextEffort;
localStorage.setItem(`${targetProvider}-effort`, nextEffort);
hasUpdates = true;
}
if (hasUpdates) {
setProviderEfforts((previous) => ({ ...previous, ...nextEfforts }));
}
}, [providerEfforts, providerModels, reconcileStoredEffort]);
useEffect(() => {
if (!selectedSession?.id) {
return;
@@ -331,8 +457,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
const validModes = getPermissionModesForProvider(provider);
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
}, [selectedSession?.id, provider, getPermissionModesForProvider]);
setPermissionMode(
savedMode && validModes.includes(savedMode)
? savedMode
: getDefaultPermissionModeForProvider(provider),
);
}, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
useEffect(() => {
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
@@ -386,6 +516,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]);
const resolvePermissionModeForProvider = useCallback((
targetProvider: LLMProvider,
requestedMode: PermissionMode | string,
): PermissionMode => {
const validModes = getPermissionModesForProvider(targetProvider);
return validModes.includes(requestedMode as PermissionMode)
? requestedMode as PermissionMode
: getDefaultPermissionModeForProvider(targetProvider);
}, [getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
const selectProviderModel = useCallback(async (
targetProvider: LLMProvider,
model: string,
@@ -421,6 +561,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
};
}, [setStoredProviderModel]);
const currentProviderEffortOptions = useMemo(() => {
return getEffortOptionsForModel(provider, providerModels[provider]);
}, [getEffortOptionsForModel, provider, providerModels]);
const currentProviderEffort = useMemo(() => {
return reconcileStoredEffort(
provider,
providerModels[provider],
providerEfforts[provider] ?? DEFAULT_EFFORT_VALUE,
);
}, [provider, providerEfforts, providerModels, reconcileStoredEffort]);
return {
provider,
setProvider,
@@ -430,6 +581,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setClaudeModel,
codexModel,
setCodexModel,
currentProviderEffort,
currentProviderEffortOptions,
geminiModel,
setGeminiModel,
opencodeModel,
@@ -445,5 +598,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
providerModelsRefreshing,
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
selectProviderModel,
setStoredProviderEffort,
resolvePermissionModeForProvider,
};
}

View File

@@ -17,7 +17,6 @@ import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal';
function ChatInterface({
selectedProject,
selectedSession,
@@ -70,6 +69,8 @@ function ChatInterface({
setClaudeModel,
codexModel,
setCodexModel,
currentProviderEffort,
currentProviderEffortOptions,
geminiModel,
setGeminiModel,
opencodeModel,
@@ -84,6 +85,8 @@ function ChatInterface({
providerModelsRefreshing,
hardRefreshProviderModels,
selectProviderModel,
setStoredProviderEffort,
resolvePermissionModeForProvider,
} = useChatProviderState({
selectedSession,
selectedProject,
@@ -197,6 +200,7 @@ function ChatInterface({
cursorModel,
claudeModel,
codexModel,
currentProviderEffort,
geminiModel,
opencodeModel,
isLoading: isProcessing,
@@ -213,6 +217,7 @@ function ChatInterface({
addMessage,
setIsUserScrolledUp,
setPendingPermissionRequests,
resolvePermissionModeForProvider,
});
// On WebSocket reconnect, re-fetch the current session's messages from the
@@ -383,6 +388,9 @@ function ChatInterface({
onAbortSession={handleAbortSession}
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
effort={currentProviderEffort}
availableEffortOptions={currentProviderEffortOptions}
onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)}
tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal}
slashCommandsCount={slashCommandsCount}

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type {
ChangeEvent,
ClipboardEvent,
@@ -11,12 +11,13 @@ import type {
RefObject,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check } from 'lucide-react';
import { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
import type { ProviderModelOption } from '../../../../types/app';
import {
PromptInput,
PromptInputHeader,
@@ -62,6 +63,9 @@ interface ChatComposerProps {
onAbortSession: () => void;
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
effort: string;
availableEffortOptions: NonNullable<ProviderModelOption['effort']>['values'];
onSelectEffort: (effort: string) => void;
tokenBudget: Record<string, unknown> | null;
onShowTokenUsage: () => void;
slashCommandsCount: number;
@@ -114,6 +118,9 @@ export default function ChatComposer({
onAbortSession,
permissionMode,
onModeSwitch,
effort,
availableEffortOptions,
onSelectEffort,
tokenBudget,
onShowTokenUsage,
slashCommandsCount,
@@ -167,7 +174,7 @@ export default function ChatComposer({
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
}, [input, isCommandMenuOpen, textareaRef]);
}, [isCommandMenuOpen, textareaRef]);
// Voice state is hosted here (not in the mic button) so the main Send button can stop
// recording and send the transcript in one tap, the way the mic button drops it in the box.
@@ -189,6 +196,67 @@ export default function ChatComposer({
);
const isRecording = voiceState === 'recording';
const isTranscribing = voiceState === 'transcribing';
const [isEffortDropdownOpen, setIsEffortDropdownOpen] = useState(false);
const effortDropdownRef = useRef<HTMLDivElement | null>(null);
const effortDropdownMenuRef = useRef<HTMLDivElement | null>(null);
const effortDropdownButtonRef = useRef<HTMLButtonElement | null>(null);
const [effortDropdownPosition, setEffortDropdownPosition] = useState<{
left: number;
top: number;
maxHeight: number;
} | null>(null);
const effortOptions = useMemo(
() => [{ value: 'default' }, ...availableEffortOptions],
[availableEffortOptions],
);
const selectedEffortLabel = effort === 'default' ? 'Default' : effort;
const updateEffortDropdownPosition = useCallback(() => {
const rect = effortDropdownButtonRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
setEffortDropdownPosition({
left: rect.left,
top: rect.top - 8,
maxHeight: Math.max(96, rect.top - 16),
});
}, []);
useEffect(() => {
if (!isEffortDropdownOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (
!effortDropdownRef.current?.contains(target)
&& !effortDropdownMenuRef.current?.contains(target)
) {
setIsEffortDropdownOpen(false);
}
};
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setIsEffortDropdownOpen(false);
}
};
document.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('resize', updateEffortDropdownPosition);
window.addEventListener('scroll', updateEffortDropdownPosition, true);
window.addEventListener('keydown', handleKeyDown, { capture: true });
updateEffortDropdownPosition();
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('resize', updateEffortDropdownPosition);
window.removeEventListener('scroll', updateEffortDropdownPosition, true);
window.removeEventListener('keydown', handleKeyDown, { capture: true });
};
}, [isEffortDropdownOpen, updateEffortDropdownPosition]);
// Detect if the AskUserQuestion interactive panel is active
const hasQuestionPanel = pendingPermissionRequests.some(
@@ -376,6 +444,70 @@ export default function ChatComposer({
</div>
</button>
{availableEffortOptions.length > 0 && (
<div ref={effortDropdownRef} className="relative">
<button
ref={effortDropdownButtonRef}
type="button"
onClick={() => {
updateEffortDropdownPosition();
setIsEffortDropdownOpen((current) => !current);
}}
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-muted/40 px-2 text-xs font-medium text-foreground transition-all duration-200 hover:bg-muted"
aria-haspopup="menu"
aria-expanded={isEffortDropdownOpen}
aria-label="Select reasoning effort"
title="Select reasoning effort"
>
<span className="hidden text-[11px] text-muted-foreground sm:inline">Effort</span>
<span className="max-w-16 truncate capitalize sm:max-w-20">{selectedEffortLabel}</span>
<ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${isEffortDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{isEffortDropdownOpen && effortDropdownPosition && createPortal(
<div
ref={effortDropdownMenuRef}
className="fixed z-[100] min-w-36 overflow-y-auto rounded-lg border border-border bg-card p-1 shadow-lg"
style={{
left: effortDropdownPosition.left,
top: effortDropdownPosition.top,
maxHeight: effortDropdownPosition.maxHeight,
transform: 'translateY(-100%)',
}}
role="menu"
>
{effortOptions.map((option) => {
const isSelected = option.value === effort;
const label = option.value === 'default' ? 'Default' : option.value;
return (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={isSelected}
onClick={() => {
onSelectEffort(option.value);
setIsEffortDropdownOpen(false);
}}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs capitalize transition-colors ${
isSelected
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/70 hover:text-foreground'
}`}
>
<span className="flex h-3 w-3 items-center justify-center">
{isSelected && <Check className="h-3 w-3 text-primary" />}
</span>
<span>{label}</span>
</button>
);
})}
</div>,
document.body,
)}
</div>
)}
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
<PromptInputButton

View File

@@ -263,7 +263,6 @@ function ModelsContent({
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
return availableModels.map((model) => ({ value: model, label: model }));
}, [data, liveDefinition]);
const filteredOptions = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) {

View File

@@ -4,6 +4,13 @@ export type ProviderModelOption = {
value: string;
label: string;
description?: string;
effort?: {
default?: string;
values: {
value: string;
description?: string;
}[];
};
};
export type ProviderModelsDefinition = {