// Question UI body for the direct-mode footer. // // Renders inside the footer when the reducer pushes a FooterView of type // "question". Supports single-question and multi-question flows: // // Single question: options list with up/down selection, digit shortcuts, // and optional custom text input. // // Multi-question: tabbed interface where each question is a tab, plus a // final "Confirm" tab that shows all answers for review. Tab/shift-tab // or left/right to navigate between questions. // // All state logic lives in question.shared.ts as a pure state machine. // This component just renders it and dispatches keyboard events. /** @jsxImportSource @opentui/solid */ import type { TextareaRenderable } from "@opentui/core" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { For, Show, createEffect, createMemo, createSignal } from "solid-js" import type { QuestionRequest } from "@opencode-ai/sdk/v2" import { createQuestionBodyState, questionConfirm, questionCustom, questionInfo, questionInput, questionMove, questionOther, questionPicked, questionReject, questionSave, questionSelect, questionSetEditing, questionSetSelected, questionSetSubmitting, questionSetTab, questionSingle, questionStoreCustom, questionSubmit, questionSync, questionTabs, questionTotal, } from "./question.shared" import { footerWidthPolicy } from "./footer.width" import type { RunFooterTheme } from "./theme" import type { QuestionReject, QuestionReply } from "./types" export function RunQuestionBody(props: { request: QuestionRequest theme: RunFooterTheme onReply: (input: QuestionReply) => void | Promise onReject: (input: QuestionReject) => void | Promise }) { const dims = useTerminalDimensions() const [state, setState] = createSignal(createQuestionBodyState(props.request.id)) const single = createMemo(() => questionSingle(props.request)) const confirm = createMemo(() => questionConfirm(props.request, state())) const info = createMemo(() => questionInfo(props.request, state())) const input = createMemo(() => questionInput(state())) const other = createMemo(() => questionOther(props.request, state())) const picked = createMemo(() => questionPicked(state())) const disabled = createMemo(() => state().submitting) const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) const verb = createMemo(() => { if (confirm()) { return "submit" } if (info()?.multiple) { return "toggle" } if (single()) { return "submit" } return "confirm" }) let area: TextareaRenderable | undefined createEffect(() => { setState((prev) => questionSync(prev, props.request.id)) }) const setTab = (tab: number) => { setState((prev) => questionSetTab(prev, tab)) } const move = (dir: -1 | 1) => { setState((prev) => questionMove(prev, props.request, dir)) } const beginReply = async (input: QuestionReply) => { setState((prev) => questionSetSubmitting(prev, true)) try { await props.onReply(input) } catch { setState((prev) => questionSetSubmitting(prev, false)) } } const beginReject = async (input: QuestionReject) => { setState((prev) => questionSetSubmitting(prev, true)) try { await props.onReject(input) } catch { setState((prev) => questionSetSubmitting(prev, false)) } } const saveCustom = () => { const cur = state() const next = questionSave(cur, props.request) if (next.state !== cur) { setState(next.state) } if (!next.reply) { return } void beginReply(next.reply) } const choose = (selected: number) => { const base = state() const cur = questionSetSelected(base, selected) const next = questionSelect(cur, props.request) if (next.state !== base) { setState(next.state) } if (!next.reply) { return } void beginReply(next.reply) } const mark = (selected: number) => { setState((prev) => questionSetSelected(prev, selected)) } const select = () => { const cur = state() const next = questionSelect(cur, props.request) if (next.state !== cur) { setState(next.state) } if (!next.reply) { return } void beginReply(next.reply) } const submit = () => { void beginReply(questionSubmit(props.request, state())) } const reject = () => { void beginReject(questionReject(props.request)) } useKeyboard((event) => { const cur = state() if (cur.submitting) { event.preventDefault() return } if (cur.editing) { if (event.name === "escape") { setState((prev) => questionSetEditing(prev, false)) event.preventDefault() return } return } if (!single() && (event.name === "left" || event.name === "h")) { setTab((cur.tab - 1 + questionTabs(props.request)) % questionTabs(props.request)) event.preventDefault() return } if (!single() && (event.name === "right" || event.name === "l")) { setTab((cur.tab + 1) % questionTabs(props.request)) event.preventDefault() return } if (!single() && event.name === "tab") { const dir = event.shift ? -1 : 1 setTab((cur.tab + dir + questionTabs(props.request)) % questionTabs(props.request)) event.preventDefault() return } if (questionConfirm(props.request, cur)) { if (event.name === "return") { submit() event.preventDefault() return } if (event.name === "escape") { reject() event.preventDefault() } return } const total = questionTotal(props.request, cur) const max = Math.min(total, 9) const digit = Number(event.name) if (!Number.isNaN(digit) && digit >= 1 && digit <= max) { choose(digit - 1) event.preventDefault() return } if (event.name === "up" || event.name === "k") { move(-1) event.preventDefault() return } if (event.name === "down" || event.name === "j") { move(1) event.preventDefault() return } if (event.name === "return") { select() event.preventDefault() return } if (event.name === "escape") { reject() event.preventDefault() } }) createEffect(() => { if (!state().editing || !area || area.isDestroyed) { return } if (area.plainText !== input()) { area.setText(input()) area.cursorOffset = input().length } queueMicrotask(() => { if (!area || area.isDestroyed || !state().editing) { return } area.focus() area.cursorOffset = area.plainText.length }) }) return ( {(item, index) => { const active = () => state().tab === index() const answered = () => (state().answers[index()]?.length ?? 0) > 0 return ( { if (!disabled()) setTab(index()) }} > {item.header} ) }} { if (!disabled()) setTab(props.request.questions.length) }} > Confirm Review {(item, index) => { const value = () => state().answers[index()]?.join(", ") ?? "" const answered = () => Boolean(value()) return ( {item.header}:{" "} {answered() ? value() : "(not answered)"} ) }} } > {info()?.question} {info()?.multiple ? " (select all that apply)" : ""} {(item, index) => { const active = () => state().selected === index() const hit = () => state().answers[state().tab]?.includes(item.label) ?? false return ( { if (!disabled()) { mark(index()) } }} onMouseDown={() => { if (!disabled()) { mark(index()) } }} onMouseUp={() => { if (!disabled()) { choose(index()) } }} > {`${index() + 1}.`} {info()?.multiple ? `[${hit() ? "✓" : " "}] ${item.label}` : item.label} {hit() ? " ✓" : ""} {item.description} ) }} { if (!disabled()) { mark(info()?.options.length ?? 0) } }} onMouseDown={() => { if (!disabled()) { mark(info()?.options.length ?? 0) } }} onMouseUp={() => { if (!disabled()) { choose(info()?.options.length ?? 0) } }} > {`${(info()?.options.length ?? 0) + 1}.`} {info()?.multiple ? `[${picked() ? "✓" : " "}] Type your own answer` : "Type your own answer"} {picked() ? " ✓" : ""} {input()} } >