573 lines
19 KiB
TypeScript
573 lines
19 KiB
TypeScript
// 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<void>
|
|
onReject: (input: QuestionReject) => void | Promise<void>
|
|
}) {
|
|
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 (
|
|
<box width="100%" height="100%" flexDirection="column">
|
|
<box
|
|
flexDirection="column"
|
|
gap={1}
|
|
paddingLeft={1}
|
|
paddingRight={3}
|
|
paddingTop={1}
|
|
flexGrow={1}
|
|
flexShrink={1}
|
|
backgroundColor={props.theme.surface}
|
|
>
|
|
<Show when={!single()}>
|
|
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
|
<For each={props.request.questions}>
|
|
{(item, index) => {
|
|
const active = () => state().tab === index()
|
|
const answered = () => (state().answers[index()]?.length ?? 0) > 0
|
|
return (
|
|
<box
|
|
paddingLeft={1}
|
|
paddingRight={1}
|
|
backgroundColor={active() ? props.theme.highlight : props.theme.surface}
|
|
onMouseUp={() => {
|
|
if (!disabled()) setTab(index())
|
|
}}
|
|
>
|
|
<text fg={active() ? props.theme.surface : answered() ? props.theme.text : props.theme.muted}>
|
|
{item.header}
|
|
</text>
|
|
</box>
|
|
)
|
|
}}
|
|
</For>
|
|
<box
|
|
paddingLeft={1}
|
|
paddingRight={1}
|
|
backgroundColor={confirm() ? props.theme.highlight : props.theme.surface}
|
|
onMouseUp={() => {
|
|
if (!disabled()) setTab(props.request.questions.length)
|
|
}}
|
|
>
|
|
<text fg={confirm() ? props.theme.surface : props.theme.muted}>Confirm</text>
|
|
</box>
|
|
</box>
|
|
</Show>
|
|
|
|
<Show
|
|
when={!confirm()}
|
|
fallback={
|
|
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1}>
|
|
<scrollbox
|
|
width="100%"
|
|
height="100%"
|
|
verticalScrollbarOptions={{
|
|
trackOptions: {
|
|
backgroundColor: props.theme.surface,
|
|
foregroundColor: props.theme.line,
|
|
},
|
|
}}
|
|
>
|
|
<box width="100%" flexDirection="column" gap={1}>
|
|
<box paddingLeft={1}>
|
|
<text fg={props.theme.text}>Review</text>
|
|
</box>
|
|
<For each={props.request.questions}>
|
|
{(item, index) => {
|
|
const value = () => state().answers[index()]?.join(", ") ?? ""
|
|
const answered = () => Boolean(value())
|
|
return (
|
|
<box paddingLeft={1}>
|
|
<text wrapMode="word">
|
|
<span style={{ fg: props.theme.muted }}>{item.header}:</span>{" "}
|
|
<span style={{ fg: answered() ? props.theme.text : props.theme.error }}>
|
|
{answered() ? value() : "(not answered)"}
|
|
</span>
|
|
</text>
|
|
</box>
|
|
)
|
|
}}
|
|
</For>
|
|
</box>
|
|
</scrollbox>
|
|
</box>
|
|
}
|
|
>
|
|
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} gap={1}>
|
|
<box>
|
|
<text fg={props.theme.text} wrapMode="word">
|
|
{info()?.question}
|
|
{info()?.multiple ? " (select all that apply)" : ""}
|
|
</text>
|
|
</box>
|
|
|
|
<box flexGrow={1} flexShrink={1}>
|
|
<scrollbox
|
|
width="100%"
|
|
height="100%"
|
|
verticalScrollbarOptions={{
|
|
trackOptions: {
|
|
backgroundColor: props.theme.surface,
|
|
foregroundColor: props.theme.line,
|
|
},
|
|
}}
|
|
>
|
|
<box width="100%" flexDirection="column">
|
|
<For each={info()?.options ?? []}>
|
|
{(item, index) => {
|
|
const active = () => state().selected === index()
|
|
const hit = () => state().answers[state().tab]?.includes(item.label) ?? false
|
|
return (
|
|
<box
|
|
flexDirection="column"
|
|
gap={0}
|
|
onMouseOver={() => {
|
|
if (!disabled()) {
|
|
mark(index())
|
|
}
|
|
}}
|
|
onMouseDown={() => {
|
|
if (!disabled()) {
|
|
mark(index())
|
|
}
|
|
}}
|
|
onMouseUp={() => {
|
|
if (!disabled()) {
|
|
choose(index())
|
|
}
|
|
}}
|
|
>
|
|
<box flexDirection="row">
|
|
<box backgroundColor={active() ? props.theme.line : undefined} paddingRight={1}>
|
|
<text fg={active() ? props.theme.highlight : props.theme.muted}>{`${index() + 1}.`}</text>
|
|
</box>
|
|
<box backgroundColor={active() ? props.theme.line : undefined}>
|
|
<text
|
|
fg={active() ? props.theme.highlight : hit() ? props.theme.success : props.theme.text}
|
|
>
|
|
{info()?.multiple ? `[${hit() ? "✓" : " "}] ${item.label}` : item.label}
|
|
</text>
|
|
</box>
|
|
<Show when={!info()?.multiple}>
|
|
<text fg={props.theme.success}>{hit() ? " ✓" : ""}</text>
|
|
</Show>
|
|
</box>
|
|
<box paddingLeft={3}>
|
|
<text fg={props.theme.muted} wrapMode="word">
|
|
{item.description}
|
|
</text>
|
|
</box>
|
|
</box>
|
|
)
|
|
}}
|
|
</For>
|
|
|
|
<Show when={questionCustom(props.request, state())}>
|
|
<box
|
|
flexDirection="column"
|
|
gap={0}
|
|
onMouseOver={() => {
|
|
if (!disabled()) {
|
|
mark(info()?.options.length ?? 0)
|
|
}
|
|
}}
|
|
onMouseDown={() => {
|
|
if (!disabled()) {
|
|
mark(info()?.options.length ?? 0)
|
|
}
|
|
}}
|
|
onMouseUp={() => {
|
|
if (!disabled()) {
|
|
choose(info()?.options.length ?? 0)
|
|
}
|
|
}}
|
|
>
|
|
<box flexDirection="row">
|
|
<box backgroundColor={other() ? props.theme.line : undefined} paddingRight={1}>
|
|
<text
|
|
fg={other() ? props.theme.highlight : props.theme.muted}
|
|
>{`${(info()?.options.length ?? 0) + 1}.`}</text>
|
|
</box>
|
|
<box backgroundColor={other() ? props.theme.line : undefined}>
|
|
<text
|
|
fg={other() ? props.theme.highlight : picked() ? props.theme.success : props.theme.text}
|
|
>
|
|
{info()?.multiple
|
|
? `[${picked() ? "✓" : " "}] Type your own answer`
|
|
: "Type your own answer"}
|
|
</text>
|
|
</box>
|
|
<Show when={!info()?.multiple}>
|
|
<text fg={props.theme.success}>{picked() ? " ✓" : ""}</text>
|
|
</Show>
|
|
</box>
|
|
<Show
|
|
when={state().editing}
|
|
fallback={
|
|
<Show when={input()}>
|
|
<box paddingLeft={3}>
|
|
<text fg={props.theme.muted} wrapMode="word">
|
|
{input()}
|
|
</text>
|
|
</box>
|
|
</Show>
|
|
}
|
|
>
|
|
<box paddingLeft={3}>
|
|
<textarea
|
|
width="100%"
|
|
minHeight={1}
|
|
maxHeight={4}
|
|
wrapMode="word"
|
|
placeholder="Type your own answer"
|
|
placeholderColor={props.theme.muted}
|
|
textColor={props.theme.text}
|
|
focusedTextColor={props.theme.text}
|
|
backgroundColor={props.theme.surface}
|
|
focusedBackgroundColor={props.theme.surface}
|
|
cursorColor={props.theme.text}
|
|
focused={!disabled()}
|
|
onSubmit={saveCustom}
|
|
onContentChange={() => {
|
|
if (!area || area.isDestroyed || disabled()) {
|
|
return
|
|
}
|
|
|
|
const text = area.plainText
|
|
setState((prev) => questionStoreCustom(prev, prev.tab, text))
|
|
}}
|
|
ref={(item) => {
|
|
area = item
|
|
}}
|
|
/>
|
|
</box>
|
|
</Show>
|
|
</box>
|
|
</Show>
|
|
</box>
|
|
</scrollbox>
|
|
</box>
|
|
</box>
|
|
</Show>
|
|
</box>
|
|
|
|
<box
|
|
flexDirection={narrow() ? "column" : "row"}
|
|
flexShrink={0}
|
|
gap={1}
|
|
paddingLeft={2}
|
|
paddingRight={3}
|
|
paddingBottom={1}
|
|
justifyContent={narrow() ? "flex-start" : "space-between"}
|
|
alignItems={narrow() ? "flex-start" : "center"}
|
|
>
|
|
<Show
|
|
when={!disabled()}
|
|
fallback={
|
|
<text fg={props.theme.muted} wrapMode="word">
|
|
Waiting for question event...
|
|
</text>
|
|
}
|
|
>
|
|
<box
|
|
flexDirection={narrow() ? "column" : "row"}
|
|
gap={narrow() ? 1 : 2}
|
|
flexShrink={0}
|
|
width={narrow() ? "100%" : undefined}
|
|
>
|
|
<Show
|
|
when={!state().editing}
|
|
fallback={
|
|
<>
|
|
<text fg={props.theme.text}>
|
|
enter <span style={{ fg: props.theme.muted }}>save</span>
|
|
</text>
|
|
<text fg={props.theme.text}>
|
|
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
|
</text>
|
|
</>
|
|
}
|
|
>
|
|
<Show when={!single()}>
|
|
<text fg={props.theme.text}>
|
|
{"⇆"} <span style={{ fg: props.theme.muted }}>tab</span>
|
|
</text>
|
|
</Show>
|
|
<Show when={!confirm()}>
|
|
<text fg={props.theme.text}>
|
|
{"↑↓"} <span style={{ fg: props.theme.muted }}>select</span>
|
|
</text>
|
|
</Show>
|
|
<text fg={props.theme.text}>
|
|
enter <span style={{ fg: props.theme.muted }}>{verb()}</span>
|
|
</text>
|
|
<text fg={props.theme.text}>
|
|
esc <span style={{ fg: props.theme.muted }}>dismiss</span>
|
|
</text>
|
|
</Show>
|
|
</box>
|
|
</Show>
|
|
</box>
|
|
</box>
|
|
)
|
|
}
|