From 97be34e21c7276b603dba82aa35f00d7240e0988 Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 25 Apr 2026 10:47:50 +0800 Subject: [PATCH] Add Codex model selection --- cli/src/codex/appServerTypes.ts | 25 ++++++ cli/src/codex/codexAppServerClient.ts | 9 ++ cli/src/codex/runCodex.ts | 55 ++++++++---- cli/src/modules/common/codexModels.ts | 90 +++++++++++++++++++ .../modules/common/handlers/codexModels.ts | 22 +++++ .../modules/common/registerCommonHandlers.ts | 2 + hub/src/sync/rpcGateway.ts | 22 +++++ hub/src/sync/syncEngine.ts | 12 +++ hub/src/web/routes/machines.test.ts | 59 ++++++++++++ hub/src/web/routes/machines.ts | 23 +++++ hub/src/web/routes/sessions.test.ts | 62 ++++++++++++- hub/src/web/routes/sessions.ts | 37 +++++++- shared/src/flavors.test.ts | 5 +- shared/src/flavors.ts | 2 +- web/src/api/client.ts | 13 +++ .../AssistantChat/HappyComposer.tsx | 18 ++-- .../AssistantChat/claudeModelOptions.test.ts | 4 +- .../AssistantChat/claudeModelOptions.ts | 2 +- .../AssistantChat/modelOptions.test.ts | 28 +++++- .../components/AssistantChat/modelOptions.ts | 53 +++++++++-- .../components/NewSession/ModelSelector.tsx | 12 ++- web/src/components/NewSession/index.tsx | 26 +++++- web/src/components/NewSession/types.test.ts | 2 +- web/src/components/NewSession/types.ts | 12 +-- web/src/components/SessionChat.tsx | 35 +++++++- web/src/hooks/queries/useCodexModels.ts | 52 +++++++++++ web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/lib/query-keys.ts | 2 + web/src/types/api.ts | 14 +++ 30 files changed, 644 insertions(+), 58 deletions(-) create mode 100644 cli/src/modules/common/codexModels.ts create mode 100644 cli/src/modules/common/handlers/codexModels.ts create mode 100644 hub/src/web/routes/machines.test.ts create mode 100644 web/src/hooks/queries/useCodexModels.ts diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index fdb7fcf6..e0996657 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -19,6 +19,31 @@ export interface InitializeResponse { [key: string]: unknown; } +export interface ModelListParams { + includeHidden?: boolean; +} + +export interface ModelListItem { + id: string; + model?: string; + displayName?: string; + description?: string; + hidden?: boolean; + supportedReasoningEfforts?: Array<{ + reasoningEffort?: string; + description?: string; + }>; + defaultReasoningEffort?: string | null; + isDefault?: boolean; + [key: string]: unknown; +} + +export interface ModelListResponse { + data?: ModelListItem[]; + nextCursor?: string | null; + [key: string]: unknown; +} + export interface ThreadStartParams { model?: string; modelProvider?: string; diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index b45b4976..d5a7b317 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -4,6 +4,8 @@ import { killProcessByChildProcess } from '@/utils/process'; import type { InitializeParams, InitializeResponse, + ModelListParams, + ModelListResponse, ThreadStartParams, ThreadStartResponse, ThreadResumeParams, @@ -133,6 +135,13 @@ export class CodexAppServerClient { return response as InitializeResponse; } + async listModels(params?: ModelListParams): Promise { + const response = await this.sendRequest('model/list', params ?? {}, { + timeoutMs: 30_000 + }); + return response as ModelListResponse; + } + async startThread(params: ThreadStartParams, options?: { signal?: AbortSignal }): Promise { const response = await this.sendRequest('thread/start', params, { signal: options?.signal, diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index d4f34965..c04e305c 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -71,21 +71,15 @@ export async function runCodex(opts: { lifecycle.registerProcessHandlers(); registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); - const syncSessionMode = () => { + const applyCurrentConfigToSession = (options?: { syncModel?: boolean }) => { const sessionInstance = sessionWrapperRef.current; if (!sessionInstance) { return; } - const sessionModel = sessionInstance.getModel(); - if (sessionModel !== undefined) { - currentModel = sessionModel ?? undefined; - } - const sessionModelReasoningEffort = sessionInstance.getModelReasoningEffort(); - if (sessionModelReasoningEffort !== undefined) { - currentModelReasoningEffort = (sessionModelReasoningEffort ?? undefined) as ReasoningEffort | undefined; - } sessionInstance.setPermissionMode(currentPermissionMode); - sessionInstance.setModel(currentModel ?? null); + if (options?.syncModel !== false) { + sessionInstance.setModel(currentModel ?? null); + } sessionInstance.setModelReasoningEffort(currentModelReasoningEffort ?? null); sessionInstance.setCollaborationMode(currentCollaborationMode); logger.debug( @@ -167,16 +161,32 @@ export async function runCodex(opts: { return value as ReasoningEffort; }; + const resolveModel = (value: unknown): string => { + if (typeof value !== 'string') { + throw new Error('Invalid model'); + } + const trimmedValue = value.trim(); + if (!trimmedValue) { + throw new Error('Invalid model'); + } + return trimmedValue; + }; + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { if (!payload || typeof payload !== 'object') { throw new Error('Invalid session config payload'); } - const config = payload as { permissionMode?: unknown; modelReasoningEffort?: unknown; collaborationMode?: unknown }; + const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown; collaborationMode?: unknown }; if (config.permissionMode !== undefined) { currentPermissionMode = resolvePermissionMode(config.permissionMode); } + const shouldSyncModel = config.model !== undefined; + if (shouldSyncModel) { + currentModel = resolveModel(config.model); + } + if (config.modelReasoningEffort !== undefined) { currentModelReasoningEffort = resolveModelReasoningEffort(config.modelReasoningEffort); } @@ -185,13 +195,22 @@ export async function runCodex(opts: { currentCollaborationMode = resolveCollaborationMode(config.collaborationMode); } - syncSessionMode(); + applyCurrentConfigToSession({ syncModel: shouldSyncModel }); + const applied: { + permissionMode: PermissionMode; + model?: string | null; + modelReasoningEffort: ReasoningEffort | null; + collaborationMode: EnhancedMode['collaborationMode']; + } = { + permissionMode: currentPermissionMode, + modelReasoningEffort: currentModelReasoningEffort ?? null, + collaborationMode: currentCollaborationMode + }; + if (shouldSyncModel) { + applied.model = currentModel ?? null; + } return { - applied: { - permissionMode: currentPermissionMode, - modelReasoningEffort: currentModelReasoningEffort ?? null, - collaborationMode: currentCollaborationMode - } + applied }; }); @@ -213,7 +232,7 @@ export async function runCodex(opts: { onModeChange: createModeChangeHandler(session), onSessionReady: (instance) => { sessionWrapperRef.current = instance; - syncSessionMode(); + applyCurrentConfigToSession(); } }); } catch (error) { diff --git a/cli/src/modules/common/codexModels.ts b/cli/src/modules/common/codexModels.ts new file mode 100644 index 00000000..800c74f8 --- /dev/null +++ b/cli/src/modules/common/codexModels.ts @@ -0,0 +1,90 @@ +import { CodexAppServerClient } from '@/codex/codexAppServerClient'; +import { getErrorMessage } from './rpcResponses'; + +export interface CodexModelSummary { + id: string; + displayName: string; + isDefault: boolean; + defaultReasoningEffort?: string | null; + supportedReasoningEfforts?: string[]; +} + +export interface ListCodexModelsRequest { + includeHidden?: boolean; +} + +export interface ListCodexModelsResponse { + success: boolean; + models?: CodexModelSummary[]; + error?: string; +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeSupportedReasoningEfforts(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const efforts = value + .map((entry) => { + if (!entry || typeof entry !== 'object') { + return null; + } + const reasoningEffort = asNonEmptyString((entry as { reasoningEffort?: unknown }).reasoningEffort); + return reasoningEffort; + }) + .filter((entry): entry is string => entry !== null); + + return efforts.length > 0 ? efforts : undefined; +} + +function normalizeModel(entry: unknown): CodexModelSummary | null { + if (!entry || typeof entry !== 'object') { + return null; + } + + const record = entry as Record; + const id = asNonEmptyString(record.id) ?? asNonEmptyString(record.model); + if (!id) { + return null; + } + + return { + id, + displayName: asNonEmptyString(record.displayName) ?? id, + isDefault: record.isDefault === true, + defaultReasoningEffort: asNonEmptyString(record.defaultReasoningEffort), + supportedReasoningEfforts: normalizeSupportedReasoningEfforts(record.supportedReasoningEfforts) + }; +} + +export async function listCodexModels(includeHidden: boolean = false): Promise { + const client = new CodexAppServerClient(); + + try { + await client.connect(); + await client.initialize({ + clientInfo: { + name: 'hapi-codex-models', + version: '1.0.0' + }, + capabilities: { + experimentalApi: true + } + }); + + const response = await client.listModels({ includeHidden }); + const models = Array.isArray(response.data) + ? response.data.map(normalizeModel).filter((model): model is CodexModelSummary => model !== null) + : []; + + return models; + } catch (error) { + throw new Error(getErrorMessage(error, 'Failed to list Codex models')); + } finally { + await client.disconnect().catch(() => undefined); + } +} diff --git a/cli/src/modules/common/handlers/codexModels.ts b/cli/src/modules/common/handlers/codexModels.ts new file mode 100644 index 00000000..c6cef6fa --- /dev/null +++ b/cli/src/modules/common/handlers/codexModels.ts @@ -0,0 +1,22 @@ +import { logger } from '@/ui/logger'; +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { + listCodexModels, + type ListCodexModelsRequest, + type ListCodexModelsResponse +} from '../codexModels'; +import { getErrorMessage, rpcError } from '../rpcResponses'; + +export function registerCodexModelHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler('listCodexModels', async (data) => { + logger.debug('List Codex models request'); + + try { + const models = await listCodexModels(data?.includeHidden === true); + return { success: true, models }; + } catch (error) { + logger.debug('Failed to list Codex models:', error); + return rpcError(getErrorMessage(error, 'Failed to list Codex models')); + } + }); +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index 91fa20d6..fa8ba0b6 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -1,5 +1,6 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' import { registerBashHandlers } from './handlers/bash' +import { registerCodexModelHandlers } from './handlers/codexModels' import { registerDirectoryHandlers } from './handlers/directories' import { registerDifftasticHandlers } from './handlers/difftastic' import { registerFileHandlers } from './handlers/files' @@ -11,6 +12,7 @@ import { registerUploadHandlers } from './handlers/uploads' export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { registerBashHandlers(rpcHandlerManager, workingDirectory) + registerCodexModelHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) registerDirectoryHandlers(rpcHandlerManager, workingDirectory) registerRipgrepHandlers(rpcHandlerManager, workingDirectory) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index d3fe5ee2..dfc78d89 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -44,6 +44,20 @@ export type RpcPathExistsResponse = { exists: Record } +export type RpcCodexModel = { + id: string + displayName: string + isDefault: boolean + defaultReasoningEffort?: string | null + supportedReasoningEfforts?: string[] +} + +export type RpcListCodexModelsResponse = { + success: boolean + models?: RpcCodexModel[] + error?: string +} + export class RpcGateway { constructor( private readonly io: Server, @@ -232,6 +246,14 @@ export class RpcGateway { } } + async listCodexModelsForSession(sessionId: string): Promise { + return await this.sessionRpc(sessionId, 'listCodexModels', {}) as RpcListCodexModelsResponse + } + + async listCodexModelsForMachine(machineId: string): Promise { + return await this.machineRpc(machineId, 'listCodexModels', {}) as RpcListCodexModelsResponse + } + private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { return await this.rpcCall(`${sessionId}:${method}`, params) } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 04ac0983..b1c7998e 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -17,9 +17,11 @@ import { MachineCache, type Machine } from './machineCache' import { MessageService } from './messageService' import { RpcGateway, + type RpcCodexModel, type RpcCommandResponse, type RpcDeleteUploadResponse, type RpcListDirectoryResponse, + type RpcListCodexModelsResponse, type RpcPathExistsResponse, type RpcReadFileResponse, type RpcUploadFileResponse @@ -30,9 +32,11 @@ export type { Session, SyncEvent } from '@hapi/protocol/types' export type { Machine } from './machineCache' export type { SyncEventListener } from './eventPublisher' export type { + RpcCodexModel, RpcCommandResponse, RpcDeleteUploadResponse, RpcListDirectoryResponse, + RpcListCodexModelsResponse, RpcPathExistsResponse, RpcReadFileResponse, RpcUploadFileResponse @@ -555,4 +559,12 @@ export class SyncEngine { }> { return await this.rpcGateway.listSkills(sessionId) } + + async listCodexModelsForSession(sessionId: string): Promise { + return await this.rpcGateway.listCodexModelsForSession(sessionId) + } + + async listCodexModelsForMachine(machineId: string): Promise { + return await this.rpcGateway.listCodexModelsForMachine(machineId) + } } diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts new file mode 100644 index 00000000..f616adaa --- /dev/null +++ b/hub/src/web/routes/machines.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { Machine, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createMachinesRoutes } from './machines' + +function createMachine(overrides?: Partial): Machine { + return { + id: 'machine-1', + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + metadata: { + host: 'localhost', + platform: 'darwin', + happyCliVersion: '1.0.0' + }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 1, + ...overrides + } +} + +describe('machines routes', () => { + it('returns Codex models for an online machine', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listCodexModelsForMachine: async () => ({ + success: true, + models: [ + { id: 'gpt-5.5', displayName: 'GPT-5.5', isDefault: true } + ] + }) + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/codex-models') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + models: [ + { id: 'gpt-5.5', displayName: 'GPT-5.5', isDefault: true } + ] + }) + }) +}) diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 9c923797..ea6dedf0 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -97,5 +97,28 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/machines/:id/codex-models', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + try { + const result = await engine.listCodexModelsForMachine(machineId) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Codex models' + }, 500) + } + }) + return app } diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 2400853e..29a91009 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -55,9 +55,16 @@ function createApp(session: Session) { const applySessionConfig = async (sessionId: string, config: Record) => { applySessionConfigCalls.push([sessionId, config]) } + const listCodexModelsForSession = async () => ({ + success: true, + models: [ + { id: 'gpt-5.5', displayName: 'GPT-5.5', isDefault: true } + ] + }) const engine = { resolveSessionAccess: () => ({ ok: true, sessionId: session.id, session }), - applySessionConfig + applySessionConfig, + listCodexModelsForSession } as Partial const app = new Hono() @@ -195,6 +202,45 @@ describe('sessions routes', () => { ]) }) + it('applies model changes for remote Codex sessions', async () => { + const { app, applySessionConfigCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.5' }) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { model: 'gpt-5.5' }] + ]) + }) + + it('rejects model changes for local Codex sessions', async () => { + const session = createSession({ + agentState: { + controlledByUser: true, + requests: {}, + completedRequests: {} + } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.5' }) + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Model selection can only be changed for remote Codex sessions' + }) + expect(applySessionConfigCalls).toEqual([]) + }) + it('rejects effort changes for non-Claude sessions', async () => { const { app, applySessionConfigCalls } = createApp(createSession()) @@ -233,4 +279,18 @@ describe('sessions routes', () => { ['session-1', { effort: 'max' }] ]) }) + + it('returns Codex models for active Codex sessions', async () => { + const { app } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/codex-models') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + models: [ + { id: 'gpt-5.5', displayName: 'GPT-5.5', isDefault: true } + ] + }) + }) }) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 803fadff..d324f4b0 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -320,8 +320,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const flavor = sessionResult.session.metadata?.flavor ?? 'claude' - if (flavor !== 'claude' && flavor !== 'gemini') { - return c.json({ error: 'Model selection is only supported for Claude and Gemini sessions' }, 400) + if (flavor !== 'claude' && flavor !== 'gemini' && flavor !== 'codex') { + return c.json({ error: 'Model selection is only supported for Claude, Gemini, and Codex sessions' }, 400) + } + if (flavor === 'codex' && sessionResult.session.agentState?.controlledByUser === true) { + return c.json({ error: 'Model selection can only be changed for remote Codex sessions' }, 409) } try { @@ -507,5 +510,35 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/sessions/:id/codex-models', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'codex') { + return c.json({ + success: false, + error: 'Codex models are only available for Codex sessions' + }, 400) + } + + try { + const result = await engine.listCodexModelsForSession(sessionResult.sessionId) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Codex models' + }, 500) + } + }) + return app } diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index a8efe5e9..60d27e9b 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -22,8 +22,8 @@ describe('hasCapability', () => { expect(hasCapability('gemini', Capabilities.Effort)).toBe(false) }) - test('codex has no capabilities', () => { - expect(hasCapability('codex', Capabilities.ModelChange)).toBe(false) + test('codex supports model-change but not effort', () => { + expect(hasCapability('codex', Capabilities.ModelChange)).toBe(true) expect(hasCapability('codex', Capabilities.Effort)).toBe(false) }) @@ -86,6 +86,7 @@ describe('convenience functions', () => { test('supportsModelChange matches hasCapability', () => { expect(supportsModelChange('claude')).toBe(true) expect(supportsModelChange('gemini')).toBe(true) + expect(supportsModelChange('codex')).toBe(true) expect(supportsModelChange('cursor')).toBe(false) expect(supportsModelChange(null)).toBe(false) }) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index 817d3dd9..ab283292 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -12,7 +12,7 @@ export type Capability = typeof Capabilities[keyof typeof Capabilities] const FLAVOR_CAPS: Record> = { claude: new Set([Capabilities.ModelChange, Capabilities.Effort]), gemini: new Set([Capabilities.ModelChange]), - codex: new Set([]), + codex: new Set([Capabilities.ModelChange]), cursor: new Set([]), opencode: new Set([]), } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7f1083c8..80252886 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -10,6 +10,7 @@ import type { MachinePathsExistsResponse, MachinesResponse, MessagesResponse, + CodexModelsResponse, PermissionMode, PushSubscriptionPayload, PushUnsubscribePayload, @@ -407,6 +408,18 @@ export class ApiClient { }) } + async getMachineCodexModels(machineId: string): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/codex-models` + ) + } + + async getSessionCodexModels(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/codex-models` + ) + } + async getSlashCommands(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/slash-commands` diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 6d0e20d3..8fbb7cbf 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -56,6 +56,7 @@ export function HappyComposer(props: { contextSize?: number controlledByUser?: boolean agentFlavor?: string | null + availableModelOptions?: Array<{ value: string | null; label: string }> onCollaborationModeChange?: (mode: CodexCollaborationMode) => void onPermissionModeChange?: (mode: PermissionMode) => void onModelChange?: (model: string | null) => void @@ -89,6 +90,7 @@ export function HappyComposer(props: { contextSize, controlledByUser = false, agentFlavor, + availableModelOptions, onCollaborationModeChange, onPermissionModeChange, onModelChange, @@ -278,9 +280,9 @@ export function HappyComposer(props: { () => agentFlavor === 'codex' ? getCodexCollaborationModeOptions() : [], [agentFlavor] ) - const claudeModelOptions = useMemo( - () => getModelOptionsForFlavor(agentFlavor, model), - [agentFlavor, model] + const modelOptions = useMemo( + () => getModelOptionsForFlavor(agentFlavor, model, availableModelOptions), + [agentFlavor, model, availableModelOptions] ) const codexReasoningEffortOptions = useMemo( () => agentFlavor === 'codex' ? getCodexComposerReasoningEffortOptions(modelReasoningEffort) : [], @@ -385,14 +387,14 @@ export function HappyComposer(props: { const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelChange && supportsModelChange(agentFlavor)) { e.preventDefault() - onModelChange(getNextModelForFlavor(agentFlavor, model)) + onModelChange(getNextModelForFlavor(agentFlavor, model, availableModelOptions)) haptic('light') } } window.addEventListener('keydown', handleGlobalKeyDown) return () => window.removeEventListener('keydown', handleGlobalKeyDown) - }, [model, onModelChange, haptic, agentFlavor]) + }, [model, onModelChange, haptic, agentFlavor, availableModelOptions]) const handleChange = useCallback((e: ReactChangeEvent) => { const selection = { @@ -477,7 +479,7 @@ export function HappyComposer(props: { const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) - const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor)) + const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor) && modelOptions.length > 0) const showModelReasoningEffortSettings = Boolean(onModelReasoningEffortChange && codexReasoningEffortOptions.length > 0) const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor)) const showSettingsButton = Boolean( @@ -586,7 +588,7 @@ export function HappyComposer(props: {
{t('misc.model')}
- {claudeModelOptions.map((option) => ( + {modelOptions.map((option) => (