refactor(tui): split createTuiChat into chat/ sub-controllers
Extract model-command, questions, and resume sub-machines from the ~1600-line createTuiChat closure into src/chat/ factories that take explicit dependency bundles (shared ChatChannelDeps/ChannelNotice). Reorganize src/ so chat/ holds all chat-channel concerns (former input and session/ files move under it); xml-tool-output moves to components/; TuiRuntime/TuiResumeHost move to runtime.ts. index.ts drops 2067->~1530 lines. Behavior identical: 167 tests and all TUI snapshots pass unchanged.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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<ContextResolution> | undefined
|
||||
let modelOverlay: TuiOverlaySession | undefined
|
||||
let modelCommands = Promise.resolve()
|
||||
|
||||
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
|
||||
contextWindow = undefined
|
||||
const resolution: Promise<ContextResolution> = 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<void> => {
|
||||
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 <provider>/<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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user