feat(core): add session form service (#34855)
This commit is contained in:
parent
460cdc5aec
commit
7ebd344fa2
67 changed files with 7862 additions and 5223 deletions
|
|
@ -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] : []))),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
30
packages/tui/src/util/question-form.ts
Normal file
30
packages/tui/src/util/question-form.ts
Normal 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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue