feat(core): MCP elicitation support (#35064)

This commit is contained in:
Aiden Cline 2026-07-02 23:25:54 -05:00 committed by GitHub
commit efcf2c3f5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1170 additions and 668 deletions

View file

@ -18,12 +18,15 @@ import type {
Shell,
SkillV2Info,
V2Event,
FormFormInfo,
FormUrlInfo,
} from "@opencode-ai/sdk/v2"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { createSignal, onCleanup } from "solid-js"
import { isQuestionForm, type QuestionForm } from "../util/question-form"
type FormInfo = FormFormInfo | FormUrlInfo
export type DataSessionStatus = "idle" | "running"
@ -35,6 +38,7 @@ type LocationData = {
model?: ModelV2Info[]
provider?: ProviderV2Info[]
reference?: ReferenceInfo[]
form?: FormInfo[]
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, Shell>
@ -51,7 +55,7 @@ type Data = {
status: Record<string, DataSessionStatus>
message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionForm[]>
form: Record<string, FormInfo[]>
}
project: {
permission: Record<string, PermissionSavedInfo[]>
@ -85,7 +89,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
status: {},
message: {},
permission: {},
question: {},
form: {},
},
project: {
permission: {},
@ -181,17 +185,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const info = store.session.info[sessionID]
if (!info) return
const rootID = resolveRoot(sessionID)
setStore("session", "family", produce((draft) => {
if (sessionID !== rootID && draft[sessionID]) {
const members = draft[rootID] ??= []
for (const id of draft[sessionID]) {
if (!members.includes(id)) members.push(id)
setStore(
"session",
"family",
produce((draft) => {
if (sessionID !== rootID && draft[sessionID]) {
const members = (draft[rootID] ??= [])
for (const id of draft[sessionID]) {
if (!members.includes(id)) members.push(id)
}
delete draft[sessionID]
}
delete draft[sessionID]
}
const family = draft[rootID] ??= []
if (!family.includes(sessionID)) family.push(sessionID)
}))
const family = (draft[rootID] ??= [])
if (!family.includes(sessionID)) family.push(sessionID)
}),
)
}
function handleEvent(event: V2Event) {
@ -568,10 +576,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
break
case "form.created":
if (!isQuestionForm(event.data.form)) break
if (store.session.question[event.data.form.sessionID]?.some((request) => request.id === event.data.form.id)) break
setStore("session", "question", event.data.form.sessionID, [
...(store.session.question[event.data.form.sessionID] ?? []),
if (event.data.form.sessionID === "global") {
const key = locationKey(event.location ?? defaultLocation())
if (store.location[key]?.form?.some((request) => request.id === event.data.form.id)) break
setStore("location", key, (data) => ({ ...data, form: [...(data?.form ?? []), event.data.form] }))
break
}
if (store.session.form[event.data.form.sessionID]?.some((request) => request.id === event.data.form.id)) break
setStore("session", "form", event.data.form.sessionID, [
...(store.session.form[event.data.form.sessionID] ?? []),
event.data.form,
])
break
@ -579,11 +592,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "form.cancelled":
setStore(
"session",
"question",
"form",
event.data.sessionID,
(store.session.question[event.data.sessionID] ?? []).filter(
(request) => request.id !== event.data.id,
),
(store.session.form[event.data.sessionID] ?? []).filter((request) => request.id !== event.data.id),
)
if (event.location) {
setStore("location", locationKey(event.location), (data) => ({
...data,
form: (data?.form ?? []).filter((request) => request.id !== event.data.id),
}))
break
}
setStore(
"location",
produce((draft) => {
for (const data of Object.values(draft))
data.form = data.form?.filter((request) => request.id !== event.data.id)
}),
)
break
case "shell.created":
@ -691,8 +716,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return liveByID.get(message.id) ?? message
}),
...live.filter((message) => !loadedIDs.has(message.id)),
]
.toSorted((a, b) => a.time.created - b.time.created)
].toSorted((a, b) => a.time.created - b.time.created)
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, messages)
},
@ -705,17 +729,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "permission", sessionID, mutable(await sdk.api.permission.list({ sessionID })))
},
},
question: {
form: {
list(sessionID: string) {
return store.session.question[sessionID]
return store.session.form[sessionID]
},
async refresh(sessionID: string) {
setStore(
"session",
"question",
sessionID,
mutable((await sdk.api.form.list({ sessionID })).data.flatMap((form) => (isQuestionForm(form) ? [form] : []))),
)
setStore("session", "form", sessionID, mutable((await sdk.api.form.list({ sessionID })).data))
},
},
question: {
list(sessionID: string) {
return store.session.form[sessionID]
},
async refresh(sessionID: string) {
setStore("session", "form", sessionID, mutable((await sdk.api.form.list({ sessionID })).data))
},
},
},
@ -806,6 +833,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("location", key, { ...store.location[key], mcp: result.data.data })
},
},
form: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.form
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.form.request.list(
{ location: locationQuery(ref) },
{ throwOnError: true },
)
const key = locationKey(result.data.location)
setStore("location", key, {
...store.location[key],
form: result.data.data.filter((form) => form.sessionID === "global"),
})
},
},
model: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.model
@ -882,6 +925,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.agent.refresh(),
result.location.integration.refresh(),
result.location.mcp.refresh(),
result.location.form.refresh(),
result.location.model.refresh(),
result.location.provider.refresh(),
result.location.reference.refresh(),

View file

@ -1,7 +1,6 @@
import type { V2Event } from "@opencode-ai/sdk/v2"
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { isQuestionForm } from "../../util/question-form"
const id = "internal:notifications"
@ -48,10 +47,14 @@ const tui: TuiPlugin = async (api) => {
})
api.event.on("form.created", (event) => {
if (!isQuestionForm(event.data.form)) return
if (questions.has(event.data.form.id)) return
questions.add(event.data.form.id)
notify(api, event.data.form.sessionID, "Question needs input", "question")
notify(
api,
event.data.form.sessionID === "global" ? undefined : event.data.form.sessionID,
"Form needs input",
"question",
)
})
api.event.on("form.replied", (event) => {

View file

@ -0,0 +1,710 @@
import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import type { FormAnswer, FormFormInfo, FormUrlInfo, LocationRef } from "@opencode-ai/sdk/v2"
import type { TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../ui/border"
import { useBindings, useOpencodeModeStack } from "../../keymap"
import { useTuiConfig } from "../../config"
import { errorMessage } from "../../util/error"
import { Locale } from "../../util/locale"
const FORM_MODE = "form"
type FormField = FormFormInfo["fields"][number]
type PromptForm = FormFormInfo | FormUrlInfo
type FieldOption = { value: string; label: string; description?: string; custom?: boolean }
type FieldTab = { type: "field"; index: number } | { type: "ellipsis"; key: string }
export function FormPrompt(props: { request: PromptForm; location?: LocationRef }) {
const sdk = useSDK()
const { theme } = useTheme()
const renderer = useRenderer()
const tuiConfig = useTuiConfig()
const modeStack = useOpencodeModeStack()
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [settling, setSettling] = createSignal<"reply" | "cancel">()
const [settleError, setSettleError] = createSignal<string>()
const [store, setStore] = createStore({
tab: 0,
selected: 0,
editing: false,
text: {} as Record<string, string>,
boolean: {} as Record<string, boolean>,
multiselect: {} as Record<string, string[]>,
custom: {} as Record<string, string>,
})
let textarea: TextareaRenderable | undefined
const fields = createMemo(() => (props.request.mode === "form" ? props.request.fields : []))
const single = createMemo(() => fields().length === 1 && fields()[0]?.type !== "multiselect")
const compactTabs = createMemo(() => {
const items = fields()
return items.length > 5 || items.some((item) => fieldLabel(item).length > 18)
})
const fieldTabItems = createMemo(() => fieldTabs(fields(), store.tab, compactTabs()))
const answeredCount = createMemo(() => fields().filter((item) => fieldText(item).length > 0).length)
const tabs = createMemo(() => (single() ? 1 : fields().length + 1))
const confirm = createMemo(() => !single() && store.tab === fields().length)
const field = createMemo(() => fields()[store.tab])
const options = createMemo(() => fieldOptions(field()))
const currentText = createMemo(() => fieldText(field()))
const footerAction = createMemo(() => {
if (confirm() || props.request.mode === "url") return "submit"
if (field()?.type === "multiselect") return "toggle"
if (options().length > 0) return single() ? "submit" : "confirm"
return "type"
})
function submit() {
const result = props.request.mode === "url" ? { answer: {} } : buildAnswer(fields())
if ("error" in result) {
setSettleError(result.error)
return
}
settle("reply", () =>
sdk.client.v2.session.form.reply(
{
sessionID: props.request.sessionID,
formID: props.request.id,
location: locationQuery(props.location),
formReply: { answer: result.answer },
},
{ throwOnError: true },
),
)
}
function cancel() {
settle("cancel", () =>
sdk.client.v2.session.form.cancel(
{
sessionID: props.request.sessionID,
formID: props.request.id,
location: locationQuery(props.location),
},
{ throwOnError: true },
),
)
}
function settle(kind: "reply" | "cancel", run: () => Promise<unknown>) {
if (settling()) return
setSettling(kind)
setSettleError(undefined)
void run()
.catch((error) => setSettleError(errorMessage(error)))
.finally(() => setSettling(undefined))
}
function selectTab(index: number) {
setStore("tab", index)
setStore("selected", selectedOptionIndex(fields()[index]))
setStore("editing", false)
}
function moveTo(index: number) {
setStore("selected", index)
}
function moveOption(count: number, index: number) {
if (count === 0) return
moveTo((index + count) % count)
}
function choose() {
const item = field()
if (!item) {
submit()
return
}
if (item.type === "boolean") {
setStore("boolean", item.key, store.selected === 1)
if (single()) {
submit()
return
}
nextTab()
return
}
if (item.type === "string" && fieldOptions(item).length) {
const option = fieldOptions(item)[store.selected]
if (!option) return
if (option.custom) {
setStore("editing", true)
return
}
setStore("text", item.key, option.value)
if (single()) {
submit()
return
}
nextTab()
return
}
if (item.type === "multiselect") {
const option = fieldOptions(item)[store.selected]
if (!option) return
if (option.custom) {
const value = store.custom[item.key]
if (value && (store.multiselect[item.key] ?? item.default ?? []).includes(value)) {
setStore("multiselect", item.key, toggle(store.multiselect[item.key] ?? item.default ?? [], value))
return
}
setStore("editing", true)
return
}
const existing = store.multiselect[item.key] ?? item.default ?? []
setStore("multiselect", item.key, toggle(existing, option.value))
return
}
setStore("editing", true)
}
function nextTab() {
selectTab(Math.min(store.tab + 1, fields().length))
}
function fieldText(item: FormField | undefined) {
if (!item) return ""
if (item.type === "boolean") return String(store.boolean[item.key] ?? item.default ?? false)
if (item.type === "multiselect") return (store.multiselect[item.key] ?? item.default ?? []).join(", ")
return store.text[item.key] ?? (item.default === undefined ? "" : String(item.default))
}
function selectedOptionIndex(item: FormField | undefined) {
const choices = fieldOptions(item)
if (!item || choices.length === 0 || item.type === "multiselect") return 0
const index = choices.findIndex((option) => option.value === fieldText(item))
if (index >= 0) return index
const customIndex = choices.findIndex((option) => option.custom === true)
return customIndex >= 0 && (store.custom[item.key] || fieldText(item)) ? customIndex : 0
}
function buildAnswer(items: FormField[]): { answer: FormAnswer } | { error: string } {
const result = items.reduce<{ answer: FormAnswer; error?: string }>(
(state, item) => {
if (state.error) return state
if (!isActive(item, state.answer)) return state
if (item.type === "boolean") {
if (store.boolean[item.key] !== undefined || item.default !== undefined || item.required) {
return {
...state,
answer: { ...state.answer, [item.key]: store.boolean[item.key] ?? item.default ?? false },
}
}
return state
}
if (item.type === "multiselect") {
const value = store.multiselect[item.key] ?? item.default ?? []
if (value.length > 0 || item.required) {
return { ...state, answer: { ...state.answer, [item.key]: value } }
}
return state
}
const text = fieldText(item).trim()
if (!text) {
if (item.required) return { ...state, answer: { ...state.answer, [item.key]: "" } }
return state
}
if (item.type === "number" || item.type === "integer") {
const value = Number(text)
if (!Number.isFinite(value)) return { ...state, error: `Expected number for ${fieldLabel(item)}` }
if (item.type === "integer" && !Number.isInteger(value))
return { ...state, error: `Expected integer for ${fieldLabel(item)}` }
return { ...state, answer: { ...state.answer, [item.key]: value } }
}
return { ...state, answer: { ...state.answer, [item.key]: text } }
},
{ answer: {} },
)
if (result.error) return { error: result.error }
return { answer: result.answer }
}
onMount(() => {
setStore("selected", selectedOptionIndex(field()))
const popMode = modeStack.push(FORM_MODE)
onCleanup(popMode)
})
useBindings(() => ({
mode: FORM_MODE,
enabled: store.editing,
commands: [
{
name: "form.clear",
title: "Clear form input",
category: "Form",
run() {
const text = textarea?.plainText ?? ""
if (!text) {
setStore("editing", false)
return
}
textarea?.setText("")
},
},
],
bindings: [
{
key: "escape",
desc: "Cancel edit",
group: "Form",
cmd: () => setStore("editing", false),
},
...tuiConfig.keybinds.get("prompt.clear"),
{
key: "return",
desc: "Save input",
group: "Form",
cmd: () => {
const item = field()
if (!item) return
const text = textarea?.plainText?.trim() ?? ""
if (item.type === "multiselect") {
const previous = store.custom[item.key]
const existing = store.multiselect[item.key] ?? item.default ?? []
const withoutPrevious = previous ? existing.filter((value) => value !== previous) : existing
setStore(
"multiselect",
item.key,
text && !withoutPrevious.includes(text) ? [...withoutPrevious, text] : withoutPrevious,
)
setStore("custom", item.key, text)
setStore("editing", false)
return
}
setStore("text", item.key, text)
setStore("custom", item.key, text)
setStore("editing", false)
if (single()) {
submit()
return
}
nextTab()
},
},
],
}))
useBindings(() => {
const item = field()
const count = optionCount(item)
return {
mode: FORM_MODE,
enabled: !store.editing,
commands: [
{
name: "app.exit",
title: "Cancel form",
category: "Form",
run: cancel,
},
],
bindings: [
{ key: "left", desc: "Previous field", group: "Form", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
{ key: "h", desc: "Previous field", group: "Form", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
{ key: "right", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) },
{ key: "l", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) },
{
key: "tab",
desc: "Next field",
group: "Form",
cmd: ({ event }: { event: { shift: boolean } }) => {
selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
},
},
...Array.from({ length: Math.min(count, 9) }, (_, index) => ({
key: String(index + 1),
desc: `Select option ${index + 1}`,
group: "Form",
cmd: () => {
moveTo(index)
choose()
},
})),
...(count > 0
? [
{ key: "up", desc: "Previous option", group: "Form", cmd: () => moveOption(count, store.selected - 1) },
{ key: "k", desc: "Previous option", group: "Form", cmd: () => moveOption(count, store.selected - 1) },
{ key: "down", desc: "Next option", group: "Form", cmd: () => moveOption(count, store.selected + 1) },
{ key: "j", desc: "Next option", group: "Form", cmd: () => moveOption(count, store.selected + 1) },
]
: []),
{
key: "return",
desc: confirm() || props.request.mode === "url" ? "Submit form" : count > 0 ? "Select" : "Edit",
group: "Form",
cmd: choose,
},
{ key: "escape", desc: "Cancel form", group: "Form", cmd: cancel },
...tuiConfig.keybinds.get("app.exit"),
],
}
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.accent}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<Switch>
<Match when={props.request.mode === "url"}>
<box paddingLeft={1} gap={1}>
<text fg={theme.text}>{props.request.title ?? "Open URL request"}</text>
<Show when={formMessage(props.request)}>
<text fg={theme.textMuted} wrapMode="word">
{formMessage(props.request)}
</text>
</Show>
<text fg={theme.textMuted}>Open this URL, complete the request, then press enter:</text>
<text fg={theme.secondary}>{props.request.mode === "url" ? props.request.url : ""}</text>
</box>
</Match>
<Match when={props.request.mode === "form"}>
<Show when={props.request.mode === "form" && props.request.title}>
<box paddingLeft={1}>
<text fg={theme.text} wrapMode="word">
{props.request.mode === "form" ? props.request.title : ""}
</text>
</box>
</Show>
<Show when={formMessage(props.request)}>
<box paddingLeft={1}>
<text fg={theme.textMuted} wrapMode="word">
{formMessage(props.request)}
</text>
</box>
</Show>
<Show when={!single()}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<For each={fieldTabItems()}>
{(tab) => {
if (tab.type === "ellipsis")
return (
<box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted} wrapMode="none">
...
</text>
</box>
)
const item = () => fields()[tab.index]
const active = () => tab.index === store.tab
const answered = () => fieldText(item()).length > 0
const title = () => (compactTabs() ? String(tab.index + 1) : Locale.truncate(fieldLabel(item()), 18))
return (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={
active()
? theme.accent
: tabHover() === tab.index
? theme.backgroundElement
: theme.backgroundPanel
}
onMouseOver={() => setTabHover(tab.index)}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectTab(tab.index)
}}
>
<text
fg={
active() ? selectedForeground(theme, theme.accent) : answered() ? theme.text : theme.textMuted
}
wrapMode="none"
>
{title()}
</text>
</box>
)
}}
</For>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={
confirm() ? theme.accent : tabHover() === "confirm" ? theme.backgroundElement : theme.backgroundPanel
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectTab(fields().length)
}}
>
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted} wrapMode="none">
{compactTabs() ? "Review" : "Confirm"}
</text>
</box>
</box>
</Show>
<Show when={compactTabs() && !confirm()}>
<box paddingLeft={1}>
<text fg={theme.textMuted} wrapMode="none">
{`Field ${store.tab + 1} of ${fields().length} - ${answeredCount()} answered`}
</text>
</box>
</Show>
<Show when={!confirm()}>
<FieldEditor
field={field()}
selected={store.selected}
value={currentText()}
customValue={field() ? store.custom[field()!.key] : ""}
editing={store.editing}
options={options()}
picked={(value) => picked(field(), value)}
moveTo={moveTo}
choose={choose}
textarea={(value) => {
textarea = value
}}
/>
</Show>
<Show when={confirm()}>
<box paddingLeft={1}>
<text fg={theme.text}>Review</text>
</box>
<box>
<For each={fields()}>
{(item) => {
const value = () => fieldText(item)
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.textMuted }}>{fieldLabel(item)}:</span>{" "}
<span style={{ fg: value() ? theme.text : theme.error }}>{value() || "(not answered)"}</span>
</text>
</box>
)
}}
</For>
</box>
</Show>
</Match>
</Switch>
</box>
<box
flexDirection="row"
flexShrink={0}
gap={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent="space-between"
>
<box flexDirection="row" gap={2}>
<Show when={props.request.mode === "form" && !single()}>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
</text>
</Show>
<Show when={props.request.mode === "form" && !confirm() && optionCount(field()) > 0}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
</Show>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>{footerAction()}</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>cancel</span>
</text>
<Show when={settling()}>
<text fg={theme.textMuted}>{settling() === "cancel" ? "Cancelling..." : "Submitting..."}</text>
</Show>
<Show when={settleError()}>
<text fg={theme.error}>{settleError()}</text>
</Show>
</box>
</box>
</box>
)
function picked(item: FormField | undefined, value: string) {
if (!item) return false
if (value === CUSTOM_OPTION_VALUE) return Boolean(store.custom[item.key])
if (item.type === "multiselect") return (store.multiselect[item.key] ?? item.default ?? []).includes(value)
return fieldText(item) === value
}
}
function FieldEditor(props: {
field?: FormField
selected: number
value: string
customValue: string
editing: boolean
options: FieldOption[]
picked: (value: string) => boolean
moveTo: (index: number) => void
choose: () => void
textarea: (value: TextareaRenderable) => void
}) {
const { theme } = useTheme()
return (
<box paddingLeft={1} gap={1}>
<text fg={theme.text}>{fieldLabel(props.field)}</text>
<Show when={fieldDescription(props.field)}>
<text fg={theme.textMuted}>{props.field?.description}</text>
</Show>
<Switch>
<Match when={props.options.length > 0}>
<For each={props.options}>
{(option, index) => {
const active = () => index() === props.selected
const picked = () => props.picked(option.value)
const multi = () => props.field?.type === "multiselect"
return (
<box
onMouseOver={() => props.moveTo(index())}
onMouseDown={() => props.moveTo(index())}
onMouseUp={() => props.choose()}
>
<box flexDirection="row">
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
<text
fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}
>{`${index() + 1}.`}</text>
</box>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
{multi() ? `[${picked() ? "✓" : " "}] ${option.label}` : option.label}
</text>
</box>
<Show when={!multi()}>
<text fg={theme.success}>{picked() ? " ✓" : ""}</text>
</Show>
</box>
<Show when={option.description}>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{option.description}</text>
</box>
</Show>
</box>
)
}}
</For>
<Show when={props.editing}>
<box paddingLeft={3}>
<FormTextarea value={props.customValue} placeholder="Type custom value" textarea={props.textarea} />
</box>
</Show>
<Show when={!props.editing && props.customValue}>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{props.customValue}</text>
</box>
</Show>
</Match>
<Match when={props.editing}>
<FormTextarea value={props.value} placeholder="Type value" textarea={props.textarea} />
</Match>
<Match when={true}>
<text fg={props.value ? theme.text : theme.textMuted}>{props.value || "Press enter to type"}</text>
</Match>
</Switch>
</box>
)
}
function FormTextarea(props: { value: string; placeholder: string; textarea: (value: TextareaRenderable) => void }) {
const { theme } = useTheme()
return (
<textarea
ref={(value: TextareaRenderable) => {
props.textarea(value)
value.traits = { status: "FORM" }
queueMicrotask(() => {
value.focus()
value.gotoLineEnd()
})
}}
initialValue={props.value}
placeholder={props.placeholder}
placeholderColor={theme.textMuted}
minHeight={1}
maxHeight={6}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
/>
)
}
function fieldTabs(fields: FormField[], active: number, compact: boolean): FieldTab[] {
if (!compact) return fields.map((_, index) => ({ type: "field", index }))
if (fields.length <= 7) return fields.map((_, index) => ({ type: "field", index }))
const last = fields.length - 1
const start = Math.max(0, Math.min(active - 2, last - 4))
const window = Array.from({ length: 5 }, (_, offset) => start + offset).filter((index) => index <= last)
const indexes = [...new Set([0, ...window, last])].toSorted((a, b) => a - b)
return indexes.flatMap((index, position): FieldTab[] => {
const previous = indexes[position - 1]
const tab: FieldTab = { type: "field", index }
if (previous !== undefined && index - previous > 1) return [{ type: "ellipsis", key: `${previous}-${index}` }, tab]
return [tab]
})
}
function fieldOptions(field: FormField | undefined) {
if (!field) return []
if (field.type === "boolean")
return [
{ value: "false", label: "No" },
{ value: "true", label: "Yes" },
]
if (field.type === "string") return withCustomOption(field.options ?? [], field.custom)
if (field.type === "multiselect") return withCustomOption(field.options, field.custom)
return []
}
const CUSTOM_OPTION_VALUE = "__custom__"
function withCustomOption(options: FieldOption[], custom: boolean | undefined): FieldOption[] {
if (options.length > 0 && custom !== true) return options
return [...options, { value: CUSTOM_OPTION_VALUE, label: "Type custom value", custom: true }]
}
function formMessage(form: PromptForm) {
const message = form.metadata?.message
return typeof message === "string" && message !== form.title ? message : undefined
}
function optionCount(field: FormField | undefined) {
return fieldOptions(field).length
}
function fieldLabel(field: FormField | undefined) {
return field?.title ?? field?.description ?? field?.key ?? "Form"
}
function fieldDescription(field: FormField | undefined) {
if (!field?.description || field.description === fieldLabel(field)) return undefined
return field.description
}
function isActive(field: FormField, answer: FormAnswer) {
if (!field.when) return true
const value = answer[field.when.key]
if (field.when.op === "eq") return value === field.when.value
return value !== field.when.value
}
function toggle(values: readonly string[], value: string) {
return values.includes(value) ? values.filter((item) => item !== value) : [...values, value]
}
function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
}

View file

@ -58,7 +58,7 @@ import { usePromptRef } from "../../context/prompt"
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { FormPrompt } from "./form"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { sessionEpilogue } from "../../util/presentation"
import { useTuiConfig } from "../../config"
@ -181,15 +181,20 @@ export function Session() {
(sessionID) => data.session.permission.list(sessionID) ?? [],
)
})
const questions = createMemo(() => {
const sessionForms = createMemo(() => {
if (session()?.parentID) return []
return data.session.question.list(route.sessionID) ?? []
return data.session.form.list(route.sessionID) ?? []
})
const locationForms = createMemo(() => {
if (session()?.parentID) return []
return data.location.form.list(location()) ?? []
})
const forms = createMemo(() => [...sessionForms(), ...locationForms()])
const [composer, setComposer] = createStore({
open: false,
tab: undefined as string | undefined,
})
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
const disabled = createMemo(() => permissions().length > 0 || forms().length > 0)
const pending = createMemo(() => {
const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
@ -246,7 +251,7 @@ export function Session() {
await Promise.all([
data.session.refresh(sessionID),
data.session.permission.refresh(sessionID),
data.session.question.refresh(sessionID),
data.session.form.refresh(sessionID),
])
const info = data.session.get(sessionID)
if (!info) {
@ -258,6 +263,7 @@ export function Session() {
navigate({ type: "home" })
return
}
await data.location.form.refresh(info.location)
project.workspace.set(info.location.workspaceID)
editor.reconnect(info.location.directory)
@ -939,8 +945,8 @@ export function Session() {
<Match when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
</Match>
<Match when={questions().length > 0}>
<QuestionPrompt request={questions()[0]} directory={session()?.location.directory} />
<Match when={forms().length > 0}>
<FormPrompt request={forms()[0]} location={location()} />
</Match>
<Match when={!disabled()}>
<pluginRuntime.Slot
@ -1387,13 +1393,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
>
<text fg={theme.text}>{props.message.text}</text>
<Show when={files().length}>
<box
flexDirection="row"
paddingBottom={metadataVisible() ? 1 : 0}
paddingTop={1}
gap={1}
flexWrap="wrap"
>
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
{(file) => {
const directory = file.mime === "application/x-directory"
@ -1723,7 +1723,8 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
return Boolean(shellID && data.shell.get(shellID))
}
if (display() === "subagent") {
const sessionID = stringValue(props.part.state.structured.sessionID) ?? stringValue(props.part.state.structured.sessionId)
const sessionID =
stringValue(props.part.state.structured.sessionID) ?? stringValue(props.part.state.structured.sessionId)
return Boolean(sessionID && data.session.status(sessionID) === "running")
}
return false

View file

@ -1,542 +0,0 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useRenderer } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../ui/border"
import { useTuiConfig } from "../../config"
import { useBindings, useOpencodeModeStack } from "../../keymap"
import { questionAnswer, type QuestionAnswer, type QuestionForm } from "../../util/question-form"
import { errorMessage } from "../../util/error"
const QUESTION_MODE = "question"
export function QuestionPrompt(props: { request: QuestionForm; directory?: string }) {
const sdk = useSDK()
const { theme } = useTheme()
const renderer = useRenderer()
const tuiConfig = useTuiConfig()
const modeStack = useOpencodeModeStack()
const questions = createMemo(() => props.request.fields)
const single = createMemo(() => questions().length === 1 && questions()[0]?.type !== "multiselect")
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [settling, setSettling] = createSignal<"reply" | "cancel">()
const [settleError, setSettleError] = createSignal<string>()
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()?.type === "multiselect")
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] ?? [])
settle("reply", () =>
sdk.api.form.reply({
sessionID: props.request.sessionID,
formID: props.request.id,
answer: questionAnswer(questions(), answers),
}),
)
}
function reject() {
settle("cancel", () =>
sdk.api.form.cancel({
sessionID: props.request.sessionID,
formID: props.request.id,
}),
)
}
function settle(kind: "reply" | "cancel", run: () => Promise<unknown>) {
if (settling()) return
setSettling(kind)
setSettleError(undefined)
void run()
.catch((error) => {
setSettleError(errorMessage(error))
})
.finally(() => {
setSettling(undefined)
})
}
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()) {
settle("reply", () =>
sdk.api.form.reply({
sessionID: props.request.sessionID,
formID: props.request.id,
answer: questionAnswer(questions(), [[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)
}
onMount(() => {
const popMode = modeStack.push(QUESTION_MODE)
onCleanup(popMode)
})
useBindings(() => ({
mode: QUESTION_MODE,
enabled: store.editing && !confirm(),
commands: [
{
name: "prompt.clear",
title: "Clear answer edit",
category: "Question",
run() {
const text = textarea?.plainText ?? ""
if (!text) {
setStore("editing", false)
return
}
textarea?.setText("")
},
},
],
bindings: [
{
key: "escape",
desc: "Cancel answer edit",
group: "Question",
cmd: () => {
setStore("editing", false)
},
},
...tuiConfig.keybinds.get("prompt.clear"),
{
key: "return",
desc: "Submit answer edit",
group: "Question",
cmd: () => {
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)
},
},
],
}))
useBindings(() => {
const opts = options()
const total = opts.length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
return {
mode: QUESTION_MODE,
enabled: !store.editing,
commands: [
{
name: "app.exit",
title: "Reject question",
category: "Question",
run() {
reject()
},
},
],
bindings: [
{
key: "left",
desc: "Previous question",
group: "Question",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
},
{
key: "h",
desc: "Previous question",
group: "Question",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
},
{ key: "right", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
{ key: "l", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
{
key: "tab",
desc: "Next question",
group: "Question",
cmd: ({ event }: { event: { shift: boolean } }) => {
selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
},
},
...(confirm()
? [
{ key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() },
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
...tuiConfig.keybinds.get("app.exit"),
]
: [
...Array.from({ length: max }, (_, index) => ({
key: String(index + 1),
desc: `Select answer ${index + 1}`,
group: "Question",
cmd: () => {
moveTo(index)
selectOption()
},
})),
{
key: "up",
desc: "Previous answer",
group: "Question",
cmd: () => moveTo((store.selected - 1 + total) % total),
},
{
key: "k",
desc: "Previous answer",
group: "Question",
cmd: () => moveTo((store.selected - 1 + total) % total),
},
{ key: "down", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
{ key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
{ key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() },
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
...tuiConfig.keybinds.get("app.exit"),
]),
],
}
})
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
: tabHover() === index()
? theme.backgroundElement
: theme.backgroundPanel
}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectTab(index())
}}
>
<text
fg={
isActive()
? selectedForeground(theme, theme.accent)
: isAnswered()
? theme.text
: theme.textMuted
}
>
{q.description ?? q.title}
</text>
</box>
)
}}
</For>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={
confirm() ? theme.accent : tabHover() === "confirm" ? theme.backgroundElement : theme.backgroundPanel
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectTab(questions().length)
}}
>
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
</box>
</box>
</Show>
<Show when={!confirm()}>
<box paddingLeft={1} gap={1}>
<box>
<text fg={theme.text}>
{question()?.title}
{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())}
onMouseDown={() => moveTo(i())}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectOption()
}}
>
<box flexDirection="row">
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
{`${i() + 1}.`}
</text>
</box>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
{multi() ? `[${picked() ? "✓" : " "}] ${opt.label}` : 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)}
onMouseDown={() => moveTo(options().length)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
selectOption()
}}
>
<box flexDirection="row">
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={other() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
{`${options().length + 1}.`}
</text>
</box>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "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
val.traits = { status: "ANSWER" }
queueMicrotask(() => {
val.focus()
val.gotoLineEnd()
})
}}
initialValue={input()}
placeholder="Type your own answer"
placeholderColor={theme.textMuted}
minHeight={1}
maxHeight={6}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
/>
</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.description ?? q.title}:</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>
<Show when={settling()}>
<text fg={theme.textMuted}>{settling() === "cancel" ? "Dismissing..." : "Submitting..."}</text>
</Show>
<Show when={settleError()}>
<text fg={theme.error}>{settleError()}</text>
</Show>
</box>
</box>
</box>
)
}

View file

@ -1,30 +0,0 @@
import type { FormAnswer, FormFormInfo } from "@opencode-ai/sdk/v2"
export type QuestionField = Extract<FormFormInfo["fields"][number], { type: "string" | "multiselect" }>
export type QuestionForm = Omit<FormFormInfo, "fields"> & { fields: QuestionField[] }
export type QuestionAnswer = string[]
export function isQuestionForm(value: unknown): value is QuestionForm {
if (typeof value !== "object" || value === null) return false
const form = value as { mode?: unknown; metadata?: unknown; fields?: unknown }
if (form.mode !== "form") return false
if (typeof form.metadata !== "object" || form.metadata === null) return false
if ((form.metadata as { kind?: unknown }).kind !== "question") return false
return Array.isArray(form.fields) && form.fields.every(isQuestionField)
}
export function questionAnswer(fields: QuestionField[], answers: QuestionAnswer[]): FormAnswer {
const entries = fields.flatMap((field, index): Array<[string, string | string[]]> => {
const answer = answers[index] ?? []
if (answer.length === 0) return []
if (field.type === "multiselect") return [[field.key, answer]]
return [[field.key, answer[0] ?? ""]]
})
return Object.fromEntries(entries)
}
function isQuestionField(value: unknown): value is QuestionField {
if (typeof value !== "object" || value === null) return false
const field = value as { type?: unknown }
return field.type === "string" || field.type === "multiselect"
}