diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ab3d0968925..301781d1321 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -442,6 +442,20 @@ function App() { local.model.cycleFavorite(-1) }, }, + { + title: "Switch compaction model", + value: "compaction_model.list", + keybind: "compaction_model_list", + category: "Agent", + slash: { + name: "compaction-models", + aliases: ["compaction-model"], + }, + onSelect: () => { + dialog.replace(() => ) + }, + }, + { title: "Switch agent", value: "agent.list", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index c30b8d12a93..ab3c5ed2d02 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -15,7 +15,7 @@ export function useConnected() { ) } -export function DialogModel(props: { providerID?: string }) { +export function DialogModel(props: { providerID?: string; target?: "session" | "compaction" }) { const local = useLocal() const sync = useSync() const dialog = useDialog() @@ -25,14 +25,41 @@ export function DialogModel(props: { providerID?: string }) { const connected = useConnected() const providers = createDialogProviderOptions() + const isCompaction = props.target === "compaction" + const showExtra = createMemo(() => connected() && !props.providerID) + function onModelSelect(model: { providerID: string; modelID: string }) { + dialog.clear() + if (isCompaction) { + local.model.compaction.set(model) + return + } + local.model.set(model, { recent: true }) + } + const options = createMemo(() => { const needle = query().trim() const showSections = showExtra() && needle.length === 0 const favorites = connected() ? local.model.favorite() : [] const recents = local.model.recent() + // "Use session model (default)" option only shown in compaction mode + const defaultOption = isCompaction + ? [ + { + value: { providerID: "", modelID: "" }, + title: "Use session model (default)", + description: "Compaction will use the same model as the session", + category: showSections ? "Default" : undefined, + onSelect: () => { + dialog.clear() + local.model.compaction.clear() + }, + }, + ] + : [] + function toOptions(items: typeof favorites, category: string) { if (!showSections) return [] return items.flatMap((item) => { @@ -49,10 +76,7 @@ export function DialogModel(props: { providerID?: string }) { category, disabled: provider.id === "opencode" && model.id.includes("-nano"), footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect: () => { - dialog.clear() - local.model.set({ providerID: provider.id, modelID: model.id }, { recent: true }) - }, + onSelect: () => onModelSelect({ providerID: provider.id, modelID: model.id }), }, ] }) @@ -87,10 +111,7 @@ export function DialogModel(props: { providerID?: string }) { category: connected() ? provider.name : undefined, disabled: provider.id === "opencode" && model.includes("-nano"), footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - dialog.clear() - local.model.set({ providerID: provider.id, modelID: model }, { recent: true }) - }, + onSelect: () => onModelSelect({ providerID: provider.id, modelID: model }), })), filter((x) => { if (!showSections) return true @@ -121,19 +142,22 @@ export function DialogModel(props: { providerID?: string }) { if (needle) { return [ + ...defaultOption, ...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj), ...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj), ] } - return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders] + return [...defaultOption, ...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders] }) const provider = createMemo(() => props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null, ) - const title = createMemo(() => provider()?.name ?? "Select model") + const title = createMemo(() => (isCompaction ? "Select compaction model" : (provider()?.name ?? "Select model"))) + + const current = createMemo(() => (isCompaction ? local.model.compaction.current() : local.model.current())) return ( [number]["value"]> @@ -151,7 +175,10 @@ export function DialogModel(props: { providerID?: string }) { title: "Favorite", disabled: !connected(), onTrigger: (option) => { - local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) + const val = option.value as { providerID: string; modelID: string } + if (val.providerID && val.modelID) { + local.model.toggleFavorite(val) + } }, }, ]} @@ -159,7 +186,7 @@ export function DialogModel(props: { providerID?: string }) { flat={true} skipFilter={true} title={title()} - current={local.model.current()} + current={current()} /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d63c248fb83..d3a7d4f279c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1012,6 +1012,10 @@ export function Prompt(props: PromptProps) { {local.model.variant.current()} + + ยท + compact: {local.model.compaction.parsed().model} + diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index d93079f12a4..77197135ea5 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -13,6 +13,7 @@ import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" import { Filesystem } from "@/util/filesystem" +import { useKV } from "./kv" export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", @@ -20,6 +21,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const sync = useSync() const sdk = useSDK() const toast = useToast() + const kv = useKV() function isModelValid(model: { providerID: string; modelID: string }) { const provider = sync.data.provider.find((x) => x.id === model.providerID) @@ -320,6 +322,44 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ save() }) }, + compaction: iife(() => { + const key = "compaction_model" + const [get] = kv.signal<{ providerID: string; modelID: string } | undefined>(key, undefined) + return { + current() { + return get() as { providerID: string; modelID: string } | undefined + }, + parsed: createMemo(() => { + const value = get() as { providerID: string; modelID: string } | undefined + if (!value) { + return { + provider: undefined, + model: "Using session model", + } + } + const provider = sync.data.provider.find((x) => x.id === value.providerID) + const info = provider?.models[value.modelID] + return { + provider: provider?.name ?? value.providerID, + model: info?.name ?? value.modelID, + } + }), + set(model: { providerID: string; modelID: string }) { + if (!isModelValid(model)) { + toast.show({ + message: `Model ${model.providerID}/${model.modelID} is not valid`, + variant: "warning", + duration: 3000, + }) + return + } + kv.set(key, { ...model }) + }, + clear() { + kv.set(key, undefined) + }, + } + }), variant: { current() { const m = currentModel() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f5a7f6f6ca4..aa816d45f60 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -430,10 +430,12 @@ export function Session() { }) return } + const compactionModel = local.model.compaction.current() sdk.client.session.summarize({ sessionID: route.sessionID, modelID: selectedModel.modelID, providerID: selectedModel.providerID, + compactionModel: compactionModel ?? undefined, }) dialog.clear() }, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index aad0fd76c4b..cbdaa2a2571 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -828,6 +828,7 @@ export namespace Config { model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), + compaction_model_list: z.string().optional().default("none").describe("List available compaction models"), command_list: z.string().optional().default("ctrl+p").describe("List available commands"), agent_list: z.string().optional().default("a").describe("List agents"), agent_cycle: z.string().optional().default("tab").describe("Next agent"), diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 1195529e06a..39b8a1702d4 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -511,6 +511,12 @@ export const SessionRoutes = lazy(() => providerID: z.string(), modelID: z.string(), auto: z.boolean().optional().default(false), + compactionModel: z + .object({ + providerID: z.string(), + modelID: z.string(), + }) + .optional(), }), ), async (c) => { @@ -535,6 +541,7 @@ export const SessionRoutes = lazy(() => modelID: body.modelID, }, auto: body.auto, + compactionModel: body.compactionModel, }) await SessionPrompt.loop({ sessionID }) return c.json(true) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 9245426057c..3ad507a463f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -104,12 +104,15 @@ export namespace SessionCompaction { sessionID: string abort: AbortSignal auto: boolean + compactionModel?: { providerID: string; modelID: string } }) { const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User const agent = await Agent.get("compaction") - const model = agent.model - ? await Provider.getModel(agent.model.providerID, agent.model.modelID) - : await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID) + const model = input.compactionModel + ? await Provider.getModel(input.compactionModel.providerID, input.compactionModel.modelID) + : agent.model + ? await Provider.getModel(agent.model.providerID, agent.model.modelID) + : await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID) const msg = (await Session.updateMessage({ id: Identifier.ascending("message"), role: "assistant", @@ -237,6 +240,12 @@ When constructing the summary, try to stick to this template: modelID: z.string(), }), auto: z.boolean(), + compactionModel: z + .object({ + providerID: z.string(), + modelID: z.string(), + }) + .optional(), }), async (input) => { const msg = await Session.updateMessage({ @@ -255,6 +264,7 @@ When constructing the summary, try to stick to this template: sessionID: msg.sessionID, type: "compaction", auto: input.auto, + compactionModel: input.compactionModel, }) }, ) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 178751a2227..00dfba2d64e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -196,6 +196,12 @@ export namespace MessageV2 { export const CompactionPart = PartBase.extend({ type: z.literal("compaction"), auto: z.boolean(), + compactionModel: z + .object({ + providerID: z.string(), + modelID: z.string(), + }) + .optional(), }).meta({ ref: "CompactionPart", }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 75bd3c9dfac..781223a13aa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -533,6 +533,7 @@ export namespace SessionPrompt { abort, sessionID, auto: task.auto, + compactionModel: task.compactionModel, }) if (result === "stop") break continue diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b4848e60540..0df8148fb1f 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1442,6 +1442,10 @@ export class Session2 extends HeyApiClient { providerID?: string modelID?: string auto?: boolean + compactionModel?: { + providerID: string + modelID: string + } }, options?: Options, ) { @@ -1455,6 +1459,7 @@ export class Session2 extends HeyApiClient { { in: "body", key: "providerID" }, { in: "body", key: "modelID" }, { in: "body", key: "auto" }, + { in: "body", key: "compactionModel" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4050ef15738..87424d53993 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -505,6 +505,10 @@ export type CompactionPart = { messageID: string type: "compaction" auto: boolean + compactionModel?: { + providerID: string + modelID: string + } } export type Part = @@ -1167,6 +1171,10 @@ export type KeybindsConfig = { * Previous favorite model */ model_cycle_favorite_reverse?: string + /** + * List available compaction models + */ + compaction_model_list?: string /** * List available commands */ @@ -3432,6 +3440,10 @@ export type SessionSummarizeData = { providerID: string modelID: string auto?: boolean + compactionModel?: { + providerID: string + modelID: string + } } path: { /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2741c2362ec..8a431f0cc54 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2303,6 +2303,18 @@ "auto": { "default": false, "type": "boolean" + }, + "compactionModel": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"] } }, "required": ["providerID", "modelID"] @@ -7368,6 +7380,18 @@ }, "auto": { "type": "boolean" + }, + "compactionModel": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"] } }, "required": ["id", "sessionID", "messageID", "type", "auto"] @@ -8870,6 +8894,11 @@ "default": "none", "type": "string" }, + "compaction_model_list": { + "description": "List available compaction models", + "default": "none", + "type": "string" + }, "command_list": { "description": "List available commands", "default": "ctrl+p",