diff --git a/src/features/chat/view-model.ts b/src/features/chat/view-model.ts index 5f8c72c0..c3326e00 100644 --- a/src/features/chat/view-model.ts +++ b/src/features/chat/view-model.ts @@ -1,4 +1,5 @@ import type { Thread } from "../../generated/app-server/v2/Thread"; +import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort"; import type { RuntimeSnapshot } from "../../runtime/state"; import { autoReviewActive, @@ -11,6 +12,7 @@ import { supportedReasoningEfforts, } from "../../runtime/state"; import { readRuntimeConfig } from "../../runtime/config"; +import { sortedAvailableModels } from "../../runtime/model"; import { compactContextLabel } from "../../runtime/settings"; import { contextSummary, effectiveConfigSections, rateLimitSummary } from "../../runtime/view"; import { getThreadTitle } from "../../domain/threads/model"; @@ -43,6 +45,13 @@ export interface ConnectionDiagnosticsModelInput { configuredCommand: string; } +export interface RuntimeToolbarChoicesInput { + state: ChatState; + snapshot: RuntimeSnapshot; + setRequestedModel: (model: string | null) => void; + setRequestedReasoningEffort: (effort: ReasoningEffort | null) => void; +} + export function runtimeSnapshotForChatState({ state }: RuntimeSnapshotInput): RuntimeSnapshot { return { effectiveConfig: state.effectiveConfig, @@ -66,6 +75,37 @@ export function runtimeSnapshotForChatState({ state }: RuntimeSnapshotInput): Ru }; } +export function runtimeToolbarChoices(input: RuntimeToolbarChoicesInput): Pick { + const config = readRuntimeConfig(input.state.effectiveConfig); + const activeModel = currentModel(input.snapshot, config); + const models = sortedAvailableModels(input.state.availableModels); + const modelChoices: ToolbarChoice[] = models.slice(0, 12).map((model) => ({ + label: model.model, + selected: activeModel === model.model, + onClick: () => { + input.setRequestedModel(model.model); + }, + })); + if (models.length === 0) { + modelChoices.push({ + label: "No model list available.", + disabled: true, + onClick: () => undefined, + }); + } + + const activeEffort = currentReasoningEffort(input.snapshot, config); + const effortChoices: ToolbarChoice[] = supportedReasoningEfforts(input.snapshot).map((effort) => ({ + label: effort, + selected: activeEffort === effort, + onClick: () => { + input.setRequestedReasoningEffort(effort); + }, + })); + + return { modelChoices, effortChoices }; +} + export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel { const { state, snapshot } = input; const config = readRuntimeConfig(state.effectiveConfig); diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 99d9357f..d7bb8696 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -17,9 +17,7 @@ import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadR import type { UserInput } from "../../generated/app-server/v2/UserInput"; import { collaborationModeLabel as formatCollaborationModeLabel } from "../../runtime/collaboration-mode"; import { ChatController } from "./chat-controller"; -import { currentModel, currentReasoningEffort, supportedReasoningEfforts, type RuntimeSnapshot } from "../../runtime/state"; -import { readRuntimeConfig } from "../../runtime/config"; -import { sortedAvailableModels } from "../../runtime/model"; +import { currentModel, type RuntimeSnapshot } from "../../runtime/state"; import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult } from "./slash-commands"; import type { ThreadReferenceInput } from "./slash-commands"; import { mcpStatusLines } from "./mcp-status"; @@ -48,7 +46,7 @@ import { type ReferencedThreadDisplay, } from "../../domain/threads/reference"; import { pendingRequestMessageNode } from "./ui/pending-request-message"; -import { renderToolbar, type ToolbarChoice, type ToolbarViewModel } from "./ui/toolbar"; +import { renderToolbar, type ToolbarViewModel } from "./ui/toolbar"; import { renderChatPanelShell, unmountChatPanelShell } from "./ui/shell"; import type { ChatTurnDiffViewState } from "./ui/turn-diff"; import { ChatMessageRenderer, type ChatMessageScrollIntent } from "./chat-message-renderer"; @@ -62,6 +60,7 @@ import { connectionDiagnosticsModel, effortStatusLines as buildEffortStatusLines, modelStatusLines as buildModelStatusLines, + runtimeToolbarChoices, runtimeSnapshotForChatState, statusSummaryLines as buildStatusSummaryLines, toolbarViewModel as buildToolbarViewModel, @@ -1168,8 +1167,12 @@ export class CodexChatView extends ItemView { configuredCommand: this.plugin.settings.codexPath, archiveConfirmThreadId: this.archiveConfirmThreadId, archiveExportEnabled: this.plugin.settings.archiveExportEnabled, - modelChoices: this.modelToolbarChoices(), - effortChoices: this.effortToolbarChoices(), + ...runtimeToolbarChoices({ + state: this.state, + snapshot: this.runtimeSnapshot(), + setRequestedModel: (model) => void this.setRequestedModelFromUi(model), + setRequestedReasoningEffort: (effort) => void this.setRequestedReasoningEffortFromUi(effort), + }), renameState: (threadId) => this.threadRename.editState(threadId), }); } @@ -1195,39 +1198,6 @@ export class CodexChatView extends ItemView { } } - private modelToolbarChoices(): ToolbarChoice[] { - const snapshot = this.runtimeSnapshot(); - const config = readRuntimeConfig(this.state.effectiveConfig); - const activeModel = currentModel(snapshot, config); - const models = sortedAvailableModels(this.state.availableModels); - const choices: ToolbarChoice[] = [ - ...models.slice(0, 12).map((model) => ({ - label: model.model, - selected: activeModel === model.model, - onClick: () => void this.setRequestedModelFromUi(model.model), - })), - ]; - if (models.length === 0) { - choices.push({ - label: "No model list available.", - disabled: true, - onClick: () => undefined, - }); - } - return choices; - } - - private effortToolbarChoices(): ToolbarChoice[] { - const snapshot = this.runtimeSnapshot(); - const config = readRuntimeConfig(this.state.effectiveConfig); - const activeEffort = currentReasoningEffort(snapshot, config); - return supportedReasoningEfforts(snapshot).map((effort) => ({ - label: effort, - selected: activeEffort === effort, - onClick: () => void this.setRequestedReasoningEffortFromUi(effort), - })); - } - private toggleHistoryPanel(): void { this.dispatch({ type: "ui/panel-set", panel: "history", toggle: true }); this.scheduleRender(); diff --git a/tests/features/chat/view-model.test.ts b/tests/features/chat/view-model.test.ts index 046a02b0..5f0ded79 100644 --- a/tests/features/chat/view-model.test.ts +++ b/tests/features/chat/view-model.test.ts @@ -4,6 +4,7 @@ import { createAppServerDiagnostics } from "../../../src/app-server/compatibilit import { createChatState } from "../../../src/features/chat/chat-state"; import { effortStatusLines, + runtimeToolbarChoices, modelStatusLines, runtimeSnapshotForChatState, statusSummaryLines, @@ -63,6 +64,36 @@ describe("chat view model", () => { expect(modelStatusLines(state, snapshot, "Default")).toContain("Mode: Default"); expect(effortStatusLines(state, snapshot)).toContain("Supported: high"); }); + + it("builds runtime toolbar choices from immutable chat state snapshots", () => { + const state = createChatState(); + state.effectiveConfig = effectiveConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" }); + state.availableModels = [modelFixture("gpt-5.5"), modelFixture("gpt-5-mini")]; + const selectedModels: (string | null)[] = []; + const selectedEfforts: string[] = []; + + const choices = runtimeToolbarChoices({ + state, + snapshot: runtimeSnapshotForChatState({ state }), + setRequestedModel: (model) => { + selectedModels.push(model); + }, + setRequestedReasoningEffort: (effort) => { + if (effort) selectedEfforts.push(effort); + }, + }); + + expect(choices.modelChoices).toMatchObject([ + { label: "gpt-5-mini", selected: false }, + { label: "gpt-5.5", selected: true }, + ]); + expect(choices.effortChoices).toMatchObject([{ label: "high", selected: true }]); + + choices.modelChoices[0]?.onClick(); + choices.effortChoices[0]?.onClick(); + expect(selectedModels).toEqual(["gpt-5-mini"]); + expect(selectedEfforts).toEqual(["high"]); + }); }); function effectiveConfigFixture(config: Record): ConfigReadResponse {