mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-01 18:13:03 +08:00
feat: add Claude and Codex effort controls
This commit is contained in:
@@ -41,6 +41,16 @@ 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();
|
||||||
@@ -145,13 +155,8 @@ 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 } = options;
|
const { sessionId, cwd, toolsSettings, permissionMode, effort } = options;
|
||||||
|
|
||||||
const sdkOptions = {};
|
const sdkOptions = {};
|
||||||
|
|
||||||
@@ -163,32 +168,26 @@ 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,22 +206,24 @@ 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
|
|
||||||
|
|
||||||
// Map system prompt configuration
|
const resolvedEffort = resolveClaudeEffort(
|
||||||
|
sdkOptions.model,
|
||||||
|
effort,
|
||||||
|
options.effortModels || CLAUDE_FALLBACK_MODELS,
|
||||||
|
);
|
||||||
|
if (resolvedEffort) {
|
||||||
|
sdkOptions.effort = resolvedEffort;
|
||||||
|
}
|
||||||
|
|
||||||
sdkOptions.systemPrompt = {
|
sdkOptions.systemPrompt = {
|
||||||
type: 'preset',
|
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'];
|
sdkOptions.settingSources = ['project', 'user', 'local'];
|
||||||
|
|
||||||
// Map resume session
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
sdkOptions.resume = sessionId;
|
sdkOptions.resume = sessionId;
|
||||||
}
|
}
|
||||||
@@ -533,20 +534,24 @@ 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;
|
||||||
@@ -650,7 +655,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' };
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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;
|
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';
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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';
|
||||||
@@ -18,27 +19,89 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
|||||||
{
|
{
|
||||||
value: 'default',
|
value: 'default',
|
||||||
label: 'Default (recommended)',
|
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',
|
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',
|
||||||
@@ -48,6 +111,15 @@ 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;
|
||||||
|
|||||||
@@ -21,11 +21,46 @@ 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.4', label: 'gpt-5.4' },
|
value: 'gpt-5.5',
|
||||||
{ value: 'gpt-5.4-mini', label: 'gpt-5.4-mini' },
|
label: 'gpt-5.5',
|
||||||
{ value: 'gpt-5.3-codex', label: 'gpt-5.3-codex' },
|
effort: {
|
||||||
{ value: 'gpt-5.2', label: 'gpt-5.2' },
|
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' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'gpt-5.3-codex',
|
||||||
|
label: 'gpt-5.3-codex',
|
||||||
|
effort: {
|
||||||
|
default: 'medium',
|
||||||
|
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'gpt-5.2',
|
||||||
|
label: 'gpt-5.2',
|
||||||
|
effort: {
|
||||||
|
default: 'medium',
|
||||||
|
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
DEFAULT: 'gpt-5.4',
|
DEFAULT: 'gpt-5.4',
|
||||||
};
|
};
|
||||||
@@ -37,6 +72,11 @@ 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');
|
||||||
@@ -55,11 +95,29 @@ const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({
|
|||||||
value: model.slug as string,
|
value: model.slug as string,
|
||||||
label: readOptionalString(model.display_name) ?? (model.slug as string),
|
label: readOptionalString(model.display_name) ?? (model.slug as string),
|
||||||
description: readOptionalString(model.description),
|
description: readOptionalString(model.description),
|
||||||
|
effort: Array.isArray(model.supported_reasoning_levels) && model.supported_reasoning_levels.length > 0
|
||||||
|
? {
|
||||||
|
default: readOptionalString(model.default_reasoning_level) ?? undefined,
|
||||||
|
values: model.supported_reasoning_levels
|
||||||
|
.map((level) => {
|
||||||
|
const value = readOptionalString(level?.effort);
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
description: readOptionalString(level?.description),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((level): level is NonNullable<typeof level> => Boolean(level)),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
|
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
|
||||||
const sortedModels = [...models]
|
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));
|
.sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority));
|
||||||
|
|
||||||
const options: ProviderModelOption[] = [];
|
const options: ProviderModelOption[] = [];
|
||||||
|
|||||||
@@ -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 = 1;
|
const PROVIDER_MODELS_CACHE_VERSION = 2;
|
||||||
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
|
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
|
||||||
|
|
||||||
type ProviderModelsServiceDependencies = {
|
type ProviderModelsServiceDependencies = {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ 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) {
|
||||||
@@ -228,6 +227,7 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
cwd,
|
cwd,
|
||||||
projectPath,
|
projectPath,
|
||||||
model,
|
model,
|
||||||
|
effort,
|
||||||
permissionMode = 'default'
|
permissionMode = 'default'
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -239,6 +239,12 @@ 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;
|
||||||
@@ -248,19 +254,17 @@ 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 {
|
||||||
@@ -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) {
|
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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ export type ProviderModelOption = {
|
|||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
effort?: {
|
||||||
|
default?: string;
|
||||||
|
values: {
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ interface UseChatComposerStateArgs {
|
|||||||
cursorModel: string;
|
cursorModel: string;
|
||||||
claudeModel: string;
|
claudeModel: string;
|
||||||
codexModel: string;
|
codexModel: string;
|
||||||
|
claudeEffort: string;
|
||||||
|
codexEffort: string;
|
||||||
geminiModel: string;
|
geminiModel: string;
|
||||||
opencodeModel: string;
|
opencodeModel: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -161,6 +163,17 @@ const getNotificationSessionSummary = (
|
|||||||
return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback;
|
return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getLatestProviderEffort = (
|
||||||
|
provider: LLMProvider,
|
||||||
|
fallbackEffort: string,
|
||||||
|
): string => {
|
||||||
|
if (provider !== 'claude' && provider !== 'codex') {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeLocalStorage.getItem(`${provider}-effort`) || fallbackEffort;
|
||||||
|
};
|
||||||
|
|
||||||
export function useChatComposerState({
|
export function useChatComposerState({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -171,6 +184,8 @@ export function useChatComposerState({
|
|||||||
cursorModel,
|
cursorModel,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
|
claudeEffort,
|
||||||
|
codexEffort,
|
||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -730,6 +745,12 @@ export function useChatComposerState({
|
|||||||
: provider === 'opencode'
|
: provider === 'opencode'
|
||||||
? opencodeModel
|
? opencodeModel
|
||||||
: claudeModel;
|
: claudeModel;
|
||||||
|
const effort =
|
||||||
|
provider === 'claude'
|
||||||
|
? getLatestProviderEffort(provider, claudeEffort)
|
||||||
|
: provider === 'codex'
|
||||||
|
? getLatestProviderEffort(provider, codexEffort)
|
||||||
|
: 'default';
|
||||||
|
|
||||||
// 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
|
||||||
@@ -740,6 +761,7 @@ export function useChatComposerState({
|
|||||||
content: messageContent,
|
content: messageContent,
|
||||||
options: {
|
options: {
|
||||||
model,
|
model,
|
||||||
|
effort,
|
||||||
// Codex has no plan mode; downgrade rather than sending an
|
// Codex has no plan mode; downgrade rather than sending an
|
||||||
// unsupported value to its runtime.
|
// unsupported value to its runtime.
|
||||||
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
|
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
|
||||||
@@ -768,7 +790,9 @@ export function useChatComposerState({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
attachedImages,
|
attachedImages,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
|
claudeEffort,
|
||||||
codexModel,
|
codexModel,
|
||||||
|
codexEffort,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
cursorModel,
|
cursorModel,
|
||||||
executeCommand,
|
executeCommand,
|
||||||
|
|||||||
@@ -5,18 +5,26 @@ import type {
|
|||||||
ProjectSession,
|
ProjectSession,
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
Project,
|
Project,
|
||||||
|
ProviderModelOption,
|
||||||
ProviderModelsCacheInfo,
|
ProviderModelsCacheInfo,
|
||||||
ProviderModelsDefinition,
|
ProviderModelsDefinition,
|
||||||
} from '../../../types/app';
|
} from '../../../types/app';
|
||||||
|
|
||||||
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
||||||
claude: 'opus',
|
claude: 'default',
|
||||||
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 DEFAULT_EFFORT_VALUE = 'default';
|
||||||
|
|
||||||
|
const FALLBACK_EFFORT_VALUES: Partial<Record<LLMProvider, string[]>> = {
|
||||||
|
claude: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||||
|
codex: ['low', 'medium', 'high', 'xhigh'],
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -87,6 +95,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
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 [claudeEffort, setClaudeEffort] = useState<string>(() => {
|
||||||
|
return localStorage.getItem('claude-effort') || DEFAULT_EFFORT_VALUE;
|
||||||
|
});
|
||||||
|
const [codexEffort, setCodexEffort] = useState<string>(() => {
|
||||||
|
return localStorage.getItem('codex-effort') || DEFAULT_EFFORT_VALUE;
|
||||||
|
});
|
||||||
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;
|
||||||
});
|
});
|
||||||
@@ -145,6 +159,19 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
localStorage.setItem('opencode-model', model);
|
localStorage.setItem('opencode-model', model);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => {
|
||||||
|
if (targetProvider === 'claude') {
|
||||||
|
setClaudeEffort(effort);
|
||||||
|
localStorage.setItem('claude-effort', effort);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetProvider === 'codex') {
|
||||||
|
setCodexEffort(effort);
|
||||||
|
localStorage.setItem('codex-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 providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||||
const requestId = providerModelsRequestIdRef.current + 1;
|
const requestId = providerModelsRequestIdRef.current + 1;
|
||||||
@@ -259,6 +286,53 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
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 getAllowedEffortValues = useCallback((
|
||||||
|
targetProvider: LLMProvider,
|
||||||
|
model: string,
|
||||||
|
): string[] => {
|
||||||
|
const option = getModelOption(targetProvider, model);
|
||||||
|
return option?.effort?.values.map((value) => value.value) ?? FALLBACK_EFFORT_VALUES[targetProvider] ?? [];
|
||||||
|
}, [getModelOption]);
|
||||||
|
|
||||||
|
const reconcileStoredEffort = useCallback((
|
||||||
|
targetProvider: LLMProvider,
|
||||||
|
model: string,
|
||||||
|
currentEffort: string,
|
||||||
|
): string => {
|
||||||
|
const allowedValues = getAllowedEffortValues(targetProvider, model);
|
||||||
|
if (allowedValues.length === 0) {
|
||||||
|
return DEFAULT_EFFORT_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = `${targetProvider}-effort`;
|
||||||
|
const storedEffort = localStorage.getItem(storageKey);
|
||||||
|
if (storedEffort === DEFAULT_EFFORT_VALUE || storedEffort === null) {
|
||||||
|
return DEFAULT_EFFORT_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedValues.includes(storedEffort)) {
|
||||||
|
return storedEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedValues.includes(currentEffort)) {
|
||||||
|
return currentEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_EFFORT_VALUE;
|
||||||
|
}, [getAllowedEffortValues]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const claude = providerModelCatalog.claude;
|
const claude = providerModelCatalog.claude;
|
||||||
if (claude) {
|
if (claude) {
|
||||||
@@ -272,6 +346,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
}
|
}
|
||||||
}, [providerModelCatalog.claude, claudeModel]);
|
}, [providerModelCatalog.claude, claudeModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = reconcileStoredEffort('claude', claudeModel, claudeEffort);
|
||||||
|
if (next !== claudeEffort) {
|
||||||
|
setClaudeEffort(next);
|
||||||
|
}
|
||||||
|
if ((localStorage.getItem('claude-effort') || DEFAULT_EFFORT_VALUE) !== next) {
|
||||||
|
localStorage.setItem('claude-effort', next);
|
||||||
|
}
|
||||||
|
}, [claudeEffort, claudeModel, reconcileStoredEffort]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cursor = providerModelCatalog.cursor;
|
const cursor = providerModelCatalog.cursor;
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
@@ -298,6 +382,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
}
|
}
|
||||||
}, [providerModelCatalog.codex, codexModel]);
|
}, [providerModelCatalog.codex, codexModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = reconcileStoredEffort('codex', codexModel, codexEffort);
|
||||||
|
if (next !== codexEffort) {
|
||||||
|
setCodexEffort(next);
|
||||||
|
}
|
||||||
|
if ((localStorage.getItem('codex-effort') || DEFAULT_EFFORT_VALUE) !== next) {
|
||||||
|
localStorage.setItem('codex-effort', next);
|
||||||
|
}
|
||||||
|
}, [codexEffort, codexModel, reconcileStoredEffort]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const gemini = providerModelCatalog.gemini;
|
const gemini = providerModelCatalog.gemini;
|
||||||
if (gemini) {
|
if (gemini) {
|
||||||
@@ -430,6 +524,10 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
setClaudeModel,
|
setClaudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
setCodexModel,
|
setCodexModel,
|
||||||
|
claudeEffort,
|
||||||
|
setClaudeEffort,
|
||||||
|
codexEffort,
|
||||||
|
setCodexEffort,
|
||||||
geminiModel,
|
geminiModel,
|
||||||
setGeminiModel,
|
setGeminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
@@ -445,5 +543,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
providerModelsRefreshing,
|
providerModelsRefreshing,
|
||||||
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
|
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
|
||||||
selectProviderModel,
|
selectProviderModel,
|
||||||
|
setStoredProviderEffort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ 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';
|
||||||
|
|
||||||
|
const FALLBACK_COMPOSER_EFFORT_OPTIONS = {
|
||||||
|
claude: ['low', 'medium', 'high', 'xhigh', 'max'].map((value) => ({ value })),
|
||||||
|
codex: ['low', 'medium', 'high', 'xhigh'].map((value) => ({ value })),
|
||||||
|
} as const;
|
||||||
|
|
||||||
function ChatInterface({
|
function ChatInterface({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
@@ -71,6 +75,10 @@ function ChatInterface({
|
|||||||
setClaudeModel,
|
setClaudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
setCodexModel,
|
setCodexModel,
|
||||||
|
claudeEffort,
|
||||||
|
setClaudeEffort,
|
||||||
|
codexEffort,
|
||||||
|
setCodexEffort,
|
||||||
geminiModel,
|
geminiModel,
|
||||||
setGeminiModel,
|
setGeminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
@@ -85,6 +93,7 @@ function ChatInterface({
|
|||||||
providerModelsRefreshing,
|
providerModelsRefreshing,
|
||||||
hardRefreshProviderModels,
|
hardRefreshProviderModels,
|
||||||
selectProviderModel,
|
selectProviderModel,
|
||||||
|
setStoredProviderEffort,
|
||||||
} = useChatProviderState({
|
} = useChatProviderState({
|
||||||
selectedSession,
|
selectedSession,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
@@ -199,6 +208,8 @@ function ChatInterface({
|
|||||||
cursorModel,
|
cursorModel,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
|
claudeEffort,
|
||||||
|
codexEffort,
|
||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading: isProcessing,
|
isLoading: isProcessing,
|
||||||
@@ -217,6 +228,37 @@ function ChatInterface({
|
|||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentComposerModel = useMemo(() => {
|
||||||
|
if (provider === 'claude') return claudeModel;
|
||||||
|
if (provider === 'codex') return codexModel;
|
||||||
|
if (provider === 'gemini') return geminiModel;
|
||||||
|
if (provider === 'opencode') return opencodeModel;
|
||||||
|
return cursorModel;
|
||||||
|
}, [provider, claudeModel, codexModel, geminiModel, opencodeModel, cursorModel]);
|
||||||
|
|
||||||
|
const composerEffort = useMemo(() => {
|
||||||
|
if (provider === 'claude') return claudeEffort;
|
||||||
|
if (provider === 'codex') return codexEffort;
|
||||||
|
return 'default';
|
||||||
|
}, [provider, claudeEffort, codexEffort]);
|
||||||
|
|
||||||
|
const composerEffortOptions = useMemo(() => {
|
||||||
|
const modelOption = providerModelCatalog[provider]?.OPTIONS.find((option) => option.value === currentComposerModel);
|
||||||
|
if (modelOption?.effort?.values?.length) {
|
||||||
|
return modelOption.effort.values;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'claude') {
|
||||||
|
return FALLBACK_COMPOSER_EFFORT_OPTIONS.claude;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'codex') {
|
||||||
|
return FALLBACK_COMPOSER_EFFORT_OPTIONS.codex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, [providerModelCatalog, provider, currentComposerModel]);
|
||||||
|
|
||||||
// On WebSocket reconnect, re-fetch the current session's messages from the
|
// On WebSocket reconnect, re-fetch the current session's messages from the
|
||||||
// server so missed streaming events are shown, then re-subscribe — the
|
// server so missed streaming events are shown, then re-subscribe — the
|
||||||
// `chat_subscribed` ack restores or clears the activity indicator, replays
|
// `chat_subscribed` ack restores or clears the activity indicator, replays
|
||||||
@@ -371,6 +413,9 @@ function ChatInterface({
|
|||||||
onAbortSession={handleAbortSession}
|
onAbortSession={handleAbortSession}
|
||||||
permissionMode={permissionMode}
|
permissionMode={permissionMode}
|
||||||
onModeSwitch={cyclePermissionMode}
|
onModeSwitch={cyclePermissionMode}
|
||||||
|
effort={composerEffort}
|
||||||
|
availableEffortOptions={composerEffortOptions}
|
||||||
|
onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)}
|
||||||
tokenBudget={tokenBudget}
|
tokenBudget={tokenBudget}
|
||||||
onShowTokenUsage={showCostModal}
|
onShowTokenUsage={showCostModal}
|
||||||
slashCommandsCount={slashCommandsCount}
|
slashCommandsCount={slashCommandsCount}
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import type {
|
|||||||
RefObject,
|
RefObject,
|
||||||
TouchEvent,
|
TouchEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react';
|
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2, ChevronDown, Check } 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,
|
||||||
@@ -62,6 +63,9 @@ 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;
|
||||||
@@ -116,6 +120,9 @@ export default function ChatComposer({
|
|||||||
onAbortSession,
|
onAbortSession,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
onModeSwitch,
|
onModeSwitch,
|
||||||
|
effort,
|
||||||
|
availableEffortOptions,
|
||||||
|
onSelectEffort,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
onShowTokenUsage,
|
onShowTokenUsage,
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
@@ -193,6 +200,37 @@ 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 effortOptions = useMemo(
|
||||||
|
() => [{ value: 'default' }, ...availableEffortOptions],
|
||||||
|
[availableEffortOptions],
|
||||||
|
);
|
||||||
|
const selectedEffortLabel = effort === 'default' ? 'Default' : effort;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEffortDropdownOpen) return;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (!effortDropdownRef.current?.contains(event.target as Node)) {
|
||||||
|
setIsEffortDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsEffortDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', handlePointerDown);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isEffortDropdownOpen]);
|
||||||
|
|
||||||
// Detect if the AskUserQuestion interactive panel is active
|
// Detect if the AskUserQuestion interactive panel is active
|
||||||
const hasQuestionPanel = pendingPermissionRequests.some(
|
const hasQuestionPanel = pendingPermissionRequests.some(
|
||||||
@@ -386,6 +424,55 @@ export default function ChatComposer({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{availableEffortOptions.length > 0 && (
|
||||||
|
<div ref={effortDropdownRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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 && (
|
||||||
|
<div className="absolute bottom-full left-0 z-50 mb-2 min-w-36 overflow-hidden rounded-lg border border-border bg-card p-1 shadow-lg">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
|
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
|
||||||
|
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
|
|||||||
@@ -263,7 +263,6 @@ 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) {
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ 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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user