340 lines
8.2 KiB
TypeScript
340 lines
8.2 KiB
TypeScript
// Pure state machine for the question UI.
|
|
//
|
|
// Supports both single-question and multi-question flows. Single questions
|
|
// submit immediately on selection. Multi-question flows use tabs and a
|
|
// final confirmation step.
|
|
//
|
|
// State transitions:
|
|
// questionSelect → picks an option (single: submits, multi: toggles/advances)
|
|
// questionSave → saves custom text input
|
|
// questionMove → arrow key navigation through options
|
|
// questionSetTab → tab navigation between questions
|
|
// questionSubmit → builds the final QuestionReply with all answers
|
|
//
|
|
// Custom answers: if a question has custom=true, an extra "Type your own
|
|
// answer" option appears. Selecting it enters editing mode with a text field.
|
|
import type { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2"
|
|
import type { QuestionReject, QuestionReply } from "./types"
|
|
|
|
export type QuestionBodyState = {
|
|
requestID: string
|
|
tab: number
|
|
answers: string[][]
|
|
custom: string[]
|
|
selected: number
|
|
editing: boolean
|
|
submitting: boolean
|
|
}
|
|
|
|
export type QuestionStep = {
|
|
state: QuestionBodyState
|
|
reply?: QuestionReply
|
|
}
|
|
|
|
export function createQuestionBodyState(requestID: string): QuestionBodyState {
|
|
return {
|
|
requestID,
|
|
tab: 0,
|
|
answers: [],
|
|
custom: [],
|
|
selected: 0,
|
|
editing: false,
|
|
submitting: false,
|
|
}
|
|
}
|
|
|
|
export function questionSync(state: QuestionBodyState, requestID: string): QuestionBodyState {
|
|
if (state.requestID === requestID) {
|
|
return state
|
|
}
|
|
|
|
return createQuestionBodyState(requestID)
|
|
}
|
|
|
|
export function questionSingle(request: QuestionRequest): boolean {
|
|
return request.questions.length === 1 && request.questions[0]?.multiple !== true
|
|
}
|
|
|
|
export function questionTabs(request: QuestionRequest): number {
|
|
return questionSingle(request) ? 1 : request.questions.length + 1
|
|
}
|
|
|
|
export function questionConfirm(request: QuestionRequest, state: QuestionBodyState): boolean {
|
|
return !questionSingle(request) && state.tab === request.questions.length
|
|
}
|
|
|
|
export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined {
|
|
return request.questions[state.tab]
|
|
}
|
|
|
|
export function questionCustom(request: QuestionRequest, state: QuestionBodyState): boolean {
|
|
return questionInfo(request, state)?.custom !== false
|
|
}
|
|
|
|
export function questionInput(state: QuestionBodyState): string {
|
|
return state.custom[state.tab] ?? ""
|
|
}
|
|
|
|
export function questionPicked(state: QuestionBodyState): boolean {
|
|
const value = questionInput(state)
|
|
if (!value) {
|
|
return false
|
|
}
|
|
|
|
return state.answers[state.tab]?.includes(value) ?? false
|
|
}
|
|
|
|
export function questionOther(request: QuestionRequest, state: QuestionBodyState): boolean {
|
|
const info = questionInfo(request, state)
|
|
if (!info || info.custom === false) {
|
|
return false
|
|
}
|
|
|
|
return state.selected === info.options.length
|
|
}
|
|
|
|
export function questionTotal(request: QuestionRequest, state: QuestionBodyState): number {
|
|
const info = questionInfo(request, state)
|
|
if (!info) {
|
|
return 0
|
|
}
|
|
|
|
return info.options.length + (questionCustom(request, state) ? 1 : 0)
|
|
}
|
|
|
|
export function questionAnswers(state: QuestionBodyState, count: number): string[][] {
|
|
return Array.from({ length: count }, (_, idx) => state.answers[idx] ?? [])
|
|
}
|
|
|
|
export function questionSetTab(state: QuestionBodyState, tab: number): QuestionBodyState {
|
|
return {
|
|
...state,
|
|
tab,
|
|
selected: 0,
|
|
editing: false,
|
|
}
|
|
}
|
|
|
|
export function questionSetSelected(state: QuestionBodyState, selected: number): QuestionBodyState {
|
|
return {
|
|
...state,
|
|
selected,
|
|
}
|
|
}
|
|
|
|
export function questionSetEditing(state: QuestionBodyState, editing: boolean): QuestionBodyState {
|
|
return {
|
|
...state,
|
|
editing,
|
|
}
|
|
}
|
|
|
|
export function questionSetSubmitting(state: QuestionBodyState, submitting: boolean): QuestionBodyState {
|
|
return {
|
|
...state,
|
|
submitting,
|
|
}
|
|
}
|
|
|
|
function storeAnswers(state: QuestionBodyState, tab: number, list: string[]): QuestionBodyState {
|
|
const answers = [...state.answers]
|
|
answers[tab] = list
|
|
return {
|
|
...state,
|
|
answers,
|
|
}
|
|
}
|
|
|
|
export function questionStoreCustom(state: QuestionBodyState, tab: number, text: string): QuestionBodyState {
|
|
const custom = [...state.custom]
|
|
custom[tab] = text
|
|
return {
|
|
...state,
|
|
custom,
|
|
}
|
|
}
|
|
|
|
function questionPick(
|
|
state: QuestionBodyState,
|
|
request: QuestionRequest,
|
|
answer: string,
|
|
custom = false,
|
|
): QuestionStep {
|
|
const answers = [...state.answers]
|
|
answers[state.tab] = [answer]
|
|
let next: QuestionBodyState = {
|
|
...state,
|
|
answers,
|
|
editing: false,
|
|
}
|
|
|
|
if (custom) {
|
|
const list = [...state.custom]
|
|
list[state.tab] = answer
|
|
next = {
|
|
...next,
|
|
custom: list,
|
|
}
|
|
}
|
|
|
|
if (questionSingle(request)) {
|
|
return {
|
|
state: next,
|
|
reply: {
|
|
requestID: request.id,
|
|
answers: [[answer]],
|
|
},
|
|
}
|
|
}
|
|
|
|
return {
|
|
state: questionSetTab(next, state.tab + 1),
|
|
}
|
|
}
|
|
|
|
function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyState {
|
|
const list = [...(state.answers[state.tab] ?? [])]
|
|
const idx = list.indexOf(answer)
|
|
if (idx === -1) {
|
|
list.push(answer)
|
|
} else {
|
|
list.splice(idx, 1)
|
|
}
|
|
|
|
return storeAnswers(state, state.tab, list)
|
|
}
|
|
|
|
export function questionMove(state: QuestionBodyState, request: QuestionRequest, dir: -1 | 1): QuestionBodyState {
|
|
const total = questionTotal(request, state)
|
|
if (total === 0) {
|
|
return state
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
selected: (state.selected + dir + total) % total,
|
|
}
|
|
}
|
|
|
|
export function questionSelect(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
|
const info = questionInfo(request, state)
|
|
if (!info) {
|
|
return { state }
|
|
}
|
|
|
|
if (questionOther(request, state)) {
|
|
if (!info.multiple) {
|
|
return {
|
|
state: questionSetEditing(state, true),
|
|
}
|
|
}
|
|
|
|
const value = questionInput(state)
|
|
if (value && questionPicked(state)) {
|
|
return {
|
|
state: questionToggle(state, value),
|
|
}
|
|
}
|
|
|
|
return {
|
|
state: questionSetEditing(state, true),
|
|
}
|
|
}
|
|
|
|
const option = info.options[state.selected]
|
|
if (!option) {
|
|
return { state }
|
|
}
|
|
|
|
if (info.multiple) {
|
|
return {
|
|
state: questionToggle(state, option.label),
|
|
}
|
|
}
|
|
|
|
return questionPick(state, request, option.label)
|
|
}
|
|
|
|
export function questionSave(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
|
const info = questionInfo(request, state)
|
|
if (!info) {
|
|
return { state }
|
|
}
|
|
|
|
const value = questionInput(state).trim()
|
|
const prev = state.custom[state.tab]
|
|
if (!value) {
|
|
if (!prev) {
|
|
return {
|
|
state: questionSetEditing(state, false),
|
|
}
|
|
}
|
|
|
|
const next = questionStoreCustom(state, state.tab, "")
|
|
return {
|
|
state: questionSetEditing(
|
|
storeAnswers(
|
|
next,
|
|
state.tab,
|
|
(state.answers[state.tab] ?? []).filter((item) => item !== prev),
|
|
),
|
|
false,
|
|
),
|
|
}
|
|
}
|
|
|
|
if (info.multiple) {
|
|
const answers = [...(state.answers[state.tab] ?? [])]
|
|
if (prev) {
|
|
const idx = answers.indexOf(prev)
|
|
if (idx !== -1) {
|
|
answers.splice(idx, 1)
|
|
}
|
|
}
|
|
|
|
if (!answers.includes(value)) {
|
|
answers.push(value)
|
|
}
|
|
|
|
const next = questionStoreCustom(state, state.tab, value)
|
|
return {
|
|
state: questionSetEditing(storeAnswers(next, state.tab, answers), false),
|
|
}
|
|
}
|
|
|
|
return questionPick(state, request, value, true)
|
|
}
|
|
|
|
export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply {
|
|
return {
|
|
requestID: request.id,
|
|
answers: questionAnswers(state, request.questions.length),
|
|
}
|
|
}
|
|
|
|
export function questionReject(request: QuestionRequest): QuestionReject {
|
|
return {
|
|
requestID: request.id,
|
|
}
|
|
}
|
|
|
|
export function questionHint(request: QuestionRequest, state: QuestionBodyState): string {
|
|
if (state.submitting) {
|
|
return "Waiting for question event..."
|
|
}
|
|
|
|
if (questionConfirm(request, state)) {
|
|
return "enter submit esc dismiss"
|
|
}
|
|
|
|
if (state.editing) {
|
|
return "enter save esc cancel"
|
|
}
|
|
|
|
const info = questionInfo(request, state)
|
|
if (questionSingle(request)) {
|
|
return `↑↓ select enter ${info?.multiple ? "toggle" : "submit"} esc dismiss`
|
|
}
|
|
|
|
return `⇆ tab ↑↓ select enter ${info?.multiple ? "toggle" : "confirm"} esc dismiss`
|
|
}
|