From 1e16f1f0854e347aa333434638d64f2b167d9a9d Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Wed, 1 Jul 2026 15:33:37 +0000 Subject: [PATCH 1/5] fix: remove obsolete semantic helper release jobs --- .github/workflows/release.yml | 92 ----------------------------------- 1 file changed, 92 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1253ca9..6b090c72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,82 +20,7 @@ permissions: # to immutable commit SHAs. The trailing comments keep the original major tag # visible for maintenance context. 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: - needs: - - build-macos-semantic-helper - - build-windows-semantic-helper runs-on: ubuntu-latest permissions: contents: write @@ -118,23 +43,6 @@ jobs: - 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 run: | ARGS="--ci --increment=${{ inputs.increment }}" From d8dfb2cbb65ee7ba8937c96384ad9c397ce1d3d4 Mon Sep 17 00:00:00 2001 From: viper151 Date: Wed, 1 Jul 2026 15:39:39 +0000 Subject: [PATCH 2/5] chore(release): v1.35.1 --- CHANGELOG.md | 12 ++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f59e84..6627fe6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ 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) ### New Features diff --git a/package-lock.json b/package-lock.json index 516badd3..8c702fee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.35.0", + "version": "1.35.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cloudcli-ai/cloudcli", - "version": "1.35.0", + "version": "1.35.1", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 7c19017c..57a84a54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.35.0", + "version": "1.35.1", "productName": "CloudCLI", "description": "A web-based UI for Claude Code CLI", "type": "module", From e93c83addb0bc796be239ba37eacf31ec7066c01 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Fri, 3 Jul 2026 07:22:50 +0000 Subject: [PATCH 3/5] fix desktop release asset upload --- .github/workflows/desktop-release.yml | 56 ++++++++++----------------- 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 43e5fda9..4d2f7089 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -4,18 +4,9 @@ on: workflow_dispatch: inputs: tag: - description: "Release tag to create or update (defaults to v)" + description: "Existing release tag to upload desktop assets to (defaults to v)" required: false type: string - release_name: - description: 'Release name (defaults to "CloudCLI Desktop ")' - 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 From d272922d87e4ee74bf8b0fdeac83b2c1e77973f3 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Fri, 3 Jul 2026 18:11:49 +0200 Subject: [PATCH 4/5] feat: add Claude and Codex effort controls (#943) * feat: add Claude and Codex effort controls * refactor: generic provider effort handling * fix: reconcile provider effort after model changes * fix: pass effort through external agent api * feat: add effort support for opencode * chore: update gpt fallback models * fix: use portal for showing effort dropdown --------- Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com> --- public/api-docs.html | 10 +- server/claude-sdk.js | 58 +++--- .../list/claude/claude-models.provider.ts | 74 ++++++- .../list/codex/codex-models.provider.ts | 70 +++++-- .../list/opencode/opencode-models.provider.ts | 181 +++++++++++++++++- .../services/provider-capabilities.service.ts | 7 + .../services/provider-models.service.ts | 2 +- .../providers/tests/opencode-models.test.ts | 62 ++++++ server/openai-codex.js | 16 +- server/opencode-cli.js | 23 ++- server/routes/agent.js | 17 +- server/shared/types.ts | 7 + .../chat/constants/providerEffort.ts | 13 ++ .../chat/hooks/useChatComposerState.ts | 16 +- .../chat/hooks/useChatProviderState.ts | 171 ++++++++++++++++- src/components/chat/view/ChatInterface.tsx | 10 +- .../chat/view/subcomponents/ChatComposer.tsx | 140 +++++++++++++- .../view/subcomponents/CommandResultModal.tsx | 1 - src/types/app.ts | 7 + 19 files changed, 812 insertions(+), 73 deletions(-) create mode 100644 src/components/chat/constants/providerEffort.ts diff --git a/public/api-docs.html b/public/api-docs.html index 03dbb9b8..040d8680 100644 --- a/public/api-docs.html +++ b/public/api-docs.html @@ -489,7 +489,7 @@ http://localhost:3001/api/agent -

Trigger an AI agent (Claude, Cursor, or Codex) to work on a project.

+

Trigger an AI agent (Claude, Cursor, Codex, Gemini, or OpenCode) to work on a project.

Request Body Parameters

@@ -524,7 +524,7 @@ - + @@ -540,6 +540,12 @@ Model identifier for the AI provider (loading from constants...) + + + + + + diff --git a/server/claude-sdk.js b/server/claude-sdk.js index a0a795c6..426ce029 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -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'; diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts index f6c4c0c6..2c80cc40 100644 --- a/server/modules/providers/list/claude/claude-models.provider.ts +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -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; diff --git a/server/modules/providers/list/codex/codex-models.provider.ts b/server/modules/providers/list/codex/codex-models.provider.ts index de4de0ea..bc157377 100644 --- a/server/modules/providers/list/codex/codex-models.provider.ts +++ b/server/modules/providers/list/codex/codex-models.provider.ts @@ -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 => 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[] = []; diff --git a/server/modules/providers/list/opencode/opencode-models.provider.ts b/server/modules/providers/list/opencode/opencode-models.provider.ts index 0e5f7477..6891b4df 100644 --- a/server/modules/providers/list/opencode/opencode-models.provider.ts +++ b/server/modules/providers/list/opencode/opencode-models.provider.ts @@ -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; +}; + 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['values'] => { + const effortValues: NonNullable['values'] = []; + const seenValues = new Set(); + + 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(); + + 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 => 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 { 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; diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index 1b7cbbb3..dcd5f1ab 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -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 = { supportsAbort: true, supportsPermissionRequests: true, supportsTokenUsage: true, + supportsEffort: true, }, cursor: { provider: 'cursor', @@ -47,6 +50,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: false, + supportsEffort: false, }, codex: { provider: 'codex', @@ -56,6 +60,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, + supportsEffort: true, }, gemini: { provider: 'gemini', @@ -65,6 +70,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, + supportsEffort: false, }, opencode: { provider: 'opencode', @@ -74,6 +80,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, + supportsEffort: true, }, }; diff --git a/server/modules/providers/services/provider-models.service.ts b/server/modules/providers/services/provider-models.service.ts index 9162df0c..cb183f99 100644 --- a/server/modules/providers/services/provider-models.service.ts +++ b/server/modules/providers/services/provider-models.service.ts @@ -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(['claude', 'gemini']); type ProviderModelsServiceDependencies = { diff --git a/server/modules/providers/tests/opencode-models.test.ts b/server/modules/providers/tests/opencode-models.test.ts index c28ac0ef..097c1272 100644 --- a/server/modules/providers/tests/opencode-models.test.ts +++ b/server/modules/providers/tests/opencode-models.test.ts @@ -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' }, + ], + }, + }, + ]); +}); diff --git a/server/openai-codex.js b/server/openai-codex.js index 34f5bc05..47e89ab6 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -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 }); diff --git a/server/opencode-cli.js b/server/opencode-cli.js index e8446329..d486d949 100644 --- a/server/opencode-cli.js +++ b/server/opencode-cli.js @@ -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()); } diff --git a/server/routes/agent.js b/server/routes/agent.js index f2273181..5febbf61 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -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); } diff --git a/server/shared/types.ts b/server/shared/types.ts index 5d411efe..c803856b 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -74,6 +74,13 @@ export type ProviderModelOption = { value: string; label: string; description?: string; + effort?: { + default?: string; + values: { + value: string; + description?: string; + }[]; + }; }; /** diff --git a/src/components/chat/constants/providerEffort.ts b/src/components/chat/constants/providerEffort.ts new file mode 100644 index 00000000..28e26d35 --- /dev/null +++ b/src/components/chat/constants/providerEffort.ts @@ -0,0 +1,13 @@ +import type { LLMProvider, ProviderModelOption } from '../../../types/app'; + +export const DEFAULT_EFFORT_VALUE = 'default'; + +export const FALLBACK_PROVIDER_EFFORT_VALUES: Partial> = { + 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['values'] => values.map((value) => ({ value })); diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 9817b6d4..9dd1bbc0 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -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, diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index ea49d841..86a0eced 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -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 = { - 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('default'); const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]); const [provider, setProvider] = useState(() => { @@ -87,6 +97,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const [codexModel, setCodexModel] = useState(() => { return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex; }); + const [providerEfforts, setProviderEfforts] = useState>>(() => { + return PROVIDERS.reduce>>((acc, targetProvider) => { + acc[targetProvider] = localStorage.getItem(`${targetProvider}-effort`) || DEFAULT_EFFORT_VALUE; + return acc; + }, {}); + }); const [geminiModel, setGeminiModel] = useState(() => { 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> = {}; const nextCacheCatalog: Partial> = {}; - 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['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>(() => ({ + 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> = {}; + 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, }; } diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 2fdcba57..7544ee44 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -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} diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index cc3f397c..ba35dfd8 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -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['values']; + onSelectEffort: (effort: string) => void; tokenBudget: Record | 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(null); + const effortDropdownMenuRef = useRef(null); + const effortDropdownButtonRef = useRef(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({ + {availableEffortOptions.length > 0 && ( +
+ + + {isEffortDropdownOpen && effortDropdownPosition && createPortal( +
+ {effortOptions.map((option) => { + const isSelected = option.value === effort; + const label = option.value === 'default' ? 'Default' : option.value; + return ( + + ); + })} +
, + document.body, + )} +
+ )} + ({ value: model, label: model })); }, [data, liveDefinition]); - const filteredOptions = useMemo(() => { const normalized = query.trim().toLowerCase(); if (!normalized) { diff --git a/src/types/app.ts b/src/types/app.ts index f81c3e26..b2ea97c4 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -4,6 +4,13 @@ export type ProviderModelOption = { value: string; label: string; description?: string; + effort?: { + default?: string; + values: { + value: string; + description?: string; + }[]; + }; }; export type ProviderModelsDefinition = { From 25d264d8d85623770be6538f70052dcd5c679224 Mon Sep 17 00:00:00 2001 From: viper151 Date: Fri, 3 Jul 2026 16:13:55 +0000 Subject: [PATCH 5/5] chore(release): v1.36.0 --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6627fe6c..854d5b36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to CloudCLI UI will be documented in this file. +## [](https://github.com/siteboon/claudecodeui/compare/v1.35.1...vnull) (2026-07-03) + +### New Features + +* add Claude and Codex effort controls ([#943](https://github.com/siteboon/claudecodeui/issues/943)) ([d272922](https://github.com/siteboon/claudecodeui/commit/d272922d87e4ee74bf8b0fdeac83b2c1e77973f3)) + ## [1.35.1](https://github.com/siteboon/claudecodeui/compare/v1.35.0...v1.35.1) (2026-07-01) ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index 8c702fee..ec345985 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.35.1", + "version": "1.36.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cloudcli-ai/cloudcli", - "version": "1.35.1", + "version": "1.36.0", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 57a84a54..25861d43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.35.1", + "version": "1.36.0", "productName": "CloudCLI", "description": "A web-based UI for Claude Code CLI", "type": "module",
provider string Optionalclaude, cursor, or codex (default: claude)claude, cursor, codex, gemini, or opencode (default: claude)
stream
effortstringOptionalReasoning effort for Claude, Codex, and OpenCode models that expose effort metadata. Use default or omit it to let the provider decide.
cleanup boolean