Extract runtime toolbar choices

This commit is contained in:
murashit 2026-05-29 05:27:05 +09:00
parent 7364f5db85
commit 3f7c9e29ec
3 changed files with 80 additions and 39 deletions

View file

@ -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<ToolbarViewModel, "modelChoices" | "effortChoices"> {
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);

View file

@ -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();

View file

@ -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<string, unknown>): ConfigReadResponse {