369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
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 other = createMemo(() => 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)
|
|
}
|
|
|
|
const dialog = useDialog()
|
|
|
|
useKeyboard((evt) => {
|
|
// When editing "Other" textarea
|
|
if (store.editing && !confirm()) {
|
|
if (evt.name === "escape") {
|
|
evt.preventDefault()
|
|
setStore("editing", false)
|
|
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]
|
|
if (prev) {
|
|
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
|
|
}
|
|
if (!prev) {
|
|
answers[store.tab] = []
|
|
}
|
|
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()
|
|
const next = (store.tab - 1 + tabs()) % tabs()
|
|
setStore("tab", next)
|
|
setStore("selected", 0)
|
|
}
|
|
|
|
if (evt.name === "right" || evt.name === "l") {
|
|
evt.preventDefault()
|
|
const next = (store.tab + 1) % tabs()
|
|
setStore("tab", next)
|
|
setStore("selected", 0)
|
|
}
|
|
|
|
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 + 1 // options + "Other"
|
|
|
|
if (evt.name === "up" || evt.name === "k") {
|
|
evt.preventDefault()
|
|
setStore("selected", (store.selected - 1 + total) % total)
|
|
}
|
|
|
|
if (evt.name === "down" || evt.name === "j") {
|
|
evt.preventDefault()
|
|
setStore("selected", (store.selected + 1) % total)
|
|
}
|
|
|
|
if (evt.name === "return") {
|
|
evt.preventDefault()
|
|
if (other()) {
|
|
if (!multi()) {
|
|
setStore("editing", true)
|
|
return
|
|
}
|
|
const value = input()
|
|
if (value && customPicked()) {
|
|
toggle(value)
|
|
return
|
|
}
|
|
setStore("editing", true)
|
|
return
|
|
}
|
|
const opt = opts[store.selected]
|
|
if (!opt) return
|
|
if (multi()) {
|
|
toggle(opt.label)
|
|
return
|
|
}
|
|
pick(opt.label)
|
|
}
|
|
|
|
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}
|
|
>
|
|
<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}>
|
|
<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>
|
|
<box flexDirection="row" gap={1}>
|
|
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
|
|
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
|
|
{i() + 1}. {opt.label}
|
|
</text>
|
|
</box>
|
|
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
|
|
</box>
|
|
<box paddingLeft={3}>
|
|
<text fg={theme.textMuted}>{opt.description}</text>
|
|
</box>
|
|
</box>
|
|
)
|
|
}}
|
|
</For>
|
|
<box>
|
|
<box flexDirection="row" gap={1}>
|
|
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
|
|
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
|
|
{options().length + 1}. Type your own answer
|
|
</text>
|
|
</box>
|
|
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
|
|
</box>
|
|
<Show when={store.editing}>
|
|
<box paddingLeft={3}>
|
|
<textarea
|
|
ref={(val: TextareaRenderable) => (textarea = val)}
|
|
focused
|
|
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>
|
|
</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 flexDirection="row" gap={1} paddingLeft={1}>
|
|
<text fg={theme.textMuted}>{q.header}:</text>
|
|
<text fg={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</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>
|
|
)
|
|
}
|