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:
168
packages/ui/tui/src/chat/questions.ts
Normal file
168
packages/ui/tui/src/chat/questions.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Ask-user-question sub-machine for the interactive chat channel. Registers the
|
||||
* user-interaction provider, presents one question overlay at a time in FIFO
|
||||
* order, and settles each request on answer, abort, overlay error, or channel
|
||||
* shutdown.
|
||||
* @module @deepseek-ai/dsh-tui/chat/questions
|
||||
*/
|
||||
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionAnswer,
|
||||
type AskUserQuestionAnswerItem,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import { QuestionDialog } from '../components/dialogs.ts'
|
||||
import type { ChatChannelDeps } from './channel.ts'
|
||||
|
||||
/** One queued or active ask-user-question request and its running answers. */
|
||||
interface PendingQuestion {
|
||||
request: AskUserQuestionRequest
|
||||
index: number
|
||||
answers: AskUserQuestionAnswerItem[]
|
||||
resolve(answer: AskUserQuestionAnswer): void
|
||||
reject(error: unknown): void
|
||||
onAbort: () => void
|
||||
overlay: TuiOverlaySession | undefined
|
||||
}
|
||||
|
||||
/** Collaborators the question queue needs from the chat channel. */
|
||||
export type QuestionQueueDeps = ChatChannelDeps
|
||||
|
||||
/** Ask-user-question controller for one chat channel. */
|
||||
export interface QuestionQueue {
|
||||
/** Reject the active and all queued questions (shutdown). */
|
||||
rejectAll(): void
|
||||
/** Remove the user-interaction provider registration. */
|
||||
unregister(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ask-user-question queue for one chat channel.
|
||||
* @param deps - channel collaborators and overlay host.
|
||||
* @returns the controller used at shutdown to drain and unregister.
|
||||
*/
|
||||
export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue {
|
||||
const { ctx, resolved, palette, overlayManager } = deps
|
||||
const questionQueue: PendingQuestion[] = []
|
||||
let activeQuestion: PendingQuestion | undefined
|
||||
|
||||
const removeAbortListener = (pending: PendingQuestion): void => {
|
||||
pending.request.signal?.removeEventListener('abort', pending.onAbort)
|
||||
}
|
||||
|
||||
const rejectQuestion = (pending: PendingQuestion): void => {
|
||||
void pending.overlay?.close()
|
||||
pending.overlay = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError(
|
||||
'ask_user_question was interrupted before the user answered',
|
||||
'ASK_ABORTED',
|
||||
))
|
||||
}
|
||||
|
||||
const startNextQuestion = (): void => {
|
||||
if (activeQuestion !== undefined || deps.isDisposed()) return
|
||||
const pending = questionQueue.shift()
|
||||
if (pending === undefined) return
|
||||
activeQuestion = pending
|
||||
const show = (): void => {
|
||||
const question = pending.request.questions[pending.index]
|
||||
if (question === undefined) {
|
||||
activeQuestion = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.resolve({ answers: pending.answers })
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
const session = overlayManager.open({
|
||||
...pending.request.signal === undefined ? {} : { signal: pending.request.signal },
|
||||
create: () => new QuestionDialog(
|
||||
question,
|
||||
pending.index + 1,
|
||||
pending.request.questions.length,
|
||||
pending.request.questions.length - pending.answers.length,
|
||||
resolved.maxQuestionOptions,
|
||||
palette,
|
||||
(selection) => {
|
||||
pending.overlay = undefined
|
||||
void session.close()
|
||||
pending.answers.push({ id: question.id, ...selection })
|
||||
pending.index += 1
|
||||
show()
|
||||
},
|
||||
() => {
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
startNextQuestion()
|
||||
},
|
||||
),
|
||||
options: {
|
||||
width: resolved.questionDialogWidth,
|
||||
maxHeight: resolved.questionDialogMaxHeight,
|
||||
anchor: 'bottom-left',
|
||||
margin: { bottom: 1 },
|
||||
},
|
||||
})
|
||||
pending.overlay = session
|
||||
void session.closed.then((result) => {
|
||||
if (pending.overlay !== session) return
|
||||
pending.overlay = undefined
|
||||
/* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */
|
||||
if (result.reason !== 'error') return
|
||||
activeQuestion = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError(
|
||||
`ask_user_question TUI failed: ${errorChain(result.error)}`,
|
||||
'ASK_ABORTED',
|
||||
))
|
||||
startNextQuestion()
|
||||
})
|
||||
deps.requestRender()
|
||||
}
|
||||
show()
|
||||
}
|
||||
|
||||
const unregister = ctx.userInteraction.registerProvider({
|
||||
ask(request) {
|
||||
return new Promise<AskUserQuestionAnswer>((resolveAnswer, reject) => {
|
||||
const pending: PendingQuestion = {
|
||||
request,
|
||||
index: 0,
|
||||
answers: [],
|
||||
resolve: resolveAnswer,
|
||||
reject,
|
||||
overlay: undefined,
|
||||
onAbort: () => {
|
||||
if (activeQuestion === pending) {
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
// A non-active pending ask remains in the queue until this listener settles it.
|
||||
questionQueue.splice(questionQueue.indexOf(pending), 1)
|
||||
rejectQuestion(pending)
|
||||
},
|
||||
}
|
||||
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
|
||||
questionQueue.push(pending)
|
||||
startNextQuestion()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
rejectAll(): void {
|
||||
if (activeQuestion !== undefined) {
|
||||
const pending = activeQuestion
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
}
|
||||
for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
|
||||
},
|
||||
unregister,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user