Compare commits

..

2 Commits

Author SHA1 Message Date
viper151
d8dfb2cbb6 chore(release): v1.35.1 2026-07-01 15:39:39 +00:00
Simos Mikelatos
1e16f1f085 fix: remove obsolete semantic helper release jobs 2026-07-01 15:33:37 +00:00
23 changed files with 88 additions and 907 deletions

View File

@@ -20,82 +20,7 @@ permissions:
# to immutable commit SHAs. The trailing comments keep the original major tag # to immutable commit SHAs. The trailing comments keep the original major tag
# visible for maintenance context. # visible for maintenance context.
jobs: jobs:
build-macos-semantic-helper:
strategy:
fail-fast: false
matrix:
include:
- runs_on: macos-15
target_dir: darwin-arm64
- runs_on: macos-15-intel
target_dir: darwin-x64
runs-on: ${{ matrix.runs_on }}
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
- name: Build macOS semantic helper
run: node scripts/build-computer-semantics.mjs
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
- name: Verify macOS semantic helper target
run: test -x "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics"
- name: Stage macOS semantic helper artifact
run: |
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics" "semantic-helper-artifact/${{ matrix.target_dir }}/"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: semantic-helper-${{ matrix.target_dir }}
path: semantic-helper-artifact/*
if-no-files-found: error
build-windows-semantic-helper:
strategy:
fail-fast: false
matrix:
include:
- runs_on: windows-2025
target_dir: win32-x64
- runs_on: windows-11-arm
target_dir: win32-arm64
runs-on: ${{ matrix.runs_on }}
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
- name: Build Windows semantic helper
run: node scripts/build-computer-semantics.mjs
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
- name: Verify Windows semantic helper target
shell: bash
run: test -f "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe"
- name: Stage Windows semantic helper artifact
shell: bash
run: |
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe" "semantic-helper-artifact/${{ matrix.target_dir }}/"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: semantic-helper-${{ matrix.target_dir }}
path: semantic-helper-artifact/*
if-no-files-found: error
release: release:
needs:
- build-macos-semantic-helper
- build-windows-semantic-helper
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
@@ -118,23 +43,6 @@ jobs:
- run: npm ci - run: npm ci
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
pattern: semantic-helper-*
path: server/modules/computer-use/semantics/bin
merge-multiple: true
- name: Restore semantic helper permissions
run: find server/modules/computer-use/semantics/bin -path '*/darwin-*/CloudCLISemantics' -type f -exec chmod 755 {} +
- name: Verify bundled semantic helpers
run: |
test -x server/modules/computer-use/semantics/bin/darwin-arm64/CloudCLISemantics
test -x server/modules/computer-use/semantics/bin/darwin-x64/CloudCLISemantics
test -f server/modules/computer-use/semantics/bin/win32-x64/CloudCLISemantics.exe
test -f server/modules/computer-use/semantics/bin/win32-arm64/CloudCLISemantics.exe
find server/modules/computer-use/semantics/bin -maxdepth 2 -type f -print
- name: Release - name: Release
run: | run: |
ARGS="--ci --increment=${{ inputs.increment }}" ARGS="--ci --increment=${{ inputs.increment }}"

View File

@@ -3,6 +3,18 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [1.35.1](https://github.com/siteboon/claudecodeui/compare/v1.35.0...v1.35.1) (2026-07-01)
### Bug Fixes
* preview video on new tab ([#933](https://github.com/siteboon/claudecodeui/issues/933)) ([2ebe64f](https://github.com/siteboon/claudecodeui/commit/2ebe64f21874f45f6c8747310be874ae7342c61c))
* remove obsolete semantic helper release jobs ([1e16f1f](https://github.com/siteboon/claudecodeui/commit/1e16f1f0854e347aa333434638d64f2b167d9a9d))
* resolve mobile shell issues ([#923](https://github.com/siteboon/claudecodeui/issues/923)) ([b6cf333](https://github.com/siteboon/claudecodeui/commit/b6cf33308da996f8169580a4b5b74e3c5f38e447))
### Maintenance
* remove computer use ([6761f31](https://github.com/siteboon/claudecodeui/commit/6761f31a56fe82d82c7e0c079b4891e7d5a81817))
## [1.35.0](https://github.com/siteboon/claudecodeui/compare/v1.34.0...v1.35.0) (2026-06-29) ## [1.35.0](https://github.com/siteboon/claudecodeui/compare/v1.34.0...v1.35.0) (2026-06-29)
### New Features ### New Features

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.35.0", "version": "1.35.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.35.0", "version": "1.35.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.35.0", "version": "1.35.1",
"productName": "CloudCLI", "productName": "CloudCLI",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ 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,89 +18,27 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
{ {
value: 'default', value: 'default',
label: 'Default (recommended)', label: 'Default (recommended)',
description: 'Use the Claude Code default model (currently Sonnet 4.6)', description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'max' },
],
},
}, },
{ {
value: 'fable', value: 'fable',
label: 'Fable', label: 'Fable',
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus', 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", value: "sonnet",
label: "Sonnet", label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok", 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]', value: 'sonnet[1m]',
label: 'Sonnet (1M context)', label: 'Sonnet (1M context)',
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok', 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]', value: 'opus[1m]',
label: 'Opus 4.8 (1M context)', label: 'Opus 4.8 (1M context)',
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok', 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', value: 'haiku',
@@ -111,15 +48,6 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
], ],
DEFAULT: 'default', 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 = { type ClaudeInitEvent = {
sessionId?: string; sessionId?: string;
session_id?: string; session_id?: string;

View File

@@ -21,30 +21,11 @@ import {
export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = { export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [ OPTIONS: [
{ { value: 'gpt-5.5', label: 'gpt-5.5' },
value: 'gpt-5.5', { value: 'gpt-5.4', label: 'gpt-5.4' },
label: 'gpt-5.5', { value: 'gpt-5.4-mini', label: 'gpt-5.4-mini' },
effort: { { value: 'gpt-5.3-codex', label: 'gpt-5.3-codex' },
default: 'medium', { value: 'gpt-5.2', label: 'gpt-5.2' },
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', DEFAULT: 'gpt-5.4',
}; };
@@ -56,11 +37,6 @@ type CodexCachedModel = {
priority?: number; priority?: number;
visibility?: string; visibility?: string;
supported_in_api?: boolean; 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'); const CODEX_MODELS_CACHE_PATH = path.join(os.homedir(), '.codex', 'models_cache.json');
@@ -75,39 +51,15 @@ const readCodexPriority = (value: unknown): number => (
typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER
); );
const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => { const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({
const effortValues = Array.isArray(model.supported_reasoning_levels) value: model.slug as string,
? model.supported_reasoning_levels label: readOptionalString(model.display_name) ?? (model.slug as string),
.map((level) => { description: readOptionalString(model.description),
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 buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
const sortedModels = [...models] const sortedModels = [...models]
.filter((model) => model.visibility === 'list' && model.supported_in_api !== false) .filter((model) => model.visibility !== 'hidden' && model.supported_in_api !== false)
.sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority)); .sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority));
const options: ProviderModelOption[] = []; const options: ProviderModelOption[] = [];

View File

@@ -74,13 +74,6 @@ const VERSION_TOKEN = /^[a-z]\d+$/i;
const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/; const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/;
const SHORT_ACRONYM_TOKEN = /^[a-z]{2,3}$/; 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[] => { export const parseOpenCodeModelsStdout = (stdout: string): string[] => {
const ids: string[] = []; const ids: string[] = [];
@@ -98,83 +91,6 @@ export const parseOpenCodeModelsStdout = (stdout: string): string[] => {
return [...new Set(ids)]; 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 => ( const formatDateToken = (token: string): string => (
`${token.slice(0, 4)}-${token.slice(4, 6)}-${token.slice(6, 8)}` `${token.slice(0, 4)}-${token.slice(4, 6)}-${token.slice(6, 8)}`
); );
@@ -239,20 +155,6 @@ 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 labelForOpenCodeModelId = (id: string): string => {
const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label; const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label;
if (fallbackLabel) { if (fallbackLabel) {
@@ -268,52 +170,6 @@ const descriptionForOpenCodeModelId = (id: string): string => {
return upstreamProvider ? `${upstreamProvider} - ${id}` : id; 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 => { export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = ids.map((value) => ({ const options: ProviderModelOption[] = ids.map((value) => ({
value, value,
@@ -331,36 +187,6 @@ 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 => { const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => {
if (typeof rawModel === 'string') { if (typeof rawModel === 'string') {
const trimmed = rawModel.trim(); const trimmed = rawModel.trim();
@@ -388,7 +214,7 @@ const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => {
}; };
const runOpenCodeModelsCommand = (): Promise<string> => new Promise((resolve, reject) => { const runOpenCodeModelsCommand = (): Promise<string> => new Promise((resolve, reject) => {
const openCodeProcess = spawnFunction('opencode', ['models', '--verbose'], { const openCodeProcess = spawnFunction('opencode', ['models'], {
cwd: process.cwd(), cwd: process.cwd(),
env: { ...process.env }, env: { ...process.env },
}); });
@@ -447,11 +273,6 @@ export class OpenCodeProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> { async getSupportedModels(): Promise<ProviderModelsDefinition> {
try { try {
const stdout = await runOpenCodeModelsCommand(); const stdout = await runOpenCodeModelsCommand();
const verboseModels = parseOpenCodeVerboseModelsStdout(stdout);
if (verboseModels.length > 0) {
return buildOpenCodeDefinitionFromVerboseModels(verboseModels);
}
const ids = parseOpenCodeModelsStdout(stdout); const ids = parseOpenCodeModelsStdout(stdout);
if (ids.length === 0) { if (ids.length === 0) {
return OPENCODE_FALLBACK_MODELS; return OPENCODE_FALLBACK_MODELS;

View File

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

View File

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

View File

@@ -2,10 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
buildOpenCodeDefinitionFromVerboseModels,
buildOpenCodeDefinitionFromIds, buildOpenCodeDefinitionFromIds,
parseOpenCodeModelsStdout, parseOpenCodeModelsStdout,
parseOpenCodeVerboseModelsStdout,
} from '@/modules/providers/list/opencode/opencode-models.provider.js'; } from '@/modules/providers/list/opencode/opencode-models.provider.js';
test('OpenCode models provider parses plain CLI output and removes duplicates', () => { test('OpenCode models provider parses plain CLI output and removes duplicates', () => {
@@ -73,63 +71,3 @@ 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,6 +20,7 @@ import { providerAuthService } from './modules/providers/services/provider-auth.
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
// Track active sessions
const activeCodexSessions = new Map(); const activeCodexSessions = new Map();
function readUsageNumber(value) { function readUsageNumber(value) {
@@ -227,7 +228,6 @@ export async function queryCodex(command, options = {}, ws) {
cwd, cwd,
projectPath, projectPath,
model, model,
effort,
permissionMode = 'default' permissionMode = 'default'
} = options; } = options;
@@ -239,12 +239,6 @@ export async function queryCodex(command, options = {}, ws) {
const workingDirectory = cwd || projectPath || process.cwd(); const workingDirectory = cwd || projectPath || process.cwd();
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode); 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 codex;
let thread; let thread;
@@ -254,17 +248,19 @@ export async function queryCodex(command, options = {}, ws) {
const abortController = new AbortController(); const abortController = new AbortController();
try { try {
// Initialize Codex SDK
codex = new Codex(); codex = new Codex();
// Thread options with sandbox and approval settings
const threadOptions = { const threadOptions = {
workingDirectory, workingDirectory,
skipGitRepoCheck: true, skipGitRepoCheck: true,
sandboxMode, sandboxMode,
approvalPolicy, approvalPolicy,
model: resolvedModel, model: resolvedModel
modelReasoningEffort: resolvedEffort,
}; };
// Start or resume thread
if (sessionId) { if (sessionId) {
thread = codex.resumeThread(sessionId, threadOptions); thread = codex.resumeThread(sessionId, threadOptions);
} else { } else {
@@ -284,10 +280,12 @@ export async function queryCodex(command, options = {}, ws) {
}); });
}; };
// Existing sessions can be tracked immediately; new sessions are tracked after thread.started.
if (capturedSessionId) { if (capturedSessionId) {
registerSession(capturedSessionId); registerSession(capturedSessionId);
} }
// Execute with streaming
const streamedTurn = await thread.runStreamed(command, { const streamedTurn = await thread.runStreamed(command, {
signal: abortController.signal signal: abortController.signal
}); });

View File

@@ -14,14 +14,6 @@ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
const activeOpenCodeProcesses = new Map(); 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) { function readOpenCodeSessionId(event) {
if (!event || typeof event !== 'object') { if (!event || typeof event !== 'object') {
return null; return null;
@@ -92,7 +84,7 @@ function readOpenCodeTokenUsage(sessionId) {
async function spawnOpenCode(command, options = {}, ws) { async function spawnOpenCode(command, options = {}, ws) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const { sessionId, projectPath, cwd, model, effort, sessionSummary } = options; const { sessionId, projectPath, cwd, model, sessionSummary } = options;
const workingDir = cwd || projectPath || process.cwd(); const workingDir = cwd || projectPath || process.cwd();
const processKey = sessionId || Date.now().toString(); const processKey = sessionId || Date.now().toString();
let capturedSessionId = sessionId || null; let capturedSessionId = sessionId || null;
@@ -200,15 +192,7 @@ async function spawnOpenCode(command, options = {}, ws) {
} }
}; };
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then(async (resolvedModel) => { void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((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']; const args = ['run', '--format', 'json'];
// OpenCode's `run` command owns workspace selection through `--dir`. // OpenCode's `run` command owns workspace selection through `--dir`.
// Relying on the child-process cwd alone is not enough on Linux, where // Relying on the child-process cwd alone is not enough on Linux, where
@@ -220,9 +204,6 @@ async function spawnOpenCode(command, options = {}, ws) {
if (resolvedModel) { if (resolvedModel) {
args.push('--model', resolvedModel); args.push('--model', resolvedModel);
} }
if (resolvedEffort) {
args.push('--variant', resolvedEffort);
}
if (command && command.trim()) { if (command && command.trim()) {
args.push(command.trim()); args.push(command.trim());
} }

View File

@@ -646,17 +646,12 @@ class ResponseCollector {
* *
* @param {string} model - (Optional) Model identifier for providers. * @param {string} model - (Optional) Model identifier for providers.
* *
* Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'fable' * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5', * 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', * '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', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
* 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
* Codex models: 'gpt-5.4' (default), 'gpt-5.5', 'gpt-5.4-mini' * Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-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. * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
* Default: true * Default: true
@@ -849,9 +844,6 @@ class ResponseCollector {
*/ */
router.post('/', validateExternalApiKey, async (req, res) => { router.post('/', validateExternalApiKey, async (req, res) => {
const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName, sessionId } = req.body; 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) // 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'); const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
@@ -962,7 +954,6 @@ router.post('/', validateExternalApiKey, async (req, res) => {
cwd: finalProjectPath, cwd: finalProjectPath,
sessionId: sessionId || null, sessionId: sessionId || null,
model: model, model: model,
effort,
permissionMode: 'bypassPermissions' // Bypass all permissions for API calls permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
}, writer); }, writer);
@@ -984,7 +975,6 @@ router.post('/', validateExternalApiKey, async (req, res) => {
cwd: finalProjectPath, cwd: finalProjectPath,
sessionId: sessionId || null, sessionId: sessionId || null,
model: model || codexModels.DEFAULT, model: model || codexModels.DEFAULT,
effort,
permissionMode: 'bypassPermissions' permissionMode: 'bypassPermissions'
}, writer); }, writer);
} else if (provider === 'gemini') { } else if (provider === 'gemini') {
@@ -1004,8 +994,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath, projectPath: finalProjectPath,
cwd: finalProjectPath, cwd: finalProjectPath,
sessionId: sessionId || null, sessionId: sessionId || null,
model: model || opencodeModels.DEFAULT, model: model || opencodeModels.DEFAULT
effort
}, writer); }, writer);
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useMemo } from 'react';
import { createPortal } from 'react-dom'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { import type {
ChangeEvent, ChangeEvent,
ClipboardEvent, ClipboardEvent,
@@ -11,13 +11,12 @@ import type {
RefObject, RefObject,
TouchEvent, TouchEvent,
} from 'react'; } from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check } from 'lucide-react'; import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
import { useVoiceInput } from '../../hooks/useVoiceInput'; import { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
import type { SessionActivity } from '../../../../hooks/useSessionProtection'; import type { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types'; import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
import type { ProviderModelOption } from '../../../../types/app';
import { import {
PromptInput, PromptInput,
PromptInputHeader, PromptInputHeader,
@@ -63,9 +62,6 @@ interface ChatComposerProps {
onAbortSession: () => void; onAbortSession: () => void;
permissionMode: PermissionMode | string; permissionMode: PermissionMode | string;
onModeSwitch: () => void; onModeSwitch: () => void;
effort: string;
availableEffortOptions: NonNullable<ProviderModelOption['effort']>['values'];
onSelectEffort: (effort: string) => void;
tokenBudget: Record<string, unknown> | null; tokenBudget: Record<string, unknown> | null;
onShowTokenUsage: () => void; onShowTokenUsage: () => void;
slashCommandsCount: number; slashCommandsCount: number;
@@ -118,9 +114,6 @@ export default function ChatComposer({
onAbortSession, onAbortSession,
permissionMode, permissionMode,
onModeSwitch, onModeSwitch,
effort,
availableEffortOptions,
onSelectEffort,
tokenBudget, tokenBudget,
onShowTokenUsage, onShowTokenUsage,
slashCommandsCount, slashCommandsCount,
@@ -174,7 +167,7 @@ export default function ChatComposer({
left: textareaRect ? textareaRect.left : 16, left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90, bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
}; };
}, [isCommandMenuOpen, textareaRef]); }, [input, isCommandMenuOpen, textareaRef]);
// Voice state is hosted here (not in the mic button) so the main Send button can stop // 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. // recording and send the transcript in one tap, the way the mic button drops it in the box.
@@ -196,67 +189,6 @@ export default function ChatComposer({
); );
const isRecording = voiceState === 'recording'; const isRecording = voiceState === 'recording';
const isTranscribing = voiceState === 'transcribing'; 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 // Detect if the AskUserQuestion interactive panel is active
const hasQuestionPanel = pendingPermissionRequests.some( const hasQuestionPanel = pendingPermissionRequests.some(
@@ -444,70 +376,6 @@ export default function ChatComposer({
</div> </div>
</button> </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} /> <TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
<PromptInputButton <PromptInputButton

View File

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

View File

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