| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428 |
- import { createStore } from "solid-js/store"
- import { createMemo, For, Show } from "solid-js"
- import { useKeyboard } from "@opentui/solid"
- import type { TextareaRenderable } from "@opentui/core"
- import { useKeybind } from "../../context/keybind"
- import { useTheme } from "../../context/theme"
- import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
- import { useSDK } from "../../context/sdk"
- import { SplitBorder } from "../../component/border"
- import { useTextareaKeybindings } from "../../component/textarea-keybindings"
- import { useDialog } from "../../ui/dialog"
- export function QuestionPrompt(props: { request: QuestionRequest }) {
- const sdk = useSDK()
- const { theme } = useTheme()
- const keybind = useKeybind()
- const bindings = useTextareaKeybindings()
- const questions = createMemo(() => props.request.questions)
- const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
- const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
- const [store, setStore] = createStore({
- tab: 0,
- answers: [] as QuestionAnswer[],
- custom: [] as string[],
- selected: 0,
- editing: false,
- })
- let textarea: TextareaRenderable | undefined
- const question = createMemo(() => questions()[store.tab])
- const confirm = createMemo(() => !single() && store.tab === questions().length)
- const options = createMemo(() => question()?.options ?? [])
- const custom = createMemo(() => question()?.custom !== false)
- const other = createMemo(() => custom() && store.selected === options().length)
- const input = createMemo(() => store.custom[store.tab] ?? "")
- const multi = createMemo(() => question()?.multiple === true)
- const customPicked = createMemo(() => {
- const value = input()
- if (!value) return false
- return store.answers[store.tab]?.includes(value) ?? false
- })
- function submit() {
- const answers = questions().map((_, i) => store.answers[i] ?? [])
- sdk.client.question.reply({
- requestID: props.request.id,
- answers,
- })
- }
- function reject() {
- sdk.client.question.reject({
- requestID: props.request.id,
- })
- }
- function pick(answer: string, custom: boolean = false) {
- const answers = [...store.answers]
- answers[store.tab] = [answer]
- setStore("answers", answers)
- if (custom) {
- const inputs = [...store.custom]
- inputs[store.tab] = answer
- setStore("custom", inputs)
- }
- if (single()) {
- sdk.client.question.reply({
- requestID: props.request.id,
- answers: [[answer]],
- })
- return
- }
- setStore("tab", store.tab + 1)
- setStore("selected", 0)
- }
- function toggle(answer: string) {
- const existing = store.answers[store.tab] ?? []
- const next = [...existing]
- const index = next.indexOf(answer)
- if (index === -1) next.push(answer)
- if (index !== -1) next.splice(index, 1)
- const answers = [...store.answers]
- answers[store.tab] = next
- setStore("answers", answers)
- }
- function moveTo(index: number) {
- setStore("selected", index)
- }
- function selectTab(index: number) {
- setStore("tab", index)
- setStore("selected", 0)
- }
- function selectOption() {
- if (other()) {
- if (!multi()) {
- setStore("editing", true)
- return
- }
- const value = input()
- if (value && customPicked()) {
- toggle(value)
- return
- }
- setStore("editing", true)
- return
- }
- const opt = options()[store.selected]
- if (!opt) return
- if (multi()) {
- toggle(opt.label)
- return
- }
- pick(opt.label)
- }
- const dialog = useDialog()
- useKeyboard((evt) => {
- // Skip processing if a dialog (e.g., command palette) is open
- if (dialog.stack.length > 0) return
- // When editing "Other" textarea
- if (store.editing && !confirm()) {
- if (evt.name === "escape") {
- evt.preventDefault()
- setStore("editing", false)
- return
- }
- if (keybind.match("input_clear", evt)) {
- evt.preventDefault()
- const text = textarea?.plainText ?? ""
- if (!text) {
- setStore("editing", false)
- return
- }
- textarea?.setText("")
- return
- }
- if (evt.name === "return") {
- evt.preventDefault()
- const text = textarea?.plainText?.trim() ?? ""
- const prev = store.custom[store.tab]
- if (!text) {
- if (prev) {
- const inputs = [...store.custom]
- inputs[store.tab] = ""
- setStore("custom", inputs)
- const answers = [...store.answers]
- answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
- setStore("answers", answers)
- }
- setStore("editing", false)
- return
- }
- if (multi()) {
- const inputs = [...store.custom]
- inputs[store.tab] = text
- setStore("custom", inputs)
- const existing = store.answers[store.tab] ?? []
- const next = [...existing]
- if (prev) {
- const index = next.indexOf(prev)
- if (index !== -1) next.splice(index, 1)
- }
- if (!next.includes(text)) next.push(text)
- const answers = [...store.answers]
- answers[store.tab] = next
- setStore("answers", answers)
- setStore("editing", false)
- return
- }
- pick(text, true)
- setStore("editing", false)
- return
- }
- // Let textarea handle all other keys
- return
- }
- if (evt.name === "left" || evt.name === "h") {
- evt.preventDefault()
- selectTab((store.tab - 1 + tabs()) % tabs())
- }
- if (evt.name === "right" || evt.name === "l") {
- evt.preventDefault()
- selectTab((store.tab + 1) % tabs())
- }
- if (evt.name === "tab") {
- evt.preventDefault()
- const direction = evt.shift ? -1 : 1
- selectTab((store.tab + direction + tabs()) % tabs())
- }
- if (confirm()) {
- if (evt.name === "return") {
- evt.preventDefault()
- submit()
- }
- if (evt.name === "escape" || keybind.match("app_exit", evt)) {
- evt.preventDefault()
- reject()
- }
- } else {
- const opts = options()
- const total = opts.length + (custom() ? 1 : 0)
- const max = Math.min(total, 9)
- const digit = Number(evt.name)
- if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
- evt.preventDefault()
- const index = digit - 1
- moveTo(index)
- selectOption()
- return
- }
- if (evt.name === "up" || evt.name === "k") {
- evt.preventDefault()
- moveTo((store.selected - 1 + total) % total)
- }
- if (evt.name === "down" || evt.name === "j") {
- evt.preventDefault()
- moveTo((store.selected + 1) % total)
- }
- if (evt.name === "return") {
- evt.preventDefault()
- selectOption()
- }
- if (evt.name === "escape" || keybind.match("app_exit", evt)) {
- evt.preventDefault()
- reject()
- }
- }
- })
- return (
- <box
- backgroundColor={theme.backgroundPanel}
- border={["left"]}
- borderColor={theme.accent}
- customBorderChars={SplitBorder.customBorderChars}
- >
- <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
- <Show when={!single()}>
- <box flexDirection="row" gap={1} paddingLeft={1}>
- <For each={questions()}>
- {(q, index) => {
- const isActive = () => index() === store.tab
- const isAnswered = () => {
- return (store.answers[index()]?.length ?? 0) > 0
- }
- return (
- <box
- paddingLeft={1}
- paddingRight={1}
- backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
- onMouseUp={() => selectTab(index())}
- >
- <text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
- {q.header}
- </text>
- </box>
- )
- }}
- </For>
- <box
- paddingLeft={1}
- paddingRight={1}
- backgroundColor={confirm() ? theme.accent : theme.backgroundElement}
- onMouseUp={() => selectTab(questions().length)}
- >
- <text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
- </box>
- </box>
- </Show>
- <Show when={!confirm()}>
- <box paddingLeft={1} gap={1}>
- <box>
- <text fg={theme.text}>
- {question()?.question}
- {multi() ? " (select all that apply)" : ""}
- </text>
- </box>
- <box>
- <For each={options()}>
- {(opt, i) => {
- const active = () => i() === store.selected
- const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
- return (
- <box onMouseOver={() => moveTo(i())} onMouseUp={() => selectOption()}>
- <box flexDirection="row" gap={1}>
- <box backgroundColor={active() ? theme.backgroundElement : undefined}>
- <text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
- {multi()
- ? `${i() + 1}. [${picked() ? "✓" : " "}] ${opt.label}`
- : `${i() + 1}. ${opt.label}`}
- </text>
- </box>
- <Show when={!multi()}>
- <text fg={theme.success}>{picked() ? "✓" : ""}</text>
- </Show>
- </box>
- <box paddingLeft={3}>
- <text fg={theme.textMuted}>{opt.description}</text>
- </box>
- </box>
- )
- }}
- </For>
- <Show when={custom()}>
- <box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
- <box flexDirection="row" gap={1}>
- <box backgroundColor={other() ? theme.backgroundElement : undefined}>
- <text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
- {multi()
- ? `${options().length + 1}. [${customPicked() ? "✓" : " "}] Type your own answer`
- : `${options().length + 1}. Type your own answer`}
- </text>
- </box>
- <Show when={!multi()}>
- <text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
- </Show>
- </box>
- <Show when={store.editing}>
- <box paddingLeft={3}>
- <textarea
- ref={(val: TextareaRenderable) => {
- textarea = val
- queueMicrotask(() => {
- val.focus()
- val.gotoLineEnd()
- })
- }}
- initialValue={input()}
- placeholder="Type your own answer"
- textColor={theme.text}
- focusedTextColor={theme.text}
- cursorColor={theme.primary}
- keyBindings={bindings()}
- />
- </box>
- </Show>
- <Show when={!store.editing && input()}>
- <box paddingLeft={3}>
- <text fg={theme.textMuted}>{input()}</text>
- </box>
- </Show>
- </box>
- </Show>
- </box>
- </box>
- </Show>
- <Show when={confirm() && !single()}>
- <box paddingLeft={1}>
- <text fg={theme.text}>Review</text>
- </box>
- <For each={questions()}>
- {(q, index) => {
- const value = () => store.answers[index()]?.join(", ") ?? ""
- const answered = () => Boolean(value())
- return (
- <box paddingLeft={1}>
- <text>
- <span style={{ fg: theme.textMuted }}>{q.header}:</span>{" "}
- <span style={{ fg: answered() ? theme.text : theme.error }}>
- {answered() ? value() : "(not answered)"}
- </span>
- </text>
- </box>
- )
- }}
- </For>
- </Show>
- </box>
- <box
- flexDirection="row"
- flexShrink={0}
- gap={1}
- paddingLeft={2}
- paddingRight={3}
- paddingBottom={1}
- justifyContent="space-between"
- >
- <box flexDirection="row" gap={2}>
- <Show when={!single()}>
- <text fg={theme.text}>
- {"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
- </text>
- </Show>
- <Show when={!confirm()}>
- <text fg={theme.text}>
- {"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
- </text>
- </Show>
- <text fg={theme.text}>
- enter{" "}
- <span style={{ fg: theme.textMuted }}>
- {confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
- </span>
- </text>
- <text fg={theme.text}>
- esc <span style={{ fg: theme.textMuted }}>dismiss</span>
- </text>
- </box>
- </box>
- </box>
- )
- }
|