/** * Model-selection sub-controller for the interactive chat channel: the queued * `/model` command, the keyboard model selector overlay with reasoning-effort * selection, and resolution of the selected model's context window. Owns the * context-window cache the prompt and status views read; the caller owns the * shared {@link AgentLlmTargetRef}. * @module @deepseek-ai/dsh-tui/chat/model-command */ import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { TuiOverlaySession } from '../extension/types.ts' import { displayText } from '../components/text.ts' import { ModelDialog, readModelChoices, targetLabel, targetReasoningLabel, type ModelChoice, type ModelDialogSelection, } from '../components/dialogs.ts' import type { ChannelNotice, ChatChannelDeps } from './channel.ts' /** Collaborators the model controller needs from the chat channel. */ export interface ModelControllerDeps extends ChatChannelDeps, ChannelNotice { /** Shared selected-target handle owned by the channel. */ readonly target: AgentLlmTargetRef } /** Model-selection controller for one chat channel. */ export interface ModelController { /** Resolved context window of the selected model, or `undefined` if unknown. */ contextWindow(): number | undefined /** Queue a `/model` command; empty argument opens the selector. */ queueModelCommand(raw: string): void /** Drop the pending context-window resolution (shutdown). */ resetContextResolution(): void /** Forget the tracked selector overlay (shutdown). */ clearOverlay(): void } type ContextResolution = | { readonly kind: 'resolved'; readonly contextWindow: number | undefined } | { readonly kind: 'error'; readonly error: unknown } /** * Build the model-selection controller for one chat channel. * @param deps - channel collaborators and shared target handle. * @returns the controller wired to the channel's overlay and prompt views. */ export function createModelController(deps: ModelControllerDeps): ModelController { const { ctx, resolved, palette, overlayManager, target } = deps let contextWindow: number | undefined let contextResolution: Promise | undefined let modelOverlay: TuiOverlaySession | undefined let modelCommands = Promise.resolve() const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { contextWindow = undefined const resolution: Promise = selected === undefined ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) : ctx.llm.resolveModelInfo(selected.provider, selected.model).then( info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const), (error: unknown) => ({ kind: 'error', error } as const), ) contextResolution = resolution void resolution.then((result) => { if (contextResolution !== resolution) return if (result.kind === 'error') { deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') return } contextWindow = result.contextWindow deps.requestRender() }) } resolveContextWindow(target.current) const selectModel = ( selected: ModelChoice, explicitReasoning?: { effort: ReasoningEffortId | undefined }, ): void => { const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model const reasoningEffort = explicitReasoning === undefined ? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort) : explicitReasoning.effort if (sameRoute && target.current?.reasoningEffort === reasoningEffort) { const reasoning = targetReasoningLabel(selected, reasoningEffort) deps.appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`) return } target.current = { provider: selected.provider, model: selected.model, ...reasoningEffort === undefined ? {} : { reasoningEffort }, } resolveContextWindow(target.current) const reasoning = targetReasoningLabel(selected, reasoningEffort) deps.appendNotice([ `Model selected: ${targetLabel(selected)}.`, ...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`], 'New steps will use it.', ].join(' ')) } const showModelSelector = (choices: readonly ModelChoice[]): void => { const current = target.current === undefined ? 'unset' : targetLabel(target.current) if (choices.length === 0) { deps.appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning') return } void modelOverlay?.close() const session = overlayManager.open({ create: () => new ModelDialog( choices, target.current, resolved.maxModelOptions, palette, (selection: ModelDialogSelection) => { void session.close() selectModel(selection.choice, { effort: selection.reasoningEffort }) }, () => { void session.close() }, ), options: { width: resolved.modelDialogWidth, maxHeight: resolved.modelDialogMaxHeight, anchor: 'center', margin: 1, }, }) modelOverlay = session void session.closed.then(() => { if (modelOverlay === session) modelOverlay = undefined }) deps.requestRender() } const handleModelCommand = async (raw: string): Promise => { const choices = await readModelChoices(ctx, target.current) if (deps.isDisposed()) return const argument = raw.trim() if (argument === '') { showModelSelector(choices) return } const parts = argument.split(/\s+/u) if (parts.length > 2) { deps.appendNotice('Usage: /model [provider/]model', 'warning') return } let matches: ModelChoice[] if (parts.length === 2) { matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1]) } else { const value = argument const qualified = choices.filter(choice => targetLabel(choice) === value) matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value) } if (matches.length === 0) { deps.appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning') return } if (matches.length > 1) { deps.appendNotice(`Model "${argument}" is advertised by multiple providers; use /model /.`, 'warning') return } const selected = matches[0] /* v8 ignore next -- a non-empty matches array always has index zero. */ if (selected === undefined) return selectModel(selected) } return { contextWindow: () => contextWindow, queueModelCommand(raw: string): void { modelCommands = modelCommands.then(async () => { await handleModelCommand(raw) }).catch((error: unknown) => { if (!deps.isDisposed()) deps.appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error') }) }, resetContextResolution(): void { contextResolution = undefined }, clearOverlay(): void { modelOverlay = undefined }, } }