feat(core): add session form service (#34855)

This commit is contained in:
Aiden Cline 2026-07-02 17:15:18 -05:00 committed by GitHub
commit 7ebd344fa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 7862 additions and 5223 deletions

View file

@ -8,7 +8,6 @@ import type {
PermissionSavedInfo,
PermissionV2Request,
ProviderV2Info,
QuestionV2Request,
ReferenceInfo,
SessionMessage,
SessionMessageAssistant,
@ -24,6 +23,7 @@ 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"
export type DataSessionStatus = "idle" | "running"
@ -51,7 +51,7 @@ type Data = {
status: Record<string, DataSessionStatus>
message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionV2Request[]>
question: Record<string, QuestionForm[]>
}
project: {
permission: Record<string, PermissionSavedInfo[]>
@ -567,21 +567,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
),
)
break
case "question.v2.asked":
if (store.session.question[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
setStore("session", "question", event.data.sessionID, [
...(store.session.question[event.data.sessionID] ?? []),
event.data,
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] ?? []),
event.data.form,
])
break
case "question.v2.replied":
case "question.v2.rejected":
case "form.replied":
case "form.cancelled":
setStore(
"session",
"question",
event.data.sessionID,
(store.session.question[event.data.sessionID] ?? []).filter(
(request) => request.id !== event.data.requestID,
(request) => request.id !== event.data.id,
),
)
break
@ -709,7 +710,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.question[sessionID]
},
async refresh(sessionID: string) {
setStore("session", "question", sessionID, mutable(await sdk.api.question.list({ sessionID })))
setStore(
"session",
"question",
sessionID,
mutable((await sdk.api.form.list({ sessionID })).data.flatMap((form) => (isQuestionForm(form) ? [form] : []))),
)
},
},
},

View file

@ -1,6 +1,7 @@
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"
@ -46,6 +47,21 @@ const tui: TuiPlugin = async (api) => {
questions.delete(event.data.requestID)
})
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")
})
api.event.on("form.replied", (event) => {
questions.delete(event.data.id)
})
api.event.on("form.cancelled", (event) => {
questions.delete(event.data.id)
})
api.event.on("permission.asked", (event) => {
if (permissions.has(event.data.id)) return
permissions.add(event.data.id)

View file

@ -3,28 +3,31 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
import { useRenderer } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionV2Answer, QuestionV2Request } from "@opencode-ai/sdk/v2"
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: QuestionV2Request; directory?: string }) {
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.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
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 QuestionV2Answer[],
answers: [] as QuestionAnswer[],
custom: [] as string[],
selected: 0,
editing: false,
@ -38,7 +41,7 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?:
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 multi = createMemo(() => question()?.type === "multiselect")
const customPicked = createMemo(() => {
const value = input()
if (!value) return false
@ -47,18 +50,35 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?:
function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? [])
void sdk.api.question.reply({
sessionID: props.request.sessionID,
requestID: props.request.id,
answers,
})
settle("reply", () =>
sdk.api.form.reply({
sessionID: props.request.sessionID,
formID: props.request.id,
answer: questionAnswer(questions(), answers),
}),
)
}
function reject() {
void sdk.api.question.reject({
sessionID: props.request.sessionID,
requestID: props.request.id,
})
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) {
@ -71,11 +91,13 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?:
setStore("custom", inputs)
}
if (single()) {
void sdk.api.question.reply({
sessionID: props.request.sessionID,
requestID: props.request.id,
answers: [[answer]],
})
settle("reply", () =>
sdk.api.form.reply({
sessionID: props.request.sessionID,
formID: props.request.id,
answer: questionAnswer(questions(), [[answer]]),
}),
)
return
}
setStore("tab", store.tab + 1)
@ -328,7 +350,7 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?:
: theme.textMuted
}
>
{q.header}
{q.description ?? q.title}
</text>
</box>
)
@ -356,7 +378,7 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?:
<box paddingLeft={1} gap={1}>
<box>
<text fg={theme.text}>
{question()?.question}
{question()?.title}
{multi() ? " (select all that apply)" : ""}
</text>
</box>
@ -466,7 +488,7 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?:
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.textMuted }}>{q.header}:</span>{" "}
<span style={{ fg: theme.textMuted }}>{q.description ?? q.title}:</span>{" "}
<span style={{ fg: answered() ? theme.text : theme.error }}>
{answered() ? value() : "(not answered)"}
</span>
@ -507,6 +529,12 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?:
<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

@ -0,0 +1,30 @@
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"
}